From 97b6bf5d615c5bb1a1bfa987c2ab875f9d94a1d0 Mon Sep 17 00:00:00 2001 From: Nikita Ashikhmin Date: Thu, 1 Oct 2026 21:26:21 +0400 Subject: [PATCH 01/22] feat: show live MCP server status and reconnect from /mcp /mcp now shows the live status of each MCP server in a list, not in a table. /mcp reconnect reloads the MCP configuration and shows the status after the servers start. The list shows the reconnect hint only when a server failed, was cancelled, needs authentication, or did not start. The hint says to restart the servers that are not connected. The list groups the servers by the status. The groups with a problem come first, and the disabled servers share one line. A table rendered badly in AIR. The narrow columns broke the words, and the details cells showed the raw error chains. So each item now shows the tool count or one short error. The cleanup removes the wrapper messages, the numeric codes, the Rust type paths, and the repeated messages. It keeps at most two sentences. An "e.g." or an "i.e." does not end a sentence. The cleanup removes only a real type path, such as crate::module::Type. A lone "<" and an IPv6 URL stay. A segment that repeats and extends an earlier segment replaces it. The cut never splits a surrogate pair, and an empty error shows no detail. A server that needs authentication, or a server that is not logged in, shows "not signed in". A status that the adapter does not know shows as unknown. A server name gets a backtick fence that is longer than the backticks in the name. An empty name shows as "(unnamed)". The reload errors in the notes use the same cleanup. The status list gets the session thread id, because Codex fills runtimeStatus only for a thread. Without the thread id, every status is unknown. The adapter reads all pages of the list. When Codex does not know the thread, the adapter lists the servers without the thread and does not wait for the startup. Other list errors are not retried. The reconnect sends config/mcpServer/reload. Codex applies the reload to every loaded thread. It restarts the servers that failed, stopped, or changed, and a healthy connection stays as it is. The protocol has no request to reconnect one server, so /mcp reconnect reloads all servers and tells the user so. The wait list comes from the status list after the reload, so a removed server does not hold the wait until the timeout. When a server needs authentication and the client supports URL elicitation, the adapter starts the OAuth sign-in and reloads again after a successful sign-in. A failed reload skips the sign-in. The notes show as a list under a "Reconnect:" label, above the status list. A reload error shows on one line, shortened, and without inline code. The MCP startup state is now kept per thread. A startup event of one thread no longer ends the wait of another session, and an event without a thread id counts for every thread. A wait must name its thread. The session startup reports use the session thread too. A startup wait removes its waiter at the timeout or at an abort. A startup wait and a sign-in wait reject when the Codex connection ends. The exit of the Codex process disposes the connection, and the stdio reader never fires a close event. So the waits end on onDispose and on onClose. A test kills a real child process to check this path. The command gets the abort signal of the prompt. After a cancel, it sends no update. A cancel also stops the sign-in: the sign-in closes the dialog with elicitation/complete and drops its completion wait. The adapter routes mcpServer/oauthLogin/completed once, to a waiter list by server name. Before, each sign-in replaced the notification handler, so two sign-ins at the same time lost one completion, and a declined sign-in left its handler. Now a decline or an abort removes its waiter. A session close aborts the startup wait and the sign-in of the startup report, and forgets the startup states of the session thread. A fork publishes no startup report, so it aborts its wait after the optional startup timeout. The scenario baseline stays a recording of the adapter before the AIR extensions. A new allowed difference covers the new /mcp command entry. The /mcp code is in src/mcp/. McpCommand runs the command, McpServerStatusList reads the status pages, McpStatusMarkdown formats the markdown list, and McpServerSignIn signs in to a server. McpStartupTracker keeps the startup states and the startup waits, and McpOauthCompletions keeps the sign-in waits. CodexAppServerClient only feeds them the notifications and the connection end. Doc fix: docs/mcp-startup-await-timeout.md said that, with the timeout field, the wait ends at the timeout. The wait ends when the startup completes or at the timeout, whichever comes first. --- README.md | 2 +- docs/air-extensions.md | 1 + docs/mcp-startup-await-timeout.md | 17 +- src/CodexAcpClient.ts | 25 +- src/CodexAcpServer.ts | 99 ++- src/CodexAppServerClient.ts | 145 +--- src/CodexCommands.ts | 30 +- src/CodexThreadErrors.ts | 8 + .../CodexACPAgent/CodexAcpClient.test.ts | 7 +- .../data/available-commands-build-in.json | 6 +- .../data/available-commands-skills.json | 6 +- .../CodexACPAgent/data/command-mcp.json | 2 +- .../data/load-session-history.json | 6 +- .../CodexACPAgent/data/mcp-command-pages.md | 12 + .../data/mcp-command-reconnect.md | 8 + .../data/mcp-command-reload-error.md | 9 + .../CodexACPAgent/data/mcp-command-status.md | 20 + .../CodexACPAgent/elicitation-events.test.ts | 2 +- .../CodexACPAgent/mcp-command.test.ts | 645 ++++++++++++++++++ .../CodexACPAgent/mcp-config-merge.test.ts | 4 +- .../CodexACPAgent/mcp-session.test.ts | 23 +- .../CodexACPAgent/session-close.test.ts | 64 +- .../CodexACPAgent/session-fork.test.ts | 31 +- src/__tests__/McpStartupTracker.test.ts | 123 ++++ src/__tests__/McpStatusMarkdown.test.ts | 230 +++++++ src/__tests__/acp-test-utils.ts | 7 + src/__tests__/scenarios/baseline.ts | 28 +- .../scenarios/client-profiles.test.ts | 20 +- .../air/agent-message-and-reasoning.jsonl | 2 +- .../data/air/background-terminal.jsonl | 2 +- .../scenarios/data/air/collab-agent.jsonl | 2 +- .../scenarios/data/air/collab-controls.jsonl | 2 +- .../scenarios/data/air/command-approval.jsonl | 2 +- .../scenarios/data/air/command-failed.jsonl | 2 +- .../data/air/command-output-stdin.jsonl | 2 +- .../air/command-without-streamed-output.jsonl | 2 +- .../data/air/context-compaction.jsonl | 2 +- .../scenarios/data/air/dynamic-tool.jsonl | 2 +- .../data/air/file-change-approval.jsonl | 2 +- .../scenarios/data/air/file-changes.jsonl | 2 +- .../data/air/fuzzy-file-search.jsonl | 2 +- .../scenarios/data/air/goal-update.jsonl | 2 +- .../scenarios/data/air/guardian-review.jsonl | 2 +- .../scenarios/data/air/history-replay.jsonl | 2 +- .../scenarios/data/air/image-generation.jsonl | 2 +- .../scenarios/data/air/image-view.jsonl | 2 +- .../data/air/late-subagent-update.jsonl | 2 +- .../scenarios/data/air/mcp-elicitation.jsonl | 2 +- .../data/air/mcp-startup-failure.jsonl | 2 +- .../data/air/mcp-tool-approval.jsonl | 2 +- .../scenarios/data/air/mcp-tool.jsonl | 2 +- .../data/air/mcp-url-elicitation.jsonl | 2 +- .../data/air/native-subagent-session.jsonl | 2 +- .../data/air/nested-subagent-session.jsonl | 2 +- .../air/network-and-sandbox-approval.jsonl | 2 +- .../data/air/plan-deltas-and-turn-plan.jsonl | 2 +- .../data/air/plan-review-permission.jsonl | 2 +- .../scenarios/data/air/plan-stream.jsonl | 2 +- .../scenarios/data/air/read-search-list.jsonl | 2 +- .../data/air/subagent-activity.jsonl | 2 +- .../scenarios/data/air/web-search.jsonl | 2 +- src/mcp/McpCommand.ts | 245 +++++++ src/mcp/McpOauthCompletions.ts | 77 +++ src/mcp/McpServerSignIn.ts | 99 +++ src/mcp/McpServerStatusList.ts | 30 + src/mcp/McpStartupTracker.ts | 192 ++++++ src/mcp/McpStatusMarkdown.ts | 278 ++++++++ 67 files changed, 2305 insertions(+), 262 deletions(-) create mode 100644 src/__tests__/CodexACPAgent/data/mcp-command-pages.md create mode 100644 src/__tests__/CodexACPAgent/data/mcp-command-reconnect.md create mode 100644 src/__tests__/CodexACPAgent/data/mcp-command-reload-error.md create mode 100644 src/__tests__/CodexACPAgent/data/mcp-command-status.md create mode 100644 src/__tests__/CodexACPAgent/mcp-command.test.ts create mode 100644 src/__tests__/McpStartupTracker.test.ts create mode 100644 src/__tests__/McpStatusMarkdown.test.ts create mode 100644 src/mcp/McpCommand.ts create mode 100644 src/mcp/McpOauthCompletions.ts create mode 100644 src/mcp/McpServerSignIn.ts create mode 100644 src/mcp/McpServerStatusList.ts create mode 100644 src/mcp/McpStartupTracker.ts create mode 100644 src/mcp/McpStatusMarkdown.ts diff --git a/README.md b/README.md index 9e514677b..04af1e938 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ Use [OpenAI Codex](https://github.com/openai/codex) from [Agent Client Protocol] - All AIR extensions, capabilities, and `_meta` keys: [AIR extensions](docs/air-extensions.md). - A per-turn [agent file-change report](docs/air-extensions.md#agent-file-change-report) after capability negotiation. - Client-provided MCP servers over command-based stdio config and HTTP transport. -- Slash commands: `/status`, `/mcp`, `/skills`, `/goal`, `/review`, `/review-branch`, `/review-commit`, `/compact`, and `/logout`, as well as configured skills. +- Slash commands: `/status`, `/mcp` (with `/mcp reconnect [server]`), `/skills`, `/goal`, `/review`, `/review-branch`, `/review-commit`, `/compact`, and `/logout`, as well as configured skills. ## Installation diff --git a/docs/air-extensions.md b/docs/air-extensions.md index e716db9fb..67c58aef2 100644 --- a/docs/air-extensions.md +++ b/docs/air-extensions.md @@ -58,6 +58,7 @@ Only these differences are allowed: ACP defines no merge for `_meta` keys, so the `_meta` of each report keeps every key that the adapter sent before. - Bug fixes: a unique MCP startup tool call id, the result of a dynamic tool in `content`, no output after a tool call ended, and a terminal status for a replayed image generation. +- Feature changes for every client: the `/mcp` command has a new description and an input hint. - The client gets no AIR-only key. That is no `_meta.jetbrains.air` key and none of the earlier keys in [Removed keys](#removed-keys). - The output of a command arrives once, in the chunks or in `content`, as [Zed conventions](#zed-conventions) describe. diff --git a/docs/mcp-startup-await-timeout.md b/docs/mcp-startup-await-timeout.md index 06f6e9cfe..a20c91308 100644 --- a/docs/mcp-startup-await-timeout.md +++ b/docs/mcp-startup-await-timeout.md @@ -38,10 +38,19 @@ There is no built-in default timeout: an omitted field means do not wait ## Behavior after the request completes -The adapter never blocks `session/new` or `session/resume` on MCP startup: the request completes as soon as the session -is created, before any requested MCP server reaches a terminal state. Startup itself is never cancelled or interrupted; -the adapter keeps tracking every requested server in the background and still publishes `session_info_update` MCP status -notifications for `session/new` and `session/resume` sessions as servers finish starting. +Without the field, the adapter does not block `session/new` or `session/resume` on MCP startup. The request completes +as soon as the session is created. With the field, the wait ends when the startup completes or at the timeout, +whichever comes first. The adapter never cancels the startup. +It keeps tracking every requested server in the background. + +When the requested servers reach a terminal state, the adapter reports each of them that failed or was cancelled. +Each report is a `session/update` with a new `tool_call` in the `failed` status and the title `mcp____startup`. +A server that starts successfully gets no report. When a server fails because it needs authentication and the client +supports URL elicitation, the adapter first starts the MCP OAuth sign-in. It reports the server only if the sign-in fails. +The `session/new`, `session/resume`, and `session/load` sessions get these reports. The `session/fork` sessions do not. + +To see the live status of every MCP server, send the `/mcp` command. `/mcp reconnect` reloads the MCP configuration and +restarts the servers that failed, stopped, or changed. ## Compatibility diff --git a/src/CodexAcpClient.ts b/src/CodexAcpClient.ts index ecdf1b86e..8c62782f7 100644 --- a/src/CodexAcpClient.ts +++ b/src/CodexAcpClient.ts @@ -13,8 +13,8 @@ import type { ApprovalHandler, CodexAppServerClient, ElicitationHandler, - McpStartupResult, } from "./CodexAppServerClient"; +import type {McpServerStartupWaitOptions, McpStartupResult} from "./mcp/McpStartupTracker"; import open from "open"; import type {Disposable} from "vscode-jsonrpc"; import type { @@ -34,6 +34,7 @@ import type { AccountUpdatedNotification, GetAccountRateLimitsResponse, GetAccountResponse, + ListMcpServerStatusParams, ListMcpServerStatusResponse, McpServerOauthLoginCompletedNotification, McpServerOauthLoginParams, @@ -837,12 +838,16 @@ export class CodexAcpClient { await this.codexClient.runGoalClear({threadId: sessionId}); } - async awaitMcpServerStartup(serverNames: Array, afterVersion: number): Promise { - return await this.codexClient.awaitMcpServerStartup(serverNames, afterVersion); + async awaitMcpServerStartup( + serverNames: Array, + afterVersion: number, + options: McpServerStartupWaitOptions, + ): Promise { + return await this.codexClient.mcpStartup.await(serverNames, afterVersion, options); } getMcpServerStartupVersion(): number { - return this.codexClient.getMcpServerStartupVersion(); + return this.codexClient.mcpStartup.version(); } private async createSessionConfig( @@ -1142,8 +1147,13 @@ export class CodexAcpClient { }); } - async listMcpServers(): Promise { - return this.codexClient.listMcpServerStatus({}); + async listMcpServers(params: ListMcpServerStatusParams): Promise { + return this.codexClient.listMcpServerStatus(params); + } + + /** Reloads the MCP configuration. Codex reconnects the servers of every loaded thread that failed, closed, or changed. */ + async reloadMcpServers(): Promise { + await this.codexClient.mcpServerReload(); } async mcpServerOauthLogin( @@ -1155,8 +1165,9 @@ export class CodexAcpClient { async awaitMcpServerOauthLoginCompleted( name: string, threadId: string, + signal?: AbortSignal, ): Promise { - return await this.codexClient.awaitMcpServerOauthLoginCompleted(name, threadId); + return await this.codexClient.mcpOauthCompletions.await(name, threadId, signal); } async listSessions(request: acp.ListSessionsRequest): Promise { diff --git a/src/CodexAcpServer.ts b/src/CodexAcpServer.ts index a43f9ab3c..f32da6a37 100644 --- a/src/CodexAcpServer.ts +++ b/src/CodexAcpServer.ts @@ -7,6 +7,7 @@ import {PermissionLifecycleContext} from "./permissions/lifecycle"; import {CodexElicitationHandler} from "./CodexElicitationHandler"; import {type CodexAuthRequest, getCodexAuthMethods, isCodexAuthRequest} from "./CodexAuthMethod"; import {clientSupportsUrlElicitation} from "./ElicitationCapabilities"; +import {createMcpServerSignIn, type McpServerSignIn} from "./mcp/McpServerSignIn"; import { CodexAcpClient, type JsonObject, @@ -15,7 +16,8 @@ import { type SessionMetadataWithThread, type UrlElicitationRequester } from "./CodexAcpClient"; -import {CodexAppServerClient, type McpStartupResult} from "./CodexAppServerClient"; +import {CodexAppServerClient} from "./CodexAppServerClient"; +import type {McpStartupResult} from "./mcp/McpStartupTracker"; import {isNoActiveTurnError} from "./CodexThreadErrors"; import {type CodexConnection, startCodexConnection} from "./CodexJsonRpcConnection"; import {type AcpClientConnection, ACPSessionConnection, type UpdateSessionEvent} from "./ACPSessionConnection"; @@ -255,6 +257,8 @@ interface ActiveAuthState { interface PendingMcpStartupSession { requestedServers: Set; startup: Promise; + /** Stops the startup wait and the sign-in of the startup report. */ + abort: AbortController; } interface PendingTurnStart { @@ -285,6 +289,7 @@ export interface CodexProcessState { export class CodexAcpServer { private codexAcpClient: CodexAcpClient; private readonly connection: AcpClientConnection; + private readonly mcpServerSignIn: McpServerSignIn; private readonly reportingConnection: ToolCallReportingConnection; private readonly defaultAuthRequest: CodexAuthRequest | null; private readonly getExitCode: () => number | null; @@ -335,6 +340,11 @@ export class CodexAcpServer { this.reportingConnection = new ToolCallReportingConnection(connection); this.connection = this.reportingConnection.asClientConnection(); this.codexAcpClient = codexAcpClient; + this.mcpServerSignIn = createMcpServerSignIn( + this.connection, + () => this.codexAcpClient, + () => this.clientCapabilities, + ); this.defaultAuthRequest = defaultAuthRequest ?? null; this.codexProcessState = codexProcessState ?? null; this.captureStderr(); @@ -355,7 +365,8 @@ export class CodexAcpServer { this.connection, client, (operation) => this.runWithProcessCheck(operation), - () => this.refreshAuthState(null) + () => this.refreshAuthState(null), + this.mcpServerSignIn, ); } @@ -748,6 +759,7 @@ export class CodexAcpServer { const canPublishSessionUpdates = operation !== "fork"; if (requestedMcpServers.length > 0 && mcpServerStartupVersion !== null) { const pendingStartup = this.createPendingMcpStartupSession( + sessionId, requestedMcpServers, mcpServerStartupVersion, ); @@ -771,6 +783,9 @@ export class CodexAcpServer { } if (canPublishSessionUpdates) { this.publishMcpStartupStatusAsync(sessionId); + } else { + // A fork publishes no startup report, so nothing waits for the rest of the startup. + pendingStartup.abort.abort(); } } @@ -967,7 +982,9 @@ export class CodexAcpServer { } finally { if (this.getSessionGeneration(params.sessionId) === closeGeneration) { this.sessions.delete(params.sessionId); + this.pendingMcpStartupSessions.get(params.sessionId)?.abort.abort(); this.pendingMcpStartupSessions.delete(params.sessionId); + this.codexAcpClient.appServerClient.mcpStartup.forgetThread(params.sessionId); this.pendingTurnStarts.delete(params.sessionId); this.activePrompts.delete(params.sessionId); this.steeringQueues.delete(params.sessionId); @@ -2047,7 +2064,7 @@ export class CodexAcpServer { if (requestedMcpServers.length > 0 && mcpServerStartupVersion !== null) { this.pendingMcpStartupSessions.set( sessionId, - this.createPendingMcpStartupSession(requestedMcpServers, mcpServerStartupVersion), + this.createPendingMcpStartupSession(sessionId, requestedMcpServers, mcpServerStartupVersion), ); this.publishMcpStartupStatusAsync(sessionId); } @@ -2517,16 +2534,20 @@ export class CodexAcpServer { } private createPendingMcpStartupSession( + sessionId: string, mcpServers: Array, afterVersion: number, ): PendingMcpStartupSession { const requestedServers = new Set(getRequestedMcpServerNames(mcpServers)); - return { - requestedServers, - startup: this.runWithProcessCheck(() => - this.codexAcpClient.awaitMcpServerStartup(Array.from(requestedServers), afterVersion) - ), - }; + const abort = new AbortController(); + const startup = this.runWithProcessCheck(() => this.codexAcpClient.awaitMcpServerStartup( + Array.from(requestedServers), + afterVersion, + {threadId: sessionId, signal: abort.signal}, + )); + // An abort can reject the startup before a caller awaits it. + void startup.catch(() => {}); + return {requestedServers, startup, abort}; } private async doPublishMcpStartupStatus(sessionId: string): Promise { @@ -2542,9 +2563,11 @@ export class CodexAcpServer { || this.pendingMcpStartupSessions.get(sessionId) !== pendingStartup) { return; } - await this.publishMcpStartupStatus(sessionId, mcpStartup, pendingStartup.requestedServers); + await this.publishMcpStartupStatus(sessionId, mcpStartup, pendingStartup.abort.signal, pendingStartup.requestedServers); } catch (err) { - logger.error(`Failed to publish MCP startup status for session ${sessionId}`, err); + if (!pendingStartup.abort.signal.aborted) { + logger.error(`Failed to publish MCP startup status for session ${sessionId}`, err); + } } finally { if (this.pendingMcpStartupSessions.get(sessionId) === pendingStartup) { this.pendingMcpStartupSessions.delete(sessionId); @@ -2555,6 +2578,7 @@ export class CodexAcpServer { private async publishMcpStartupStatus( sessionId: string, mcpStartup: McpStartupResult, + signal: AbortSignal, requestedServers?: Set ): Promise { const filteredStartup = requestedServers @@ -2568,23 +2592,18 @@ export class CodexAcpServer { const failuresAfterOauth: typeof filteredStartup.failed = []; const readyAfterOauth = [...filteredStartup.ready]; for (const failure of filteredStartup.failed) { - if (failure.failureReason !== "reauthenticationRequired" - || !clientSupportsUrlElicitation(this.clientCapabilities)) { - failuresAfterOauth.push(failure); - continue; - } - try { - const authenticated = await this.authenticateMcpServer(sessionId, failure.server); - if (authenticated) { - readyAfterOauth.push(failure.server); - } else { - failuresAfterOauth.push(failure); - } - } catch (error) { - logger.error(`Failed to authenticate MCP server ${failure.server}`, error); + const signIn = failure.failureReason === "reauthenticationRequired" + ? await this.mcpServerSignIn(sessionId, failure.server, signal) + : "unsupported"; + if (signIn === "signedIn") { + readyAfterOauth.push(failure.server); + } else { failuresAfterOauth.push(failure); } } + if (signal.aborted) { + return; + } const renderer = new AcpToolCallRenderer(this.capabilities); for (const facts of McpStartupReporter.failures({ @@ -2599,35 +2618,6 @@ export class CodexAcpServer { } } - private async authenticateMcpServer(sessionId: string, serverName: string): Promise { - const elicitationId = `mcp-oauth-${randomUUID()}`; - const completed = this.codexAcpClient.awaitMcpServerOauthLoginCompleted(serverName, sessionId); - const login = await this.codexAcpClient.mcpServerOauthLogin({ - name: serverName, - threadId: sessionId, - }); - const elicitation = Promise.resolve(this.connection.request( - acp.methods.client.elicitation.create, - { - mode: "url", - sessionId, - message: `Authenticate with MCP server ${serverName}`, - url: login.authorizationUrl, - elicitationId, - }, - )); - const first = await Promise.race([ - completed.then(result => ({type: "completed" as const, result})), - elicitation.then(response => ({type: "elicitation" as const, response})), - ]); - if (first.type === "elicitation" && !acp.CreateElicitationResponse.isAccept(first.response)) { - return false; - } - const result = first.type === "completed" ? first.result : await completed; - await this.connection.notify(acp.methods.client.elicitation.complete, {elicitationId}); - return result.success; - } - private trackActivePrompt(sessionId: string): ActivePrompt { let resolveCompletion: () => void = () => {}; const completion = new Promise((resolve) => { @@ -2969,6 +2959,7 @@ export class CodexAcpServer { } const commandPromise = this.availableCommands.tryHandleCommand(params.prompt, sessionState, { + signal: activePrompt.signal, onTurnStartPending: () => { sessionState.lastTokenUsage = null; ensurePendingTurnStart(); diff --git a/src/CodexAppServerClient.ts b/src/CodexAppServerClient.ts index e35f59a45..16067f331 100644 --- a/src/CodexAppServerClient.ts +++ b/src/CodexAppServerClient.ts @@ -1,4 +1,6 @@ import {type MessageConnection, RequestType} from "vscode-jsonrpc/node"; +import {McpOauthCompletions} from "./mcp/McpOauthCompletions"; +import {McpStartupTracker} from "./mcp/McpStartupTracker"; import type { ClientRequest, InitializeParams, @@ -22,9 +24,7 @@ import type { McpServerElicitationRequestResponse, McpServerOauthLoginParams, McpServerOauthLoginResponse, - McpServerOauthLoginCompletedNotification, - McpServerStartupFailureReason, - McpServerStartupState, + McpServerRefreshResponse, McpServerStatusUpdatedNotification, ModelListParams, ModelListResponse, @@ -107,18 +107,6 @@ export interface ElicitationHandler { handleUserInput(params: ToolRequestUserInputParams): Promise; } -export type McpStartupFailure = { - server: string; - error: string; - failureReason?: McpServerStartupFailureReason; -}; - -export type McpStartupResult = { - ready: Array; - failed: Array; - cancelled: Array; -}; - const CommandExecutionApprovalRequest = new RequestType< CommandExecutionRequestApprovalParams, CommandExecutionRequestApprovalResponse, @@ -171,9 +159,8 @@ export class CodexAppServerClient { readonly connection: MessageConnection; private approvalHandlers = new Map(); private elicitationHandlers = new Map(); - private mcpServerStartupVersion = 0; - private readonly mcpServerStartupStates = new Map(); - private readonly mcpServerStartupResolvers: Array = []; + readonly mcpStartup = new McpStartupTracker(); + readonly mcpOauthCompletions = new McpOauthCompletions(); private readonly pendingTurnCompletionResolvers = new Map void>>(); private readonly pendingCompactionCompletionResolvers = new Map void>>(); private readonly turnCompletionCaptures = new Map void>>(); @@ -186,17 +173,16 @@ export class CodexAppServerClient { constructor(connection: MessageConnection) { this.connection = connection; + // The process exit disposes the connection and does not close it, so both events end the MCP waits. + this.connection.onClose?.(() => this.disposeMcpWaits()); + this.connection.onDispose?.(() => this.disposeMcpWaits()); this.connection.onUnhandledNotification((data) => { const serverNotification = data as ServerNotification; if (isMcpServerStatusUpdatedNotification(serverNotification)) { - this.mcpServerStartupVersion += 1; - this.mcpServerStartupStates.set(serverNotification.params.name, { - status: serverNotification.params.status, - error: serverNotification.params.error, - failureReason: serverNotification.params.failureReason ?? null, - version: this.mcpServerStartupVersion, - }); - this.resolveMcpServerStartupResolvers(); + this.mcpStartup.record(serverNotification.params); + } + if (serverNotification.method === "mcpServer/oauthLogin/completed") { + this.mcpOauthCompletions.complete(serverNotification.params); } if (isTurnCompletedNotification(serverNotification)) { this.recordTurnCompleted(serverNotification.params); @@ -763,27 +749,12 @@ export class CodexAppServerClient { return await this.sendRequest({ method: "mcpServerStatus/list", params }); } - async mcpServerOauthLogin(params: McpServerOauthLoginParams): Promise { - return await this.sendRequest({ method: "mcpServer/oauth/login", params }); + async mcpServerReload(): Promise { + return await this.sendRequest({ method: "config/mcpServer/reload", params: undefined }); } - async awaitMcpServerOauthLoginCompleted( - name: string, - threadId: string, - ): Promise { - return await new Promise((resolve) => { - let disposable: {dispose(): void} | undefined; - disposable = this.connection.onNotification( - "mcpServer/oauthLogin/completed", - (event: McpServerOauthLoginCompletedNotification) => { - if (event.name !== name || event.threadId !== threadId) { - return; - } - disposable?.dispose(); - resolve(event); - }, - ); - }); + async mcpServerOauthLogin(params: McpServerOauthLoginParams): Promise { + return await this.sendRequest({ method: "mcpServer/oauth/login", params }); } async accountLogin(params: LoginAccountParams): Promise { @@ -802,30 +773,6 @@ export class CodexAppServerClient { return await this.sendRequest({ method: "config/read", params: params }); } - getMcpServerStartupVersion(): number { - return this.mcpServerStartupVersion; - } - - async awaitMcpServerStartup(serverNames: Array, afterVersion: number): Promise { - const uniqueServerNames = Array.from(new Set(serverNames.map(serverName => serverName.trim()).filter(serverName => serverName.length > 0))); - if (uniqueServerNames.length === 0) { - return { ready: [], failed: [], cancelled: [] }; - } - - const result = this.tryBuildMcpStartupResult(uniqueServerNames, afterVersion); - if (result !== null) { - return result; - } - - return await new Promise((resolve) => { - this.mcpServerStartupResolvers.push({ - serverNames: uniqueServerNames, - afterVersion, - resolve, - }); - }); - } - async accountRead(params: GetAccountParams): Promise { return await this.sendRequest({ method: "account/read", params: params }); } @@ -1135,50 +1082,9 @@ export class CodexAppServerClient { }; } - private resolveMcpServerStartupResolvers(): void { - const pendingResolvers: Array = []; - for (const resolver of this.mcpServerStartupResolvers) { - const result = this.tryBuildMcpStartupResult(resolver.serverNames, resolver.afterVersion); - if (result !== null) { - resolver.resolve(result); - } else { - pendingResolvers.push(resolver); - } - } - this.mcpServerStartupResolvers.splice(0, this.mcpServerStartupResolvers.length, ...pendingResolvers); - } - - private tryBuildMcpStartupResult(serverNames: Array, afterVersion: number): McpStartupResult | null { - const ready: Array = []; - const failed: Array = []; - const cancelled: Array = []; - - for (const serverName of serverNames) { - const state = this.mcpServerStartupStates.get(serverName); - if (!state || state.version <= afterVersion) { - return null; - } - - switch (state.status) { - case "starting": - return null; - case "ready": - ready.push(serverName); - break; - case "failed": - failed.push({ - server: serverName, - error: state.error ?? "unknown MCP startup error", - ...(state.failureReason === null ? {} : {failureReason: state.failureReason}), - }); - break; - case "cancelled": - cancelled.push(serverName); - break; - } - } - - return { ready, failed, cancelled }; + private disposeMcpWaits(): void { + this.mcpStartup.dispose(); + this.mcpOauthCompletions.dispose(); } private async sendRequest(request: CodexRequest): Promise { @@ -1226,19 +1132,6 @@ export interface ExperimentalThreadSettingsUpdateParams { }; } -type McpServerStartupSnapshot = { - status: McpServerStartupState; - error: string | null; - failureReason: McpServerStartupFailureReason | null; - version: number; -}; - -type McpServerStartupResolver = { - serverNames: Array; - afterVersion: number; - resolve: (result: McpStartupResult) => void; -}; - function isMcpServerStatusUpdatedNotification(notification: ServerNotification): notification is { method: "mcpServer/startupStatus/updated"; params: McpServerStatusUpdatedNotification; diff --git a/src/CodexCommands.ts b/src/CodexCommands.ts index 43d6122f3..5b294bc4e 100644 --- a/src/CodexCommands.ts +++ b/src/CodexCommands.ts @@ -8,6 +8,8 @@ import type {SessionState} from "./CodexAcpServer"; import {createRateLimitsMap, type RateLimitsMap} from "./RateLimitsMap"; import type {TokenCount} from "./TokenCount"; import {logger} from "./Logger"; +import {MCP_COMMAND_INPUT_HINT, McpCommand} from "./mcp/McpCommand"; +import type {McpServerSignIn} from "./mcp/McpServerSignIn"; import {createAgentTextMessageChunk} from "./ContentChunks"; import { COLLABORATION_MODE_CONFIG_ID, @@ -30,6 +32,8 @@ export const GOAL_CONTINUATION_PROMPT: acp.ContentBlock[] = [{ }]; 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; setConfigOption?: (configId: string, value: string) => Promise; @@ -42,6 +46,7 @@ export class CodexCommands { private readonly codexAcpClient: CodexAcpClient; private readonly runWithProcessCheck: (operation: () => Promise) => Promise; private readonly onLogout: LogoutHandler; + private readonly mcpCommand: McpCommand; /** The commands that each session got last, to skip a publish that changes nothing. */ private readonly published = new WeakMap(); @@ -49,12 +54,14 @@ export class CodexCommands { connection: AcpClientConnection, codexAcpClient: CodexAcpClient, runWithProcessCheck: (operation: () => Promise) => Promise, - onLogout: LogoutHandler = () => {} + onLogout: LogoutHandler, + mcpServerSignIn: McpServerSignIn, ) { this.connection = connection; this.codexAcpClient = codexAcpClient; this.runWithProcessCheck = runWithProcessCheck; this.onLogout = onLogout; + this.mcpCommand = new McpCommand(codexAcpClient, runWithProcessCheck, mcpServerSignIn); } /** Sends the available commands. With `onlyChanges`, it sends nothing when the commands did not change. */ @@ -145,8 +152,8 @@ export class CodexCommands { }, { name: "mcp", - description: "List configured Model Context Protocol (MCP) tools.", - input: null + description: "Show the status of the MCP servers, or reconnect them.", + input: { hint: MCP_COMMAND_INPUT_HINT } }, { name: "skills", @@ -318,19 +325,10 @@ export class CodexCommands { return { handled: true }; } case "mcp": { - const servers = await this.runWithProcessCheck(() => this.codexAcpClient.listMcpServers()); - const configuredServers = servers.data.map(server => { - const toolCount = Object.keys(server.tools ?? {}).length; - const resourceCount = (server.resources ?? []).length; - return `- ${server.name}: ${toolCount} tools, ${resourceCount} resources, auth=${server.authStatus}`; - }); - const sessionServers = sessionState.sessionMcpServers - ? sessionState.sessionMcpServers.map(serverName => `- ${serverName}`) - : []; - const lines = [...configuredServers, ...sessionServers]; - const text = lines.length > 0 - ? ["Configured MCP servers:", ...lines].join("\n") - : "No MCP servers configured."; + const text = await this.mcpCommand.run(command.rest, sessionState, options.signal); + if (text === null) { + return { handled: true }; + } const session = new ACPSessionConnection(this.connection, sessionId); await session.update(createAgentTextMessageChunk(text)); return { handled: true }; diff --git a/src/CodexThreadErrors.ts b/src/CodexThreadErrors.ts index cec2f02c6..b0aa6e866 100644 --- a/src/CodexThreadErrors.ts +++ b/src/CodexThreadErrors.ts @@ -36,6 +36,14 @@ export function isThreadNotLoadedError(err: unknown): boolean { return errorText(err).includes("thread not loaded:"); } +/** + * `mcpServerStatus/list` answers this for a thread id that is well-formed but + * unknown to the app-server process. + */ +export function isThreadNotFoundError(err: unknown): boolean { + return errorText(err).includes("thread not found:"); +} + /** * Codex thread ids are UUIDs, so anything else is rejected before lookup. ACP * session ids are opaque strings, so a client is free to send an id Codex diff --git a/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts b/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts index fc673fa65..0b87be46d 100644 --- a/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts +++ b/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts @@ -19,7 +19,7 @@ import type {Model, ReviewStartResponse, ThreadGoal, TurnCompletedNotification, import type {RateLimitsMap} from "../../RateLimitsMap"; import {ModelId} from "../../ModelId"; import {GOAL_CONTROL_METHOD} from "../../AcpExtensions"; -import type {McpStartupResult} from "../../CodexAppServerClient"; +import type {McpStartupResult} from "../../mcp/McpStartupTracker"; describe('ACP server test', { timeout: 40_000 }, () => { @@ -959,7 +959,8 @@ describe('ACP server test', { timeout: 40_000 }, () => { const startupPromise = codexAcpClient.awaitMcpServerStartup( ["alpha", "beta"], - codexAppServerClient.getMcpServerStartupVersion() + codexAppServerClient.mcpStartup.version(), + {threadId: "thread-id"}, ); mockFixture.sendServerNotification({ @@ -1102,7 +1103,7 @@ describe('ACP server test', { timeout: 40_000 }, () => { ); await vi.waitFor(() => { - expect(awaitMcpStartupSpy).toHaveBeenCalledWith(["resume-mcp"], expect.any(Number)); + expect(awaitMcpStartupSpy).toHaveBeenCalledWith(["resume-mcp"], expect.any(Number), {threadId: "resume-id", signal: expect.any(AbortSignal)}); }); expect(resumeSettled).toBe(false); diff --git a/src/__tests__/CodexACPAgent/data/available-commands-build-in.json b/src/__tests__/CodexACPAgent/data/available-commands-build-in.json index d611fd303..0ceaecfe6 100644 --- a/src/__tests__/CodexACPAgent/data/available-commands-build-in.json +++ b/src/__tests__/CodexACPAgent/data/available-commands-build-in.json @@ -27,8 +27,10 @@ }, { "name": "mcp", - "description": "List configured Model Context Protocol (MCP) tools.", - "input": null + "description": "Show the status of the MCP servers, or reconnect them.", + "input": { + "hint": "[reconnect [server]]" + } }, { "name": "skills", diff --git a/src/__tests__/CodexACPAgent/data/available-commands-skills.json b/src/__tests__/CodexACPAgent/data/available-commands-skills.json index fbc66b5c3..6b2a6a71d 100644 --- a/src/__tests__/CodexACPAgent/data/available-commands-skills.json +++ b/src/__tests__/CodexACPAgent/data/available-commands-skills.json @@ -27,8 +27,10 @@ }, { "name": "mcp", - "description": "List configured Model Context Protocol (MCP) tools.", - "input": null + "description": "Show the status of the MCP servers, or reconnect them.", + "input": { + "hint": "[reconnect [server]]" + } }, { "name": "skills", diff --git a/src/__tests__/CodexACPAgent/data/command-mcp.json b/src/__tests__/CodexACPAgent/data/command-mcp.json index adcc6d254..3c048cca3 100644 --- a/src/__tests__/CodexACPAgent/data/command-mcp.json +++ b/src/__tests__/CodexACPAgent/data/command-mcp.json @@ -7,7 +7,7 @@ "sessionUpdate": "agent_message_chunk", "content": { "type": "text", - "text": "Configured MCP servers:\n- fs: 1 tools, 1 resources, auth=bearerToken\n- browser: 0 tools, 0 resources, auth=notLoggedIn" + "text": "**MCP servers:** 2 (2 unknown)\n\n**Unknown**\n- `fs`: 1 tool\n- `browser`: not signed in" } } } diff --git a/src/__tests__/CodexACPAgent/data/load-session-history.json b/src/__tests__/CodexACPAgent/data/load-session-history.json index bd9977dea..7d663ebd3 100644 --- a/src/__tests__/CodexACPAgent/data/load-session-history.json +++ b/src/__tests__/CodexACPAgent/data/load-session-history.json @@ -25,8 +25,10 @@ }, { "name": "mcp", - "description": "List configured Model Context Protocol (MCP) tools.", - "input": null + "description": "Show the status of the MCP servers, or reconnect them.", + "input": { + "hint": "[reconnect [server]]" + } }, { "name": "skills", diff --git a/src/__tests__/CodexACPAgent/data/mcp-command-pages.md b/src/__tests__/CodexACPAgent/data/mcp-command-pages.md new file mode 100644 index 000000000..bac98c619 --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/mcp-command-pages.md @@ -0,0 +1,12 @@ +**MCP servers:** 3 (1 connected, 1 not started, 1 cancelled) + +**Cancelled** +- `third` + +**Not started** +- `second` + +**Connected** +- `first`: 1 tool + +Run `/mcp reconnect` to restart the servers that are not connected. \ No newline at end of file diff --git a/src/__tests__/CodexACPAgent/data/mcp-command-reconnect.md b/src/__tests__/CodexACPAgent/data/mcp-command-reconnect.md new file mode 100644 index 000000000..4098315e5 --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/mcp-command-reconnect.md @@ -0,0 +1,8 @@ +**Reconnect:** +- Reloaded the MCP configuration of all open sessions. Codex restarted the servers that failed, stopped, or changed. A connected server with unchanged settings keeps its connection. + +**MCP servers:** 2 (2 connected) + +**Connected** +- `fs`: 1 tool +- `docs`: 1 tool \ No newline at end of file diff --git a/src/__tests__/CodexACPAgent/data/mcp-command-reload-error.md b/src/__tests__/CodexACPAgent/data/mcp-command-reload-error.md new file mode 100644 index 000000000..254d326f3 --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/mcp-command-reload-error.md @@ -0,0 +1,9 @@ +**Reconnect:** +- Could not reload the MCP configuration: failed to refresh MCP servers: invalid config.toml + +**MCP servers:** 1 (1 failed) + +**Failed** +- `fs` + +Run `/mcp reconnect` to restart the servers that are not connected. \ No newline at end of file diff --git a/src/__tests__/CodexACPAgent/data/mcp-command-status.md b/src/__tests__/CodexACPAgent/data/mcp-command-status.md new file mode 100644 index 000000000..4e41d17d0 --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/mcp-command-status.md @@ -0,0 +1,20 @@ +**MCP servers:** 6 (1 connected, 1 connecting, 1 needs authentication, 1 failed, 1 disabled, 1 unknown) + +**Failed** +- `broken`: spawn ENOENT | exit 1 + +**Needs authentication** +- `linear`: not signed in + +**Connecting** +- `slow` + +**Connected** +- `fs`: 2 tools + +**Unknown** +- `legacy` + +**Disabled:** `off` + +Run `/mcp reconnect` to restart the servers that are not connected. \ No newline at end of file diff --git a/src/__tests__/CodexACPAgent/elicitation-events.test.ts b/src/__tests__/CodexACPAgent/elicitation-events.test.ts index b356978ed..6590e3dd4 100644 --- a/src/__tests__/CodexACPAgent/elicitation-events.test.ts +++ b/src/__tests__/CodexACPAgent/elicitation-events.test.ts @@ -734,7 +734,7 @@ describe('Elicitation Events', () => { success: true, }); - await expect((agent as any).authenticateMcpServer(sessionId, 'linear')).resolves.toBe(true); + await expect((agent as any).mcpServerSignIn(sessionId, 'linear')).resolves.toBe('signedIn'); expect(oauthLogin).toHaveBeenCalledWith({name: 'linear', threadId: sessionId}); const events = fixture.getAcpConnectionEvents([]); diff --git a/src/__tests__/CodexACPAgent/mcp-command.test.ts b/src/__tests__/CodexACPAgent/mcp-command.test.ts new file mode 100644 index 000000000..f605e5841 --- /dev/null +++ b/src/__tests__/CodexACPAgent/mcp-command.test.ts @@ -0,0 +1,645 @@ +import {afterEach, describe, expect, it, vi} from "vitest"; +import * as acp from "@agentclientprotocol/sdk"; +import {type CodexMockTestFixture, setupPromptTestSession} from "../acp-test-utils"; +import type { + ListMcpServerStatusParams, + ListMcpServerStatusResponse, + McpServerStartupState, + McpServerStatus, +} from "../../app-server/v2"; +import {MCP_RECONNECT_STARTUP_TIMEOUT_MS} from "../../mcp/McpCommand"; + +const sessionId = "session-id"; + +function server(overrides: Partial & {name: string}): McpServerStatus { + return { + runtimeStatus: "connected", + pluginId: null, + httpOrigin: null, + serverInfo: null, + serverCapabilities: null, + tools: {}, + toolsError: null, + resources: [], + resourceTemplates: [], + authStatus: "unsupported", + ...overrides, + }; +} + +function tools(...names: string[]): McpServerStatus["tools"] { + return Object.fromEntries(names.map(name => [name, {name, inputSchema: {type: "object"}}])); +} + +function startupStatus( + name: string, + status: McpServerStartupState, + error: string | null = null, + failureReason: "reauthenticationRequired" | null = null, + threadId: string | null = sessionId, +) { + return { + method: "mcpServer/startupStatus/updated", + params: {threadId, name, status, error, failureReason}, + }; +} + +async function enableUrlElicitation(fixture: CodexMockTestFixture): Promise { + await fixture.getCodexAcpAgent().initialize({ + protocolVersion: acp.PROTOCOL_VERSION, + clientCapabilities: {elicitation: {url: {}}}, + }); +} + +function mockOauthLogin(fixture: CodexMockTestFixture, success = true) { + const codexAcpClient = fixture.getCodexAcpClient(); + vi.spyOn(codexAcpClient, "awaitMcpServerOauthLoginCompleted").mockResolvedValue({ + name: "linear", + threadId: sessionId, + success, + }); + return vi.spyOn(codexAcpClient, "mcpServerOauthLogin").mockResolvedValue({ + authorizationUrl: "https://example.com/oauth/authorize", + }); +} + +function pendingStartupWaits(fixture: CodexMockTestFixture): number { + return (fixture.getCodexAppServerClient().mcpStartup as unknown as {waiters: unknown[]}).waiters.length; +} + +function pendingOauthWaits(fixture: CodexMockTestFixture): number { + const waiters = (fixture.getCodexAppServerClient().mcpOauthCompletions as unknown as {waiters: Map>}).waiters; + return [...waiters.values()].reduce((count, set) => count + set.size, 0); +} + +function elicitationEvents(fixture: CodexMockTestFixture, method: "createElicitation" | "completeElicitation") { + return fixture.getAcpConnectionEvents([]).filter(event => event.method === method); +} + +function messageText(fixture: CodexMockTestFixture): string { + return fixture.getAcpConnectionEvents([]) + .filter(event => event.method === "sessionUpdate" && event.args[0].update.sessionUpdate === "agent_message_chunk") + .map(event => event.args[0].update.content.text) + .join(""); +} + +function mockStatusPages(fixture: CodexMockTestFixture, ...pages: ListMcpServerStatusResponse[]) { + const appServer = fixture.getCodexAppServerClient(); + let index = 0; + return vi.spyOn(appServer, "listMcpServerStatus").mockImplementation(async () => { + const page = pages[Math.min(index, pages.length - 1)]!; + index += 1; + return page; + }); +} + +async function runMcp(fixture: CodexMockTestFixture, text: string): Promise { + fixture.clearAcpConnectionDump(); + await fixture.getCodexAcpAgent().prompt({sessionId, prompt: [{type: "text", text}]}); +} + +describe("/mcp command", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it("lists the live status of the thread's MCP servers, grouped by the status", async () => { + const {mockFixture} = setupPromptTestSession({sessionId}); + const list = mockStatusPages(mockFixture, { + data: [ + server({name: "fs", tools: tools("listFiles", "readFile"), authStatus: "bearerToken"}), + server({name: "linear", runtimeStatus: "authenticationRequired", authStatus: "notLoggedIn"}), + server({name: "broken", runtimeStatus: "failed", toolsError: "spawn ENOENT | exit 1"}), + server({name: "slow", runtimeStatus: "starting"}), + server({name: "off", runtimeStatus: "disabled"}), + server({name: "legacy", runtimeStatus: null}), + ], + nextCursor: null, + }); + + await runMcp(mockFixture, "/mcp"); + + expect(list).toHaveBeenCalledWith({cursor: null, detail: "toolsAndAuthOnly", threadId: sessionId}); + await expect(messageText(mockFixture)).toMatchFileSnapshot("data/mcp-command-status.md"); + }); + + it("reads every page of the MCP server status list", async () => { + const {mockFixture} = setupPromptTestSession({sessionId}); + const list = mockStatusPages(mockFixture, + {data: [server({name: "first", tools: tools("a")})], nextCursor: "page-2"}, + {data: [server({name: "second", runtimeStatus: "notStarted"})], nextCursor: "page-3"}, + {data: [server({name: "third", runtimeStatus: "cancelled"})], nextCursor: null}, + ); + + await runMcp(mockFixture, "/mcp"); + + expect(list.mock.calls.map(call => (call[0] as ListMcpServerStatusParams).cursor)).toEqual([null, "page-2", "page-3"]); + await expect(messageText(mockFixture)).toMatchFileSnapshot("data/mcp-command-pages.md"); + }); + + it("shortens a long error in the list", async () => { + const {mockFixture} = setupPromptTestSession({sessionId}); + mockStatusPages(mockFixture, { + data: [server({name: "broken", runtimeStatus: "failed", toolsError: `${"x".repeat(400)}\nsecond line`})], + nextCursor: null, + }); + + await runMcp(mockFixture, "/mcp"); + + expect(messageText(mockFixture)).toContain(`- \`broken\`: ${"x".repeat(160)}…\n`); + }); + + it("says that no MCP servers are configured", async () => { + const {mockFixture} = setupPromptTestSession({sessionId}); + mockStatusPages(mockFixture, {data: [], nextCursor: null}); + + await runMcp(mockFixture, "/mcp"); + + expect(messageText(mockFixture)).toBe("No MCP servers are configured."); + }); + + it("adds the session MCP servers that the status list does not contain", async () => { + const {mockFixture} = setupPromptTestSession({sessionId, sessionMcpServers: ["fs", "client-mcp"]}); + mockStatusPages(mockFixture, {data: [server({name: "fs", tools: tools("a")})], nextCursor: null}); + + await runMcp(mockFixture, "/mcp"); + + const text = messageText(mockFixture); + expect(text).toContain("**Connected**\n- `fs`: 1 tool"); + expect(text).toContain("**Unknown**\n- `client-mcp`"); + }); + + it("rejects an unknown subcommand", async () => { + const {mockFixture} = setupPromptTestSession({sessionId}); + const list = mockStatusPages(mockFixture, {data: [], nextCursor: null}); + + await runMcp(mockFixture, "/mcp restart"); + + expect(list).not.toHaveBeenCalled(); + expect(messageText(mockFixture)).toBe('Command "/mcp" accepts no arguments, or `reconnect [server]`.'); + }); + + it("reloads the MCP configuration, waits for the startup, and shows the new status", async () => { + const {mockFixture} = setupPromptTestSession({sessionId}); + mockStatusPages(mockFixture, + {data: [server({name: "fs", runtimeStatus: "failed"}), server({name: "docs", tools: tools("search")})], nextCursor: null}, + {data: [server({name: "fs", tools: tools("listFiles")}), server({name: "docs", tools: tools("search")})], nextCursor: null}, + ); + const reload = vi.spyOn(mockFixture.getCodexAppServerClient(), "mcpServerReload").mockImplementation(async () => { + mockFixture.sendServerNotification(startupStatus("fs", "starting")); + mockFixture.sendServerNotification(startupStatus("fs", "ready")); + mockFixture.sendServerNotification(startupStatus("docs", "ready")); + return {}; + }); + + await runMcp(mockFixture, "/mcp reconnect"); + + expect(reload).toHaveBeenCalledTimes(1); + await expect(messageText(mockFixture)).toMatchFileSnapshot("data/mcp-command-reconnect.md"); + }); + + it("shows the startup error of a server that fails again after the reload", async () => { + const {mockFixture} = setupPromptTestSession({sessionId}); + mockStatusPages(mockFixture, {data: [server({name: "fs", runtimeStatus: "failed"})], nextCursor: null}); + vi.spyOn(mockFixture.getCodexAppServerClient(), "mcpServerReload").mockImplementation(async () => { + mockFixture.sendServerNotification(startupStatus("fs", "failed", "MCP client for `fs` failed to start: spawn ENOENT")); + return {}; + }); + + await runMcp(mockFixture, "/mcp reconnect"); + + expect(messageText(mockFixture)).toContain("**Failed**\n- `fs`: spawn ENOENT"); + }); + + it("explains that a reconnect of one server reloads all MCP servers", async () => { + const {mockFixture} = setupPromptTestSession({sessionId}); + mockStatusPages(mockFixture, {data: [server({name: "fs"}), server({name: "docs"})], nextCursor: null}); + const reload = vi.spyOn(mockFixture.getCodexAppServerClient(), "mcpServerReload").mockImplementation(async () => { + mockFixture.sendServerNotification(startupStatus("fs", "ready")); + mockFixture.sendServerNotification(startupStatus("docs", "ready")); + return {}; + }); + + await runMcp(mockFixture, "/mcp reconnect fs"); + + expect(reload).toHaveBeenCalledTimes(1); + expect(messageText(mockFixture)).toContain( + "Codex cannot reconnect a single server. The reload applies to all MCP servers, not only `fs`.", + ); + }); + + it("rejects an unknown server name and lists the known servers", async () => { + const {mockFixture} = setupPromptTestSession({sessionId}); + mockStatusPages(mockFixture, {data: [server({name: "fs"}), server({name: "docs"})], nextCursor: null}); + const reload = vi.spyOn(mockFixture.getCodexAppServerClient(), "mcpServerReload"); + + await runMcp(mockFixture, "/mcp reconnect github"); + + expect(reload).not.toHaveBeenCalled(); + expect(messageText(mockFixture)).toBe("Unknown MCP server `github`. Known servers: `fs`, `docs`."); + }); + + it("does not reload when no MCP servers are configured", async () => { + const {mockFixture} = setupPromptTestSession({sessionId}); + mockStatusPages(mockFixture, {data: [], nextCursor: null}); + const reload = vi.spyOn(mockFixture.getCodexAppServerClient(), "mcpServerReload"); + + await runMcp(mockFixture, "/mcp reconnect"); + + expect(reload).not.toHaveBeenCalled(); + expect(messageText(mockFixture)).toBe("No MCP servers are configured."); + }); + + it("reports a reload error and still shows the current status", async () => { + const {mockFixture} = setupPromptTestSession({sessionId}); + mockStatusPages(mockFixture, {data: [server({name: "fs", runtimeStatus: "failed"})], nextCursor: null}); + vi.spyOn(mockFixture.getCodexAppServerClient(), "mcpServerReload") + .mockRejectedValue(new Error("failed to refresh MCP servers: invalid config.toml")); + + await runMcp(mockFixture, "/mcp reconnect"); + + await expect(messageText(mockFixture)).toMatchFileSnapshot("data/mcp-command-reload-error.md"); + }); + + it("stops the wait for the startup after the timeout", async () => { + vi.useFakeTimers(); + const {mockFixture} = setupPromptTestSession({sessionId}); + mockStatusPages(mockFixture, {data: [server({name: "slow", runtimeStatus: "starting"})], nextCursor: null}); + vi.spyOn(mockFixture.getCodexAppServerClient(), "mcpServerReload").mockResolvedValue({}); + + mockFixture.clearAcpConnectionDump(); + const prompt = mockFixture.getCodexAcpAgent().prompt({sessionId, prompt: [{type: "text", text: "/mcp reconnect"}]}); + await vi.advanceTimersByTimeAsync(MCP_RECONNECT_STARTUP_TIMEOUT_MS); + await prompt; + + expect(messageText(mockFixture)).toContain("Some MCP servers are still starting. Run `/mcp` again later to see their status."); + expect(messageText(mockFixture)).toContain("**Connecting**\n- `slow`"); + expect(pendingStartupWaits(mockFixture)).toBe(0); + }); + + it("signs in to a server that needs authentication when the client supports URL elicitation", async () => { + const {mockFixture} = setupPromptTestSession({sessionId}); + await enableUrlElicitation(mockFixture); + mockFixture.setElicitationResponse({action: "accept"}); + const needsAuth = {data: [server({name: "linear", runtimeStatus: "authenticationRequired", authStatus: "notLoggedIn"})], nextCursor: null}; + mockStatusPages(mockFixture, + needsAuth, + needsAuth, + needsAuth, + {data: [server({name: "linear", tools: tools("issues"), authStatus: "oAuth"})], nextCursor: null}, + ); + const reload = vi.spyOn(mockFixture.getCodexAppServerClient(), "mcpServerReload").mockImplementation(async () => { + mockFixture.sendServerNotification(reload.mock.calls.length === 1 + ? startupStatus("linear", "failed", "OAuth token expired", "reauthenticationRequired") + : startupStatus("linear", "ready")); + return {}; + }); + const login = mockOauthLogin(mockFixture); + + await runMcp(mockFixture, "/mcp reconnect"); + + expect(login).toHaveBeenCalledWith({name: "linear", threadId: sessionId}); + expect(reload).toHaveBeenCalledTimes(2); + const text = messageText(mockFixture); + expect(text).toContain("Signed in to MCP server `linear`."); + expect(text).toContain("**Connected**\n- `linear`: 1 tool"); + }); + + it("says that the client cannot sign in without URL elicitation", async () => { + const {mockFixture} = setupPromptTestSession({sessionId}); + mockStatusPages(mockFixture, { + data: [server({name: "linear", runtimeStatus: "authenticationRequired", authStatus: "notLoggedIn"})], + nextCursor: null, + }); + const reload = vi.spyOn(mockFixture.getCodexAppServerClient(), "mcpServerReload").mockImplementation(async () => { + mockFixture.sendServerNotification(startupStatus("linear", "failed", "OAuth token expired", "reauthenticationRequired")); + return {}; + }); + const login = vi.spyOn(mockFixture.getCodexAcpClient(), "mcpServerOauthLogin"); + + await runMcp(mockFixture, "/mcp reconnect"); + + expect(login).not.toHaveBeenCalled(); + expect(reload).toHaveBeenCalledTimes(1); + expect(messageText(mockFixture)).toContain( + "MCP server `linear` needs authentication, but this client cannot open a sign-in page.", + ); + }); + + it("does not sign in when the reload fails", async () => { + const {mockFixture} = setupPromptTestSession({sessionId}); + await enableUrlElicitation(mockFixture); + mockStatusPages(mockFixture, { + data: [server({name: "linear", runtimeStatus: "authenticationRequired", authStatus: "notLoggedIn"})], + nextCursor: null, + }); + const reload = vi.spyOn(mockFixture.getCodexAppServerClient(), "mcpServerReload") + .mockRejectedValue(new Error("invalid config.toml")); + const login = mockOauthLogin(mockFixture); + + await runMcp(mockFixture, "/mcp reconnect"); + + expect(login).not.toHaveBeenCalled(); + expect(reload).toHaveBeenCalledTimes(1); + expect(messageText(mockFixture)).toContain("Could not reload the MCP configuration: invalid config.toml"); + }); + + it("puts a reload error on one line, escapes the markdown, and shortens it", async () => { + const {mockFixture} = setupPromptTestSession({sessionId}); + mockStatusPages(mockFixture, {data: [server({name: "fs", runtimeStatus: "failed"})], nextCursor: null}); + vi.spyOn(mockFixture.getCodexAppServerClient(), "mcpServerReload") + .mockRejectedValue(new Error(`bad \`fs\` entry\n${"y".repeat(400)}`)); + + await runMcp(mockFixture, "/mcp reconnect"); + + const [label, firstNote] = messageText(mockFixture).split("\n"); + expect(label).toBe("**Reconnect:**"); + expect(firstNote).toBe(`- Could not reload the MCP configuration: bad \\\`fs\\\` entry ${"y".repeat(160 - "bad `fs` entry ".length)}…`); + }); + + it.each([ + ["declines the sign-in", (fixture: CodexMockTestFixture) => { + fixture.setElicitationResponse({action: "decline"}); + mockOauthLogin(fixture); + vi.spyOn(fixture.getCodexAcpClient(), "awaitMcpServerOauthLoginCompleted").mockReturnValue(new Promise(() => {})); + }], + ["the sign-in fails", (fixture: CodexMockTestFixture) => { + fixture.setElicitationResponse({action: "accept"}); + mockOauthLogin(fixture, false); + }], + ["the sign-in throws", (fixture: CodexMockTestFixture) => { + vi.spyOn(fixture.getCodexAcpClient(), "mcpServerOauthLogin").mockRejectedValue(new Error("no OAuth")); + }], + ])("does not reload again when the client %s", async (_name, setUp) => { + const {mockFixture} = setupPromptTestSession({sessionId}); + await enableUrlElicitation(mockFixture); + mockStatusPages(mockFixture, { + data: [server({name: "linear", runtimeStatus: "authenticationRequired", authStatus: "notLoggedIn"})], + nextCursor: null, + }); + const reload = vi.spyOn(mockFixture.getCodexAppServerClient(), "mcpServerReload").mockImplementation(async () => { + mockFixture.sendServerNotification(startupStatus("linear", "failed", "OAuth token expired", "reauthenticationRequired")); + return {}; + }); + setUp(mockFixture); + + await runMcp(mockFixture, "/mcp reconnect"); + + expect(reload).toHaveBeenCalledTimes(1); + const text = messageText(mockFixture); + expect(text).toContain("The sign-in to MCP server `linear` failed or was cancelled."); + expect(text).toContain("**Needs authentication**\n- `linear`: not signed in\n"); + }); + + it("reports a failure of the second reload after the sign-in", async () => { + const {mockFixture} = setupPromptTestSession({sessionId}); + await enableUrlElicitation(mockFixture); + mockFixture.setElicitationResponse({action: "accept"}); + mockStatusPages(mockFixture, { + data: [server({name: "linear", runtimeStatus: "authenticationRequired", authStatus: "notLoggedIn"})], + nextCursor: null, + }); + const reload = vi.spyOn(mockFixture.getCodexAppServerClient(), "mcpServerReload").mockImplementation(async () => { + if (reload.mock.calls.length > 1) { + throw new Error("config.toml changed\nduring the sign-in"); + } + mockFixture.sendServerNotification(startupStatus("linear", "failed", "OAuth token expired", "reauthenticationRequired")); + return {}; + }); + mockOauthLogin(mockFixture); + + await runMcp(mockFixture, "/mcp reconnect"); + + expect(reload).toHaveBeenCalledTimes(2); + const text = messageText(mockFixture); + expect(text).toContain([ + "- Signed in to MCP server `linear`.", + "- Could not reload the MCP configuration after the sign-in: config.toml changed during the sign-in", + "", + "**MCP servers:**", + ].join("\n")); + expect(text.startsWith("**Reconnect:**\n- Reloaded the MCP configuration")).toBe(true); + expect(text).toContain("**Needs authentication**\n- `linear`: not signed in\n"); + }); + + it("rejects a reconnect with more than one server", async () => { + const {mockFixture} = setupPromptTestSession({sessionId}); + const list = mockStatusPages(mockFixture, {data: [server({name: "a"}), server({name: "b"})], nextCursor: null}); + const reload = vi.spyOn(mockFixture.getCodexAppServerClient(), "mcpServerReload"); + + await runMcp(mockFixture, "/mcp reconnect a b"); + + expect(list).not.toHaveBeenCalled(); + expect(reload).not.toHaveBeenCalled(); + expect(messageText(mockFixture)).toBe('Command "/mcp" accepts no arguments, or `reconnect [server]`.'); + }); + + it("accepts the subcommand in any case, as the command name", async () => { + const {mockFixture} = setupPromptTestSession({sessionId}); + mockStatusPages(mockFixture, {data: [server({name: "fs"})], nextCursor: null}); + const reload = vi.spyOn(mockFixture.getCodexAppServerClient(), "mcpServerReload").mockImplementation(async () => { + mockFixture.sendServerNotification(startupStatus("fs", "ready")); + return {}; + }); + + await runMcp(mockFixture, "/MCP RECONNECT"); + + expect(reload).toHaveBeenCalledTimes(1); + expect(messageText(mockFixture)).toContain("**Connected**\n- `fs`: 0 tools"); + }); + + it("ignores the startup events of another thread", async () => { + vi.useFakeTimers(); + const {mockFixture} = setupPromptTestSession({sessionId}); + mockStatusPages(mockFixture, {data: [server({name: "fs", runtimeStatus: "starting"})], nextCursor: null}); + vi.spyOn(mockFixture.getCodexAppServerClient(), "mcpServerReload").mockImplementation(async () => { + mockFixture.sendServerNotification(startupStatus("fs", "failed", "other thread", null, "other-thread")); + return {}; + }); + + mockFixture.clearAcpConnectionDump(); + const prompt = mockFixture.getCodexAcpAgent().prompt({sessionId, prompt: [{type: "text", text: "/mcp reconnect"}]}); + await vi.advanceTimersByTimeAsync(MCP_RECONNECT_STARTUP_TIMEOUT_MS - 1); + expect(messageText(mockFixture)).toBe(""); + mockFixture.sendServerNotification(startupStatus("fs", "ready")); + await prompt; + + const text = messageText(mockFixture); + expect(text).not.toContain("other thread"); + expect(text).not.toContain("still starting"); + }); + + it("counts a startup event without a thread for every thread", async () => { + const {mockFixture} = setupPromptTestSession({sessionId}); + mockStatusPages(mockFixture, {data: [server({name: "fs", runtimeStatus: "failed"})], nextCursor: null}); + vi.spyOn(mockFixture.getCodexAppServerClient(), "mcpServerReload").mockImplementation(async () => { + mockFixture.sendServerNotification(startupStatus("fs", "failed", "global failure", null, null)); + return {}; + }); + + await runMcp(mockFixture, "/mcp reconnect"); + + expect(messageText(mockFixture)).toContain("**Failed**\n- `fs`: global failure"); + }); + + it("sends nothing when the prompt is cancelled during the reconnect", async () => { + const {mockFixture} = setupPromptTestSession({sessionId}); + mockStatusPages(mockFixture, {data: [server({name: "fs", runtimeStatus: "failed"})], nextCursor: null}); + const reload = vi.spyOn(mockFixture.getCodexAppServerClient(), "mcpServerReload").mockResolvedValue({}); + const cancel = new AbortController(); + + mockFixture.clearAcpConnectionDump(); + const prompt = mockFixture.getCodexAcpAgent().prompt({sessionId, prompt: [{type: "text", text: "/mcp reconnect"}]}, cancel.signal); + await vi.waitFor(() => expect(reload).toHaveBeenCalled()); + await vi.waitFor(() => expect(pendingStartupWaits(mockFixture)).toBe(1)); + cancel.abort(); + + await expect(prompt).resolves.toMatchObject({stopReason: "cancelled"}); + mockFixture.sendServerNotification(startupStatus("fs", "ready")); + await new Promise(resolve => setTimeout(resolve, 10)); + expect(messageText(mockFixture)).toBe(""); + expect(pendingStartupWaits(mockFixture)).toBe(0); + }); + + it("fails the reconnect when the Codex connection is disposed during the startup wait", async () => { + const {mockFixture} = setupPromptTestSession({sessionId}); + mockStatusPages(mockFixture, {data: [server({name: "fs", runtimeStatus: "failed"})], nextCursor: null}); + const reload = vi.spyOn(mockFixture.getCodexAppServerClient(), "mcpServerReload").mockResolvedValue({}); + + mockFixture.clearAcpConnectionDump(); + const prompt = mockFixture.getCodexAcpAgent().prompt({sessionId, prompt: [{type: "text", text: "/mcp reconnect"}]}); + await vi.waitFor(() => expect(reload).toHaveBeenCalled()); + await vi.waitFor(() => expect(pendingStartupWaits(mockFixture)).toBe(1)); + mockFixture.getCodexAppServerClient().connection.dispose(); + + await expect(prompt).rejects.toThrow("Codex connection closed during the MCP server startup."); + expect(pendingStartupWaits(mockFixture)).toBe(0); + expect(messageText(mockFixture)).toBe(""); + }); + + it("closes the sign-in dialog and sends nothing when the prompt is cancelled during the sign-in", async () => { + const {mockFixture} = setupPromptTestSession({sessionId}); + await enableUrlElicitation(mockFixture); + mockFixture.setElicitationResponse(new Promise(() => {})); + mockStatusPages(mockFixture, { + data: [server({name: "linear", runtimeStatus: "authenticationRequired", authStatus: "notLoggedIn"})], + nextCursor: null, + }); + const reload = vi.spyOn(mockFixture.getCodexAppServerClient(), "mcpServerReload").mockImplementation(async () => { + mockFixture.sendServerNotification(startupStatus("linear", "failed", "OAuth token expired", "reauthenticationRequired")); + return {}; + }); + vi.spyOn(mockFixture.getCodexAcpClient(), "mcpServerOauthLogin").mockResolvedValue({ + authorizationUrl: "https://example.com/oauth/authorize", + }); + const cancel = new AbortController(); + + mockFixture.clearAcpConnectionDump(); + const prompt = mockFixture.getCodexAcpAgent().prompt({sessionId, prompt: [{type: "text", text: "/mcp reconnect"}]}, cancel.signal); + await vi.waitFor(() => expect(elicitationEvents(mockFixture, "createElicitation")).toHaveLength(1)); + expect(pendingOauthWaits(mockFixture)).toBe(1); + cancel.abort(); + + await expect(prompt).resolves.toMatchObject({stopReason: "cancelled"}); + await vi.waitFor(() => expect(elicitationEvents(mockFixture, "completeElicitation")).toHaveLength(1)); + expect(pendingOauthWaits(mockFixture)).toBe(0); + mockFixture.sendServerNotification({ + method: "mcpServer/oauthLogin/completed", + params: {name: "linear", threadId: sessionId, success: true}, + }); + await new Promise(resolve => setTimeout(resolve, 10)); + expect(messageText(mockFixture)).toBe(""); + expect(reload).toHaveBeenCalledTimes(1); + }); + + it("leaves no sign-in wait when the client declines the sign-in", async () => { + const {mockFixture} = setupPromptTestSession({sessionId}); + await enableUrlElicitation(mockFixture); + mockFixture.setElicitationResponse({action: "decline"}); + mockStatusPages(mockFixture, { + data: [server({name: "linear", runtimeStatus: "authenticationRequired", authStatus: "notLoggedIn"})], + nextCursor: null, + }); + vi.spyOn(mockFixture.getCodexAppServerClient(), "mcpServerReload").mockImplementation(async () => { + mockFixture.sendServerNotification(startupStatus("linear", "failed", "OAuth token expired", "reauthenticationRequired")); + return {}; + }); + vi.spyOn(mockFixture.getCodexAcpClient(), "mcpServerOauthLogin").mockResolvedValue({ + authorizationUrl: "https://example.com/oauth/authorize", + }); + + await runMcp(mockFixture, "/mcp reconnect"); + + expect(messageText(mockFixture)).toContain("The sign-in to MCP server `linear` failed or was cancelled."); + expect(elicitationEvents(mockFixture, "completeElicitation")).toHaveLength(0); + expect(pendingOauthWaits(mockFixture)).toBe(0); + }); + + it("completes two sign-ins to the same server with one notification", async () => { + const {mockFixture} = setupPromptTestSession({sessionId}); + const codexAcpClient = mockFixture.getCodexAcpClient(); + // For example, the startup report and `/mcp reconnect` sign in at the same time. + const first = codexAcpClient.awaitMcpServerOauthLoginCompleted("linear", sessionId); + const second = codexAcpClient.awaitMcpServerOauthLoginCompleted("linear", sessionId); + + mockFixture.sendServerNotification({ + method: "mcpServer/oauthLogin/completed", + params: {name: "linear", threadId: sessionId, success: true}, + }); + + await expect(first).resolves.toMatchObject({name: "linear", success: true}); + await expect(second).resolves.toMatchObject({name: "linear", success: true}); + expect(pendingOauthWaits(mockFixture)).toBe(0); + }); + + it("does not wait for a server that the reload removed", async () => { + const {mockFixture} = setupPromptTestSession({sessionId}); + mockStatusPages(mockFixture, + {data: [server({name: "fs", runtimeStatus: "failed"}), server({name: "gone", runtimeStatus: "failed"})], nextCursor: null}, + {data: [server({name: "fs", runtimeStatus: "starting"})], nextCursor: null}, + {data: [server({name: "fs"})], nextCursor: null}, + ); + vi.spyOn(mockFixture.getCodexAppServerClient(), "mcpServerReload").mockImplementation(async () => { + mockFixture.sendServerNotification(startupStatus("fs", "ready")); + return {}; + }); + + await runMcp(mockFixture, "/mcp reconnect"); + + const text = messageText(mockFixture); + expect(text).not.toContain("still starting"); + expect(text).not.toContain("gone"); + expect(text).toContain("**Connected**\n- `fs`: 0 tools"); + }); + + it("lists the servers without the thread when Codex does not know the thread", async () => { + const {mockFixture} = setupPromptTestSession({sessionId}); + const list = vi.spyOn(mockFixture.getCodexAppServerClient(), "listMcpServerStatus").mockImplementation(async params => { + if (params.threadId !== undefined) { + throw new Error(`thread not found: ${params.threadId}`); + } + return {data: [server({name: "fs", runtimeStatus: null})], nextCursor: null}; + }); + const reload = vi.spyOn(mockFixture.getCodexAppServerClient(), "mcpServerReload").mockResolvedValue({}); + + await runMcp(mockFixture, "/mcp reconnect"); + + expect(list).toHaveBeenCalledWith({cursor: null, detail: "toolsAndAuthOnly"}); + expect(reload).toHaveBeenCalledTimes(1); + expect(pendingStartupWaits(mockFixture)).toBe(0); + const text = messageText(mockFixture); + expect(text).not.toContain("still starting"); + expect(text).toContain("**Unknown**\n- `fs`"); + }); + + it("does not retry the list without the thread for another error", async () => { + const {mockFixture} = setupPromptTestSession({sessionId}); + const list = vi.spyOn(mockFixture.getCodexAppServerClient(), "listMcpServerStatus") + .mockRejectedValue(new Error("MCP status list failed")); + + const prompt = mockFixture.getCodexAcpAgent().prompt({sessionId, prompt: [{type: "text", text: "/mcp"}]}); + + await expect(prompt).rejects.toThrow("MCP status list failed"); + expect(list).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/__tests__/CodexACPAgent/mcp-config-merge.test.ts b/src/__tests__/CodexACPAgent/mcp-config-merge.test.ts index 94a92d710..897edfef5 100644 --- a/src/__tests__/CodexACPAgent/mcp-config-merge.test.ts +++ b/src/__tests__/CodexACPAgent/mcp-config-merge.test.ts @@ -75,8 +75,8 @@ url = "https://example.com/mcp" }); const transportDump = fixture.getAcpConnectionDump([]); - expect(transportDump).contain("Configured MCP servers:"); - expect(transportDump).contain("- shared-mcp"); + expect(transportDump).contain("**MCP servers:** "); + expect(transportDump).contain("- `shared-mcp`"); }); it('should preserve a project url-based MCP when ACP passes a command-type MCP with the same name', async () => { diff --git a/src/__tests__/CodexACPAgent/mcp-session.test.ts b/src/__tests__/CodexACPAgent/mcp-session.test.ts index 848650742..988dc648f 100644 --- a/src/__tests__/CodexACPAgent/mcp-session.test.ts +++ b/src/__tests__/CodexACPAgent/mcp-session.test.ts @@ -26,8 +26,27 @@ describe('MCP session configuration', { timeout: 40_000 }, () => { fixture.clearAcpConnectionDump(); await codexAcpAgent.prompt({sessionId: newSessionResponse.sessionId, prompt: [{type: "text", text: "/mcp"}]}); const transportDump = fixture.getAcpConnectionDump([]); - expect(transportDump).contain("Configured MCP servers:"); - expect(transportDump).contain("- test-mcp"); + expect(transportDump).contain("**MCP servers:** 1 ("); + // The server can still be starting, because the session does not wait for the MCP startup. + expect(transportDump).toMatch(/\*\*(Connected|Connecting|Not started)\*\*\\n- `test-mcp`/); + }); + + it('should reconnect the session mcp servers', async () => { + const codexAcpAgent = fixture.getCodexAcpAgent(); + await codexAcpAgent.initialize({protocolVersion: 1}); + + fixture.getCodexAcpClient().authRequired = vi.fn().mockResolvedValue(false); + const mcpServer: McpServerStdio = { + name: "test-mcp", command: "./node_modules/.bin/mcp-hello-world", args: ["example"], env: [{name:"example", value: "example"}] + }; + + const newSessionResponse = await codexAcpAgent.newSession({cwd: "", mcpServers: [mcpServer]}); + fixture.clearAcpConnectionDump(); + await codexAcpAgent.prompt({sessionId: newSessionResponse.sessionId, prompt: [{type: "text", text: "/mcp reconnect test-mcp"}]}); + const transportDump = fixture.getAcpConnectionDump([]); + expect(transportDump).contain("Reloaded the MCP configuration"); + expect(transportDump).not.contain("still starting"); + expect(transportDump).contain("**Connected**\\n- `test-mcp`: "); }); }); diff --git a/src/__tests__/CodexACPAgent/session-close.test.ts b/src/__tests__/CodexACPAgent/session-close.test.ts index d2d0fd101..3c174e6f5 100644 --- a/src/__tests__/CodexACPAgent/session-close.test.ts +++ b/src/__tests__/CodexACPAgent/session-close.test.ts @@ -8,7 +8,7 @@ import { } from "../acp-test-utils"; import type {CodexAcpServer} from "../../CodexAcpServer"; import type {CodexAcpClient, SessionMetadata} from "../../CodexAcpClient"; -import type {McpStartupResult} from "../../CodexAppServerClient"; +import type {McpStartupResult} from "../../mcp/McpStartupTracker"; import type {TurnStartResponse} from "../../app-server/v2"; import type {McpServer} from "@agentclientprotocol/sdk"; @@ -201,7 +201,7 @@ describe("ACP session close", () => { const newSessionPromise = codexAcpAgent.newSession({cwd: "/test/cwd", mcpServers: [mcpServer]}); await vi.waitFor(() => { - expect(codexAcpClient.awaitMcpServerStartup).toHaveBeenCalledWith(["broken-mcp"], expect.any(Number)); + expect(codexAcpClient.awaitMcpServerStartup).toHaveBeenCalledWith(["broken-mcp"], expect.any(Number), {threadId: "session-id", signal: expect.any(AbortSignal)}); }); fixture.clearAcpConnectionDump(); @@ -224,6 +224,66 @@ describe("ACP session close", () => { await closePromise; }); + it("stops the MCP startup wait and forgets the startup states of the closed session", async () => { + const {fixture, codexAcpAgent, codexAcpClient} = await createSession({ + mcpServers: [{name: "slow-mcp", command: "npx", args: ["slow"], env: []}], + }); + const tracker = codexAcpClient.appServerClient.mcpStartup as unknown as { + waiters: unknown[], + states: Map>, + }; + fixture.sendServerNotification({ + method: "mcpServer/startupStatus/updated", + params: {threadId: sessionId, name: "slow-mcp", status: "starting", error: null, failureReason: null}, + }); + fixture.sendServerNotification({ + method: "mcpServer/startupStatus/updated", + params: {threadId: null, name: "global-mcp", status: "ready", error: null, failureReason: null}, + }); + expect(tracker.waiters).toHaveLength(1); + + await codexAcpAgent.closeSession({sessionId}); + await waitForMicrotasks(); + + expect(tracker.waiters).toHaveLength(0); + expect([...tracker.states.keys()]).toEqual(["global-mcp"]); + expect(fixture.getAcpConnectionEvents([])).toEqual([]); + }); + + it("closes the sign-in of the startup report and reports nothing when the session closes", async () => { + const fixture = createCodexMockTestFixture(); + const codexAcpAgent = fixture.getCodexAcpAgent(); + const codexAcpClient = fixture.getCodexAcpClient(); + await codexAcpAgent.initialize({protocolVersion: 1, clientCapabilities: {elicitation: {url: {}}}}); + vi.spyOn(codexAcpClient, "authRequired").mockResolvedValue(false); + vi.spyOn(codexAcpClient, "getAccount").mockResolvedValue({account: null, requiresOpenaiAuth: false}); + vi.spyOn(codexAcpClient, "listSkills").mockResolvedValue({data: []}); + vi.spyOn(codexAcpClient, "newSession").mockResolvedValue({ + sessionId, + currentModelId: "model-id[medium]", + models: [createTestModel()], + collaborationMode: "default", + currentServiceTier: null, + additionalDirectories: [], + }); + vi.spyOn(codexAcpClient, "mcpServerOauthLogin").mockResolvedValue({authorizationUrl: "https://example.com/oauth"}); + fixture.setElicitationResponse(new Promise(() => {})); + await codexAcpAgent.newSession({cwd: "/test/cwd", mcpServers: [{name: "linear", command: "npx", args: [], env: []}]}); + fixture.clearAcpConnectionDump(); + + fixture.sendServerNotification({ + method: "mcpServer/startupStatus/updated", + params: {threadId: sessionId, name: "linear", status: "failed", error: "expired", failureReason: "reauthenticationRequired"}, + }); + await vi.waitFor(() => { + expect(fixture.getAcpConnectionEvents([]).map(event => event.method)).toEqual(["createElicitation"]); + }); + await codexAcpAgent.closeSession({sessionId}); + await waitForMicrotasks(); + + expect(fixture.getAcpConnectionEvents([]).map(event => event.method)).toEqual(["createElicitation", "completeElicitation"]); + }); + it("rejects an in-flight resume that completes after close", async () => { const {codexAcpAgent, codexAcpClient} = await createSession(); const resume = deferred(); diff --git a/src/__tests__/CodexACPAgent/session-fork.test.ts b/src/__tests__/CodexACPAgent/session-fork.test.ts index 7ce8f3a5c..2ea92d7ab 100644 --- a/src/__tests__/CodexACPAgent/session-fork.test.ts +++ b/src/__tests__/CodexACPAgent/session-fork.test.ts @@ -1,6 +1,6 @@ import {describe, expect, it, vi} from "vitest"; import {createCodexMockTestFixture, createTestModel} from "../acp-test-utils"; -import type {McpStartupResult} from "../../CodexAppServerClient"; +import type {McpStartupResult} from "../../mcp/McpStartupTracker"; describe("ACP session fork", () => { it("creates and installs a forked session", async () => { @@ -78,13 +78,40 @@ describe("ACP session fork", () => { ); await vi.waitFor(() => { - expect(awaitMcpStartupSpy).toHaveBeenCalledWith(["fork-mcp"], expect.any(Number)); + expect(awaitMcpStartupSpy).toHaveBeenCalledWith(["fork-mcp"], expect.any(Number), {threadId: "fork-id", signal: expect.any(AbortSignal)}); }); expect(forkSettled).toBe(false); mcpStartup.resolve({ready: ["fork-mcp"], failed: [], cancelled: []}); await expect(forkPromise).resolves.toMatchObject({sessionId: "fork-id", modes: expect.any(Object)}); }); + + it("leaves no MCP startup wait after a fork without the startup wait", async () => { + const fixture = createCodexMockTestFixture(); + const agent = fixture.getCodexAcpAgent(); + const client = fixture.getCodexAcpClient(); + + vi.spyOn(client, "authRequired").mockResolvedValue(false); + vi.spyOn(client, "getAccount").mockResolvedValue({account: null, requiresOpenaiAuth: false}); + vi.spyOn(client, "listSkills").mockResolvedValue({data: []}); + vi.spyOn(client, "forkSession").mockResolvedValue({ + sessionId: "fork-id", + currentModelId: "gpt-5[medium]", + models: [createTestModel({id: "gpt-5"})], + collaborationMode: "default", + currentServiceTier: null, + additionalDirectories: [], + }); + + await agent.forkSession({ + sessionId: "source-id", + cwd: "/workspace", + mcpServers: [{name: "fork-mcp", command: "npx", args: ["fork"], env: []}], + }); + + const tracker = client.appServerClient.mcpStartup as unknown as {waiters: unknown[]}; + expect(tracker.waiters).toHaveLength(0); + }); }); function deferred(): {promise: Promise, resolve: (value: T) => void} { diff --git a/src/__tests__/McpStartupTracker.test.ts b/src/__tests__/McpStartupTracker.test.ts new file mode 100644 index 000000000..1bbec2841 --- /dev/null +++ b/src/__tests__/McpStartupTracker.test.ts @@ -0,0 +1,123 @@ +import {chmodSync, mkdtempSync, rmSync, writeFileSync} from "node:fs"; +import {tmpdir} from "node:os"; +import path from "node:path"; +import {afterEach, describe, expect, it} from "vitest"; +import {CodexAppServerClient} from "../CodexAppServerClient"; +import {startCodexConnection} from "../CodexJsonRpcConnection"; +import {McpOauthCompletions} from "../mcp/McpOauthCompletions"; +import {McpStartupTracker} from "../mcp/McpStartupTracker"; + +function startupStatus(name: string, threadId: string | null, status: "starting" | "ready" = "ready") { + return {threadId, name, status, error: null, failureReason: null}; +} + +function waiterCount(tracker: McpStartupTracker): number { + return (tracker as unknown as {waiters: unknown[]}).waiters.length; +} + +function stateThreads(tracker: McpStartupTracker): Record> { + const states = (tracker as unknown as {states: Map>}).states; + return Object.fromEntries([...states].map(([name, byThread]) => [name, [...byThread.keys()]])); +} + +function oauthWaiterCount(completions: McpOauthCompletions): number { + const waiters = (completions as unknown as {waiters: Map>}).waiters; + return [...waiters.values()].reduce((count, set) => count + set.size, 0); +} + +describe("McpStartupTracker", () => { + const tempDirs: string[] = []; + + afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, {recursive: true, force: true}); + } + }); + + it.skipIf(process.platform === "win32")("rejects the MCP waits when the Codex process exits", async () => { + // The real connection path: the process exit disposes the vscode-jsonrpc connection and fires no close event. + const dir = mkdtempSync(path.join(tmpdir(), "codex-acp-exit-")); + tempDirs.push(dir); + const fakeCodex = path.join(dir, "codex"); + writeFileSync(fakeCodex, "#!/bin/sh\nexec sleep 30\n"); + chmodSync(fakeCodex, 0o755); + const codex = startCodexConnection(fakeCodex); + const client = new CodexAppServerClient(codex.connection); + + const startup = client.mcpStartup.await(["fs"], client.mcpStartup.version(), {threadId: "thread-id"}); + const signIn = client.mcpOauthCompletions.await("fs", "thread-id"); + codex.process.kill(); + + await expect(startup).rejects.toThrow("Codex connection closed during the MCP server startup."); + await expect(signIn).rejects.toThrow("Codex connection closed during the MCP server sign-in."); + expect(waiterCount(client.mcpStartup)).toBe(0); + expect(oauthWaiterCount(client.mcpOauthCompletions)).toBe(0); + await expect(client.mcpStartup.await(["fs"], client.mcpStartup.version(), {threadId: "thread-id"})) + .rejects.toThrow("Codex connection closed during the MCP server startup."); + }); + + it("forgets the states of a thread and keeps the global states", () => { + const tracker = new McpStartupTracker(); + tracker.record(startupStatus("fs", "closed-thread")); + tracker.record(startupStatus("fs", "open-thread")); + tracker.record(startupStatus("only-closed", "closed-thread")); + tracker.record(startupStatus("global", null)); + + tracker.forgetThread("closed-thread"); + + expect(stateThreads(tracker)).toEqual({fs: ["open-thread"], global: [null]}); + }); + + it("removes an aborted wait", async () => { + const tracker = new McpStartupTracker(); + const abort = new AbortController(); + const startup = tracker.await(["fs"], tracker.version(), {threadId: "thread-id", signal: abort.signal}); + expect(waiterCount(tracker)).toBe(1); + + abort.abort(new Error("closed")); + + await expect(startup).rejects.toThrow("closed"); + expect(waiterCount(tracker)).toBe(0); + }); +}); + +describe("McpOauthCompletions", () => { + it("completes every sign-in that waits for the server", async () => { + const completions = new McpOauthCompletions(); + const first = completions.await("linear", "thread-id"); + const second = completions.await("linear", "thread-id"); + const other = completions.await("github", "thread-id"); + + completions.complete({name: "linear", threadId: "thread-id", success: true}); + + await expect(first).resolves.toMatchObject({name: "linear", success: true}); + await expect(second).resolves.toMatchObject({name: "linear", success: true}); + expect(oauthWaiterCount(completions)).toBe(1); + completions.complete({name: "github", threadId: "thread-id", success: false}); + await expect(other).resolves.toMatchObject({name: "github", success: false}); + expect(oauthWaiterCount(completions)).toBe(0); + }); + + it("ignores the completion of another thread", async () => { + const completions = new McpOauthCompletions(); + let completed = false; + void completions.await("linear", "thread-id").then(() => { completed = true; }); + + completions.complete({name: "linear", threadId: "other-thread", success: true}); + await Promise.resolve(); + + expect(completed).toBe(false); + expect(oauthWaiterCount(completions)).toBe(1); + }); + + it("removes an aborted wait", async () => { + const completions = new McpOauthCompletions(); + const abort = new AbortController(); + const signIn = completions.await("linear", "thread-id", abort.signal); + + abort.abort(new Error("cancelled")); + + await expect(signIn).rejects.toThrow("cancelled"); + expect(oauthWaiterCount(completions)).toBe(0); + }); +}); diff --git a/src/__tests__/McpStatusMarkdown.test.ts b/src/__tests__/McpStatusMarkdown.test.ts new file mode 100644 index 000000000..b723cb163 --- /dev/null +++ b/src/__tests__/McpStatusMarkdown.test.ts @@ -0,0 +1,230 @@ +import {describe, expect, it} from "vitest"; +import type {McpServerStatus} from "../app-server/v2"; +import {cleanErrorMessage, formatStatus, inlineCode, type McpServerEntry, toEntry} from "../mcp/McpStatusMarkdown"; + +const GLEAN_ERROR = "Tool discovery failed: MCP startup failed: handshaking with MCP server failed: Send message error Transport " + + "[codex_rmcp_client::event_notification_transport::EventNotificationTransport & {name: string}): McpServerEntry { + return { + status: "connected", + toolCount: 0, + toolsError: null, + authStatus: "unsupported", + listedByCodex: true, + ...overrides, + }; +} + +describe("cleanErrorMessage", () => { + it("keeps the human message of the ijproxy error", () => { + expect(cleanErrorMessage(IJPROXY_ERROR)).toBe("No IDE found. Install the \"MCP Server\" plugin and ensure it is enabled."); + }); + + it("removes the wrappers and the Rust type paths of the Glean error", () => { + expect(cleanErrorMessage(GLEAN_ERROR)).toBe("Send message error: not logged in"); + }); + + it("keeps the cause after a message that is not a wrapper", () => { + expect(cleanErrorMessage("failed to refresh MCP servers: invalid config.toml")).toBe("failed to refresh MCP servers: invalid config.toml"); + }); + + it("removes the MCP client wrapper of a startup error", () => { + expect(cleanErrorMessage("MCP client for `fs` failed to start: spawn ENOENT")).toBe("spawn ENOENT"); + }); + + it("puts the text on one line and shortens it with an ellipsis", () => { + expect(cleanErrorMessage(`first\nsecond ${"x".repeat(300)}`)).toBe(`first second ${"x".repeat(160 - "first second ".length)}…`); + }); + + it("returns the shortened raw text when only wrappers remain", () => { + expect(cleanErrorMessage("JSON-RPC error: -32603")).toBe("JSON-RPC error: -32603"); + }); + + it("keeps a lone less-than sign", () => { + expect(cleanErrorMessage("expected value < 10, got 12")).toBe("expected value < 10, got 12"); + }); + + it("keeps a generic type without a type path", () => { + expect(cleanErrorMessage("invalid header: Header value contains Vec bytes")) + .toBe("invalid header: Header value contains Vec bytes"); + }); + + it("keeps an IPv6 URL", () => { + expect(cleanErrorMessage("failed to connect to http://[::1]:8080/mcp: Connection refused")) + .toBe("failed to connect to http://[::1]:8080/mcp: Connection refused"); + expect(cleanErrorMessage("failed to connect to http://[fe80::1]:8080/mcp")).toBe("failed to connect to http://[fe80::1]:8080/mcp"); + }); + + it("removes a closed type path and keeps the text after it", () => { + expect(cleanErrorMessage("expected std::vec::Vec here, got a string")).toBe("expected here, got a string"); + expect(cleanErrorMessage("send failed Transport [rmcp::Transport>] now")).toBe("send failed now"); + }); + + it("returns an empty text for an empty error", () => { + expect(cleanErrorMessage("")).toBe(""); + expect(cleanErrorMessage(" \n ")).toBe(""); + }); + + it("shortens by code points and never splits a surrogate pair", () => { + const text = `${"x".repeat(159)}😀😀`; + + expect(cleanErrorMessage(text)).toBe(`${"x".repeat(159)}😀…`); + }); + + it("drops an earlier segment that the next segment repeats and extends", () => { + expect(cleanErrorMessage("timeout: timeout after 30s")).toBe("timeout after 30s"); + expect(cleanErrorMessage("time: timeout after 30s")).toBe("time: timeout after 30s"); + }); + + it("does not end a sentence at e.g. or i.e.", () => { + expect(cleanErrorMessage("Bad flag, e.g. --foo is unknown. Use i.e. the default. Third sentence.")) + .toBe("Bad flag, e.g. --foo is unknown. Use i.e. the default."); + }); +}); + +describe("formatStatus", () => { + it("groups the servers of a real session", () => { + const servers = [ + entry({name: "code-review", status: "disabled"}), + entry({name: "codex_app", status: "disabled"}), + entry({name: "codex_apps", toolCount: 100, authStatus: "bearerToken"}), + entry({name: "computer-use", status: "disabled"}), + entry({name: "cua_repl", toolCount: 3}), + entry({ + name: "Glean", + status: "authenticationRequired", + authStatus: "notLoggedIn", + toolsError: GLEAN_ERROR.replace("; Auth: not logged in", ""), + }), + entry({name: "ijproxy", status: "failed", toolsError: IJPROXY_ERROR}), + entry({name: "jbcontext", toolCount: 1}), + entry({name: "node_repl", toolCount: 4}), + entry({name: "openaiDeveloperDocs", toolCount: 5}), + ]; + + expect(formatStatus(servers, new Map())).toBe([ + "**MCP servers:** 10 (5 connected, 1 needs authentication, 1 failed, 3 disabled)", + "", + "**Failed**", + "- `ijproxy`: No IDE found. Install the \"MCP Server\" plugin and ensure it is enabled.", + "", + "**Needs authentication**", + "- `Glean`: not signed in", + "", + "**Connected**", + "- `codex_apps`: 100 tools", + "- `cua_repl`: 3 tools", + "- `jbcontext`: 1 tool", + "- `node_repl`: 4 tools", + "- `openaiDeveloperDocs`: 5 tools", + "", + "**Disabled:** `code-review`, `codex_app`, `computer-use`", + "", + "Run `/mcp reconnect` to restart the servers that are not connected.", + ].join("\n")); + }); + + it("orders the groups and omits the empty ones", () => { + const servers = [ + entry({name: "unknown", status: null}), + entry({name: "up"}), + entry({name: "slow", status: "starting"}), + entry({name: "idle", status: "notStarted"}), + entry({name: "stopped", status: "cancelled"}), + entry({name: "broken", status: "failed"}), + ]; + + const headers = formatStatus(servers, new Map()).split("\n").filter(line => /^\*\*[^:]+\*\*$/.test(line)); + + expect(headers).toEqual(["**Failed**", "**Cancelled**", "**Not started**", "**Connecting**", "**Connected**", "**Unknown**"]); + }); + + it("shows the tool count of a connected server in the singular and the plural", () => { + const text = formatStatus([entry({name: "none"}), entry({name: "one", toolCount: 1}), entry({name: "two", toolCount: 2})], new Map()); + + expect(text).toContain("- `none`: 0 tools\n- `one`: 1 tool\n- `two`: 2 tools"); + }); + + it("shows the tool count of a server that is not connected only when it has tools", () => { + const text = formatStatus([ + entry({name: "empty", status: "starting"}), + entry({name: "cached", status: "starting", toolCount: 2}), + ], new Map()); + + expect(text).toContain("- `empty`\n- `cached`: 2 tools"); + }); + + it("prefers the startup error to the tool discovery error, and escapes the markdown", () => { + const text = formatStatus( + [entry({name: "fs", status: "failed", toolsError: "old error"})], + new Map([["fs", "MCP client for `fs` failed to start: bad *glob* in [args]"]]), + ); + + expect(text).toContain("- `fs`: bad \\*glob\\* in \\[args\\]"); + }); + + it("puts the disabled servers on one line and shows no reconnect hint for them", () => { + const text = formatStatus([entry({name: "a", status: "disabled"}), entry({name: "b`c", status: "disabled"})], new Map()); + + expect(text).toBe("**MCP servers:** 2 (2 disabled)\n\n**Disabled:** `a`, ``b`c``"); + }); + + it("keeps the session servers that Codex does not list in the unknown group", () => { + const text = formatStatus([ + entry({name: "fs", status: null, toolCount: 1}), + entry({name: "client-mcp", status: null, toolCount: null, authStatus: null, listedByCodex: false}), + ], new Map()); + + expect(text).toBe("**MCP servers:** 2 (2 unknown)\n\n**Unknown**\n- `fs`: 1 tool\n- `client-mcp`"); + }); + + it("shows no dangling colon for an empty error", () => { + const text = formatStatus([ + entry({name: "a", status: "failed", toolsError: ""}), + entry({name: "b", status: "failed", toolsError: " "}), + ], new Map()); + + expect(text).toContain("**Failed**\n- `a`\n- `b`\n"); + }); + + it("shows not signed in for a server that is not logged in and has another status", () => { + const text = formatStatus([ + entry({name: "browser", status: null, authStatus: "notLoggedIn"}), + entry({name: "docs", toolCount: 2, authStatus: "notLoggedIn"}), + entry({name: "off", status: "disabled", authStatus: "notLoggedIn"}), + ], new Map()); + + expect(text).toContain("- `docs`: 2 tools; not signed in"); + expect(text).toContain("**Unknown**\n- `browser`: not signed in"); + expect(text).toContain("**Disabled:** `off`"); + }); + + it("shows a status that this adapter does not know as unknown", () => { + const server = {name: "future", runtimeStatus: "hibernating", tools: {}, toolsError: null, authStatus: "unsupported"} as unknown as McpServerStatus; + + expect(toEntry(server).status).toBeNull(); + expect(formatStatus([toEntry(server)], new Map())).toBe("**MCP servers:** 1 (1 unknown)\n\n**Unknown**\n- `future`"); + }); +}); + +describe("inlineCode", () => { + it("uses a fence that is longer than the backtick runs of the name", () => { + expect(inlineCode("plain")).toBe("`plain`"); + expect(inlineCode("a``b")).toBe("```a``b```"); + }); + + it("pads a name that starts or ends with a backtick", () => { + expect(inlineCode("`a")).toBe("`` `a ``"); + expect(inlineCode("a`")).toBe("`` a` ``"); + }); + + it("shows an empty name as unnamed", () => { + expect(inlineCode("")).toBe("(unnamed)"); + }); +}); diff --git a/src/__tests__/acp-test-utils.ts b/src/__tests__/acp-test-utils.ts index ca4901da2..94b0c08a3 100644 --- a/src/__tests__/acp-test-utils.ts +++ b/src/__tests__/acp-test-utils.ts @@ -281,6 +281,7 @@ export function createCodexMockTestFixture( ): CodexMockTestFixture { let unhandledNotificationHandler: ((notification: any) => void) | null = null; const requestHandlers = new Map Promise>(); + const disposeListeners = new Set<() => void>(); // State for controlling permission responses const permissionState: { response: RequestPermissionResponse | Promise } = { @@ -299,6 +300,12 @@ export function createCodexMockTestFixture( onRequest: (type: { method: string }, handler: (params: unknown) => Promise) => { requestHandlers.set(type.method, handler); }, + // As in vscode-jsonrpc, `dispose()` fires `onDispose`. The exit of the Codex process disposes the connection. + onDispose: (listener: () => void) => { + disposeListeners.add(listener); + return {dispose: () => disposeListeners.delete(listener)}; + }, + dispose: () => [...disposeListeners].forEach(listener => listener()), end: () => {}, } as unknown as MessageConnection; diff --git a/src/__tests__/scenarios/baseline.ts b/src/__tests__/scenarios/baseline.ts index 23750e567..86bdd93af 100644 --- a/src/__tests__/scenarios/baseline.ts +++ b/src/__tests__/scenarios/baseline.ts @@ -123,6 +123,32 @@ function withBugFixes(scenario: string, messages: RecordedMessage[]): RecordedMe }); } +/** The `/mcp` command that the baseline sends in `available_commands_update`. */ +const BASELINE_MCP_COMMAND = {name: "mcp", description: "List configured Model Context Protocol (MCP) tools.", input: null}; + +/** The `/mcp` command that shows the live status of the MCP servers and reconnects them. */ +const MCP_COMMAND = { + name: "mcp", + description: "Show the status of the MCP servers, or reconnect them.", + input: {hint: "[reconnect [server]]"}, +}; + +/** + * The feature changes of the compatibility rule that change the messages of the scenarios: + * the `/mcp` command has a new description and an input hint. + * Only a command that equals the baseline `/mcp` command changes. + */ +export function withFeatureChanges(messages: RecordedMessage[]): RecordedMessage[] { + const baselineMcp = JSON.stringify(canonical(BASELINE_MCP_COMMAND)); + return messages.map(message => { + const update = sessionUpdate(message); + if (update === undefined || update["sessionUpdate"] !== "available_commands_update") return message; + const commands = (update["availableCommands"] as unknown[]).map(command => + JSON.stringify(canonical(command)) === baselineMcp ? MCP_COMMAND : command); + return {...message, params: {...(message.params as Json), update: {...update, availableCommands: commands}}}; + }); +} + const OUTPUT_CHUNK_KEYS = ["terminal_output", "terminal_output_delta"]; /** @@ -187,7 +213,7 @@ export function withOutputOnce(messages: RecordedMessage[]): RecordedMessage[] { /** The baseline messages with the allowed differences applied, except the merge of the tool call reports. */ export function expectedFromBaseline(scenario: string, baseline: RecordedMessage[]): RecordedMessage[] { - return withBugFixes(scenario, withoutAirOnlyMessages(baseline)); + return withFeatureChanges(withBugFixes(scenario, withoutAirOnlyMessages(baseline))); } /** diff --git a/src/__tests__/scenarios/client-profiles.test.ts b/src/__tests__/scenarios/client-profiles.test.ts index 41cd0f8f0..b6b2b63d8 100644 --- a/src/__tests__/scenarios/client-profiles.test.ts +++ b/src/__tests__/scenarios/client-profiles.test.ts @@ -5,7 +5,7 @@ import {fileURLToPath} from "node:url"; import {beforeAll, describe, expect, it} from "vitest"; import packageJson from "../../../package.json"; import {schemaErrors} from "./acp-schema"; -import {airOnlyKeys, expectedFromBaseline, lines, mergedReports, metaObjects, withOutputOnce} from "./baseline"; +import {airOnlyKeys, expectedFromBaseline, lines, mergedReports, metaObjects, withFeatureChanges, withOutputOnce} from "./baseline"; import { AIR_CAPABILITY_NAMES, fromJsonLines, @@ -112,6 +112,24 @@ describe("clients that are not AIR, compared with the baseline", () => { .not.toEqual(lines(mergedReports([started, request({kind: "execute"})]))); }); + it("changes only the baseline /mcp command in the available commands", () => { + const commands = (availableCommands: unknown[]): RecordedMessage => ({ + direction: "notify", + method: "session/update", + params: {sessionId: "s", update: {sessionUpdate: "available_commands_update", availableCommands}}, + }); + const status = {name: "status", description: "Display session configuration and token usage.", input: null}; + const baselineMcp = {name: "mcp", description: "List configured Model Context Protocol (MCP) tools.", input: null}; + const otherMcp = {name: "mcp", description: "Another description.", input: null}; + const mcp = { + name: "mcp", + description: "Show the status of the MCP servers, or reconnect them.", + input: {hint: "[reconnect [server]]"}, + }; + expect(withFeatureChanges([commands([status, baselineMcp])])).toEqual([commands([status, mcp])]); + expect(withFeatureChanges([commands([status, otherMcp])])).toEqual([commands([status, otherMcp])]); + }); + it.runIf(RECORD_BASELINE)("records the baseline", () => { for (const profile of ["plain", "zed"] as const) { for (const each of SCENARIOS) { diff --git a/src/__tests__/scenarios/data/air/agent-message-and-reasoning.jsonl b/src/__tests__/scenarios/data/air/agent-message-and-reasoning.jsonl index 794bc3dc4..6ca467d0f 100644 --- a/src/__tests__/scenarios/data/air/agent-message-and-reasoning.jsonl +++ b/src/__tests__/scenarios/data/air/agent-message-and-reasoning.jsonl @@ -1,7 +1,7 @@ {"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} -{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"List configured Model Context Protocol (MCP) tools.","input":null,"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} +{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect [server]]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"content":{"text":"Thinking about it","type":"text"},"messageId":"rs-1","sessionUpdate":"agent_thought_chunk"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"content":{"text":"\n\n","type":"text"},"messageId":"rs-1","sessionUpdate":"agent_thought_chunk"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"content":{"text":"raw reasoning","type":"text"},"messageId":"rs-1","sessionUpdate":"agent_thought_chunk"}}} diff --git a/src/__tests__/scenarios/data/air/background-terminal.jsonl b/src/__tests__/scenarios/data/air/background-terminal.jsonl index 7fb6a8dc4..1b5b054b1 100644 --- a/src/__tests__/scenarios/data/air/background-terminal.jsonl +++ b/src/__tests__/scenarios/data/air/background-terminal.jsonl @@ -1,7 +1,7 @@ {"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} -{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"List configured Model Context Protocol (MCP) tools.","input":null,"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} +{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect [server]]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"_meta":{"terminal_info":{"cwd":"/workspace","terminal_id":"bg-1"}},"content":[{"terminalId":"bg-1","type":"terminal"}],"kind":"execute","name":"exec_command","rawInput":{"command":"npm run dev","cwd":"/workspace"},"sessionUpdate":"tool_call","status":"in_progress","title":"npm run dev","toolCallId":"bg-1"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"_meta":{"terminal_output_delta":{"data":"listening on 3000\n","terminal_id":"bg-1"}},"sessionUpdate":"tool_call_update","toolCallId":"bg-1"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"session_info_update","title":"Go"}}} diff --git a/src/__tests__/scenarios/data/air/collab-agent.jsonl b/src/__tests__/scenarios/data/air/collab-agent.jsonl index 61523b510..ce032fb76 100644 --- a/src/__tests__/scenarios/data/air/collab-agent.jsonl +++ b/src/__tests__/scenarios/data/air/collab-agent.jsonl @@ -1,6 +1,6 @@ {"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} -{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"List configured Model Context Protocol (MCP) tools.","input":null,"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} +{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect [server]]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"session_info_update","title":"Go"}}} {"direction":"response","method":"session/prompt","params":{"_meta":{"quota":{"model_usage":[],"token_count":null}},"stopReason":"end_turn","usage":null}} diff --git a/src/__tests__/scenarios/data/air/collab-controls.jsonl b/src/__tests__/scenarios/data/air/collab-controls.jsonl index 384b87c79..c1b726537 100644 --- a/src/__tests__/scenarios/data/air/collab-controls.jsonl +++ b/src/__tests__/scenarios/data/air/collab-controls.jsonl @@ -1,7 +1,7 @@ {"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} -{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"List configured Model Context Protocol (MCP) tools.","input":null,"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} +{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect [server]]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"capabilities":{},"name":"Weather","sessionUpdate":"subagent_spawned","subagentSessionId":"child-thread","task":"Find the weather in Paris."}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"kind":"other","rawInput":{"agentsStates":{"child-thread":{"message":null,"status":"running"}},"model":null,"prompt":null,"reasoningEffort":null,"receiverThreadIds":["child-thread"],"senderThreadId":"session-1"},"sessionUpdate":"tool_call","status":"in_progress","title":"wait","toolCallId":"wait-1"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"wait-1"}}} diff --git a/src/__tests__/scenarios/data/air/command-approval.jsonl b/src/__tests__/scenarios/data/air/command-approval.jsonl index 8e9668a45..e56b4c4f4 100644 --- a/src/__tests__/scenarios/data/air/command-approval.jsonl +++ b/src/__tests__/scenarios/data/air/command-approval.jsonl @@ -1,7 +1,7 @@ {"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} -{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"List configured Model Context Protocol (MCP) tools.","input":null,"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} +{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect [server]]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"_meta":{"terminal_info":{"cwd":"/workspace","terminal_id":"cmd-a"}},"content":[{"terminalId":"cmd-a","type":"terminal"}],"kind":"execute","rawInput":{"command":"npm install","cwd":"/workspace"},"sessionUpdate":"tool_call","status":"in_progress","title":"npm install","toolCallId":"cmd-a"}}} {"direction":"request","method":"session/request_permission","params":{"_meta":{"jetbrains":{"air":{"permission":{"description":"Install the dependencies.","title":"Run command?","version":1},"version":1}}},"options":[{"kind":"allow_once","name":"Yes, proceed","optionId":"allow_once"},{"kind":"allow_always","name":"Yes, and don't ask again for this command in this session","optionId":"allow_for_session"},{"kind":"reject_once","name":"No, continue without running it","optionId":"decline"},{"kind":"reject_once","name":"No, and tell Codex what to do differently","optionId":"cancel"}],"sessionId":"session-1","toolCall":{"rawInput":{"command":"npm install","cwd":"/workspace"},"title":"npm install","toolCallId":"cmd-a"}}} {"direction":"codexResponse","method":"item/commandExecution/requestApproval","params":{"decision":"accept"}} diff --git a/src/__tests__/scenarios/data/air/command-failed.jsonl b/src/__tests__/scenarios/data/air/command-failed.jsonl index b1682bde4..0becb5969 100644 --- a/src/__tests__/scenarios/data/air/command-failed.jsonl +++ b/src/__tests__/scenarios/data/air/command-failed.jsonl @@ -1,7 +1,7 @@ {"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} -{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"List configured Model Context Protocol (MCP) tools.","input":null,"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} +{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect [server]]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"_meta":{"terminal_info":{"cwd":"/workspace","terminal_id":"cmd-3"}},"content":[{"terminalId":"cmd-3","type":"terminal"}],"kind":"execute","rawInput":{"command":"cat missing.txt","cwd":"/workspace"},"sessionUpdate":"tool_call","status":"in_progress","title":"cat missing.txt","toolCallId":"cmd-3"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"_meta":{"terminal_output_delta":{"data":"cat: missing.txt: No such file\n","terminal_id":"cmd-3"}},"sessionUpdate":"tool_call_update","toolCallId":"cmd-3"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"_meta":{"terminal_exit":{"exit_code":1,"signal":null,"terminal_id":"cmd-3"}},"sessionUpdate":"tool_call_update","status":"failed","toolCallId":"cmd-3"}}} diff --git a/src/__tests__/scenarios/data/air/command-output-stdin.jsonl b/src/__tests__/scenarios/data/air/command-output-stdin.jsonl index 814cef2ec..23d359cb3 100644 --- a/src/__tests__/scenarios/data/air/command-output-stdin.jsonl +++ b/src/__tests__/scenarios/data/air/command-output-stdin.jsonl @@ -1,7 +1,7 @@ {"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} -{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"List configured Model Context Protocol (MCP) tools.","input":null,"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} +{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect [server]]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"_meta":{"terminal_info":{"cwd":"/workspace","terminal_id":"cmd-1"}},"content":[{"terminalId":"cmd-1","type":"terminal"}],"kind":"execute","rawInput":{"command":"npm test","cwd":"/workspace"},"sessionUpdate":"tool_call","status":"in_progress","title":"npm test","toolCallId":"cmd-1"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"_meta":{"terminal_output_delta":{"data":"Running tests\n","terminal_id":"cmd-1"}},"sessionUpdate":"tool_call_update","toolCallId":"cmd-1"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"_meta":{"terminal_input":{"data":"y","terminal_id":"cmd-1"}},"sessionUpdate":"tool_call_update","toolCallId":"cmd-1"}}} diff --git a/src/__tests__/scenarios/data/air/command-without-streamed-output.jsonl b/src/__tests__/scenarios/data/air/command-without-streamed-output.jsonl index dbd5c7f1e..d59dc4165 100644 --- a/src/__tests__/scenarios/data/air/command-without-streamed-output.jsonl +++ b/src/__tests__/scenarios/data/air/command-without-streamed-output.jsonl @@ -1,7 +1,7 @@ {"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} -{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"List configured Model Context Protocol (MCP) tools.","input":null,"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} +{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect [server]]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"_meta":{"terminal_info":{"cwd":"/workspace","terminal_id":"cmd-2"}},"content":[{"terminalId":"cmd-2","type":"terminal"}],"kind":"execute","rawInput":{"command":"ls -la","cwd":"/workspace"},"sessionUpdate":"tool_call","status":"in_progress","title":"ls -la","toolCallId":"cmd-2"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"_meta":{"terminal_exit":{"exit_code":0,"signal":null,"terminal_id":"cmd-2"},"terminal_output_delta":{"data":"a.txt\nb.txt\n","terminal_id":"cmd-2"}},"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"cmd-2"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"session_info_update","title":"Go"}}} diff --git a/src/__tests__/scenarios/data/air/context-compaction.jsonl b/src/__tests__/scenarios/data/air/context-compaction.jsonl index 580451d06..b660da180 100644 --- a/src/__tests__/scenarios/data/air/context-compaction.jsonl +++ b/src/__tests__/scenarios/data/air/context-compaction.jsonl @@ -1,7 +1,7 @@ {"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} -{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"List configured Model Context Protocol (MCP) tools.","input":null,"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} +{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect [server]]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"_meta":{"jetbrains":{"air":{"contextCompaction":{"version":1},"version":1}}},"kind":"think","sessionUpdate":"tool_call","status":"in_progress","title":"Compact conversation","toolCallId":"compact-1"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"compact-1"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"session_info_update","title":"Go"}}} diff --git a/src/__tests__/scenarios/data/air/dynamic-tool.jsonl b/src/__tests__/scenarios/data/air/dynamic-tool.jsonl index 4f553e286..bed3c60ea 100644 --- a/src/__tests__/scenarios/data/air/dynamic-tool.jsonl +++ b/src/__tests__/scenarios/data/air/dynamic-tool.jsonl @@ -1,7 +1,7 @@ {"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} -{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"List configured Model Context Protocol (MCP) tools.","input":null,"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} +{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect [server]]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"kind":"execute","name":"list_apps","rawInput":{"arguments":{"filter":"all"}},"sessionUpdate":"tool_call","status":"in_progress","title":"list_apps","toolCallId":"dyn-1"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"content":[{"content":{"text":"Found 2 apps","type":"text"},"type":"content"}],"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"dyn-1"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"session_info_update","title":"Go"}}} diff --git a/src/__tests__/scenarios/data/air/file-change-approval.jsonl b/src/__tests__/scenarios/data/air/file-change-approval.jsonl index b91a33bf2..63542bf78 100644 --- a/src/__tests__/scenarios/data/air/file-change-approval.jsonl +++ b/src/__tests__/scenarios/data/air/file-change-approval.jsonl @@ -1,7 +1,7 @@ {"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} -{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"List configured Model Context Protocol (MCP) tools.","input":null,"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} +{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect [server]]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"content":[{"_meta":{"jetbrains":{"air":{"diffPatch":{"format":"git_patch","text":"diff --git a/workspace/src/app.ts b/workspace/src/app.ts\n--- a/workspace/src/app.ts\n+++ b/workspace/src/app.ts\n@@ -1,2 +1,2 @@\n-const a = 1;\n+const a = 2;\n export {a};\n","version":1},"version":1}},"kind":"update"},"newText":"","oldText":null,"path":"/workspace/src/app.ts","type":"diff"}],"kind":"edit","sessionUpdate":"tool_call","status":"in_progress","title":"Editing files","toolCallId":"fc-a"}}} {"direction":"request","method":"session/request_permission","params":{"_meta":{"jetbrains":{"air":{"permission":{"description":"Update the constant.","title":"Make edits?","version":1},"version":1}}},"options":[{"kind":"allow_once","name":"Yes, proceed","optionId":"allow_once"},{"kind":"allow_always","name":"Yes, and don't ask again for these files","optionId":"allow_for_session"},{"kind":"reject_once","name":"No, and tell Codex what to do differently","optionId":"cancel"}],"sessionId":"session-1","toolCall":{"locations":[{"path":"/workspace/src/app.ts"}],"title":"Editing files","toolCallId":"fc-a"}}} {"direction":"codexResponse","method":"item/fileChange/requestApproval","params":{"decision":"cancel"}} diff --git a/src/__tests__/scenarios/data/air/file-changes.jsonl b/src/__tests__/scenarios/data/air/file-changes.jsonl index 18bd4dfd5..edc087757 100644 --- a/src/__tests__/scenarios/data/air/file-changes.jsonl +++ b/src/__tests__/scenarios/data/air/file-changes.jsonl @@ -1,7 +1,7 @@ {"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} -{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"List configured Model Context Protocol (MCP) tools.","input":null,"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} +{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect [server]]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"content":[{"_meta":{"jetbrains":{"air":{"diffPatch":{"format":"git_patch","text":"diff --git a/workspace/new.txt b/workspace/new.txt\nnew file mode 100644\n--- /dev/null\n+++ b/workspace/new.txt\n@@ -0,0 +1,2 @@\n+hello\n+world\n","version":1},"version":1}},"kind":"add"},"newText":"","oldText":null,"path":"/workspace/new.txt","type":"diff"}],"kind":"edit","sessionUpdate":"tool_call","status":"in_progress","title":"Editing files","toolCallId":"fc-add"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"fc-add"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"content":[{"_meta":{"jetbrains":{"air":{"diffPatch":{"format":"git_patch","text":"diff --git a/workspace/src/app.ts b/workspace/src/app.ts\n--- a/workspace/src/app.ts\n+++ b/workspace/src/app.ts\n@@ -1,2 +1,2 @@\n-const a = 1;\n+const a = 2;\n export {a};\n","version":1},"version":1}},"kind":"update"},"newText":"","oldText":null,"path":"/workspace/src/app.ts","type":"diff"}],"kind":"edit","sessionUpdate":"tool_call","status":"in_progress","title":"Editing files","toolCallId":"fc-update"}}} diff --git a/src/__tests__/scenarios/data/air/fuzzy-file-search.jsonl b/src/__tests__/scenarios/data/air/fuzzy-file-search.jsonl index 9c434f88e..6945c4358 100644 --- a/src/__tests__/scenarios/data/air/fuzzy-file-search.jsonl +++ b/src/__tests__/scenarios/data/air/fuzzy-file-search.jsonl @@ -1,7 +1,7 @@ {"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} -{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"List configured Model Context Protocol (MCP) tools.","input":null,"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} +{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect [server]]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"kind":"search","locations":[{"path":"/workspace/src/Handler.ts"}],"rawInput":{"query":"handler"},"sessionUpdate":"tool_call","status":"in_progress","title":"Search for 'handler'","toolCallId":"fuzzyFileSearch.search-1"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"locations":[{"path":"/workspace/src/OtherHandler.ts"}],"sessionUpdate":"tool_call_update","toolCallId":"fuzzyFileSearch.search-1"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"locations":[],"sessionUpdate":"tool_call_update","title":"Search for 'handlr'","toolCallId":"fuzzyFileSearch.search-1"}}} diff --git a/src/__tests__/scenarios/data/air/goal-update.jsonl b/src/__tests__/scenarios/data/air/goal-update.jsonl index 8892e8ee6..7c237898b 100644 --- a/src/__tests__/scenarios/data/air/goal-update.jsonl +++ b/src/__tests__/scenarios/data/air/goal-update.jsonl @@ -1,7 +1,7 @@ {"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} -{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"List configured Model Context Protocol (MCP) tools.","input":null,"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} +{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect [server]]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"_meta":{"jetbrains":{"air":{"goal":{"controlMethod":"_session/goal","createdAt":1710000000000,"objective":"Ship it","status":"active","timeUsedSeconds":1,"tokenBudget":null,"tokensUsed":5,"updatedAt":1710000001000},"version":1}}},"sessionUpdate":"session_info_update"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"_meta":{"jetbrains":{"air":{"goal":null,"version":1}}},"sessionUpdate":"session_info_update"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"session_info_update","title":"Go"}}} diff --git a/src/__tests__/scenarios/data/air/guardian-review.jsonl b/src/__tests__/scenarios/data/air/guardian-review.jsonl index f73ba9278..9b9f18cbd 100644 --- a/src/__tests__/scenarios/data/air/guardian-review.jsonl +++ b/src/__tests__/scenarios/data/air/guardian-review.jsonl @@ -1,7 +1,7 @@ {"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} -{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"List configured Model Context Protocol (MCP) tools.","input":null,"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} +{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect [server]]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"content":[{"content":{"text":"Status: In progress\nRisk: medium\nAuthorization: unknown\nRationale: Checking.","type":"text"},"type":"content"}],"kind":"think","rawInput":{"action":{"command":"rm -rf build","cwd":"/workspace","source":"shell","type":"command"}},"sessionUpdate":"tool_call","status":"in_progress","title":"Guardian Review","toolCallId":"guardian_assessment:review-1"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"content":[{"content":{"text":"Status: Denied\nRisk: high\nAuthorization: low\nRationale: Deletes files.","type":"text"},"type":"content"}],"sessionUpdate":"tool_call_update","status":"failed","toolCallId":"guardian_assessment:review-1"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"session_info_update","title":"Go"}}} diff --git a/src/__tests__/scenarios/data/air/history-replay.jsonl b/src/__tests__/scenarios/data/air/history-replay.jsonl index 4f1a6b5fe..528da37e6 100644 --- a/src/__tests__/scenarios/data/air/history-replay.jsonl +++ b/src/__tests__/scenarios/data/air/history-replay.jsonl @@ -1,7 +1,7 @@ {"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/load","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"}}} -{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"List configured Model Context Protocol (MCP) tools.","input":null,"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} +{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect [server]]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"_meta":{"jetbrains":{"air":{"goal":null,"version":1}}},"sessionUpdate":"session_info_update"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"session_info_update","title":"Run the tests"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"content":{"text":"Run the tests","type":"text"},"messageId":"u-1","sessionUpdate":"user_message_chunk"}}} diff --git a/src/__tests__/scenarios/data/air/image-generation.jsonl b/src/__tests__/scenarios/data/air/image-generation.jsonl index feccd3519..7000e0a15 100644 --- a/src/__tests__/scenarios/data/air/image-generation.jsonl +++ b/src/__tests__/scenarios/data/air/image-generation.jsonl @@ -1,7 +1,7 @@ {"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} -{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"List configured Model Context Protocol (MCP) tools.","input":null,"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} +{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect [server]]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"kind":"other","sessionUpdate":"tool_call","status":"in_progress","title":"Image generation","toolCallId":"gen-1"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"content":[{"content":{"text":"Revised prompt: A red square","type":"text"},"type":"content"},{"content":{"data":"iVBORw0KGgo=","mimeType":"image/png","type":"image","uri":"/workspace/square.png"},"type":"content"}],"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"gen-1"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"session_info_update","title":"Go"}}} diff --git a/src/__tests__/scenarios/data/air/image-view.jsonl b/src/__tests__/scenarios/data/air/image-view.jsonl index e5b1fc7b4..194f4dfb9 100644 --- a/src/__tests__/scenarios/data/air/image-view.jsonl +++ b/src/__tests__/scenarios/data/air/image-view.jsonl @@ -1,7 +1,7 @@ {"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} -{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"List configured Model Context Protocol (MCP) tools.","input":null,"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} +{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect [server]]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"content":[{"content":{"name":"/workspace/screen.png","type":"resource_link","uri":"/workspace/screen.png"},"type":"content"}],"kind":"read","locations":[{"path":"/workspace/screen.png"}],"name":"view_image","rawInput":{"path":"/workspace/screen.png"},"sessionUpdate":"tool_call","status":"completed","title":"View Image /workspace/screen.png","toolCallId":"img-1"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"session_info_update","title":"Go"}}} {"direction":"response","method":"session/prompt","params":{"_meta":{"quota":{"model_usage":[],"token_count":null}},"stopReason":"end_turn","usage":null}} diff --git a/src/__tests__/scenarios/data/air/late-subagent-update.jsonl b/src/__tests__/scenarios/data/air/late-subagent-update.jsonl index f31b025ad..a38415d6c 100644 --- a/src/__tests__/scenarios/data/air/late-subagent-update.jsonl +++ b/src/__tests__/scenarios/data/air/late-subagent-update.jsonl @@ -1,7 +1,7 @@ {"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} -{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"List configured Model Context Protocol (MCP) tools.","input":null,"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} +{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect [server]]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"capabilities":{},"name":"Weather","sessionUpdate":"subagent_spawned","subagentSessionId":"child-thread","task":"Find the weather in Paris."}}} {"direction":"notify","method":"session/update","params":{"sessionId":"child-thread","update":{"_meta":{"terminal_info":{"cwd":"/workspace","terminal_id":"child-cmd"}},"content":[{"terminalId":"child-cmd","type":"terminal"}],"kind":"execute","rawInput":{"command":"curl wttr.in","cwd":"/workspace"},"sessionUpdate":"tool_call","status":"in_progress","title":"curl wttr.in","toolCallId":"child-cmd"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"subagent_state_update","state":"completed","subagentSessionId":"child-thread"}}} diff --git a/src/__tests__/scenarios/data/air/mcp-elicitation.jsonl b/src/__tests__/scenarios/data/air/mcp-elicitation.jsonl index b5c4cdb0c..53f39e978 100644 --- a/src/__tests__/scenarios/data/air/mcp-elicitation.jsonl +++ b/src/__tests__/scenarios/data/air/mcp-elicitation.jsonl @@ -1,7 +1,7 @@ {"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} -{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"List configured Model Context Protocol (MCP) tools.","input":null,"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} +{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect [server]]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} {"direction":"request","method":"elicitation/create","params":{"_meta":null,"message":"Pick a color","mode":"form","requestedSchema":{"properties":{"color":{"enum":["red","blue"],"type":"string"}},"required":["color"],"type":"object"},"sessionId":"session-1"}} {"direction":"codexResponse","method":"mcpServer/elicitation/request","params":{"_meta":null,"action":"accept","content":{"color":"red"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"session_info_update","title":"Go"}}} diff --git a/src/__tests__/scenarios/data/air/mcp-startup-failure.jsonl b/src/__tests__/scenarios/data/air/mcp-startup-failure.jsonl index c797e5d65..9cb027d6f 100644 --- a/src/__tests__/scenarios/data/air/mcp-startup-failure.jsonl +++ b/src/__tests__/scenarios/data/air/mcp-startup-failure.jsonl @@ -2,5 +2,5 @@ {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"content":[{"content":{"text":"[codex-acp forwarded startup error] MCP server `db` failed to start: boom","type":"text"},"type":"content"}],"kind":"other","sessionUpdate":"tool_call","status":"failed","title":"mcp__db__startup","toolCallId":"mcp_startup.db."}}} -{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"List configured Model Context Protocol (MCP) tools.","input":null,"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} +{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect [server]]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"content":[{"content":{"text":"[codex-acp forwarded startup error] MCP server `slow` startup was cancelled.","type":"text"},"type":"content"}],"kind":"other","sessionUpdate":"tool_call","status":"failed","title":"mcp__slow__startup","toolCallId":"mcp_startup.slow."}}} diff --git a/src/__tests__/scenarios/data/air/mcp-tool-approval.jsonl b/src/__tests__/scenarios/data/air/mcp-tool-approval.jsonl index 3806a3af3..195ccddd9 100644 --- a/src/__tests__/scenarios/data/air/mcp-tool-approval.jsonl +++ b/src/__tests__/scenarios/data/air/mcp-tool-approval.jsonl @@ -1,7 +1,7 @@ {"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} -{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"List configured Model Context Protocol (MCP) tools.","input":null,"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} +{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect [server]]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"_meta":{"is_mcp_tool_call":true},"kind":"execute","rawInput":{"arguments":{"query":"acp"},"server":"docs","tool":"write"},"sessionUpdate":"tool_call","status":"in_progress","title":"mcp.docs.write","toolCallId":"mcp-3"}}} {"direction":"request","method":"session/request_permission","params":{"_meta":{"is_mcp_tool_approval":true},"options":[{"_meta":{"jetbrains":{"air":{"permission":{"description":"Run the tool and continue.","version":1},"version":1}}},"kind":"allow_once","name":"Allow","optionId":"allow_once"},{"_meta":{"jetbrains":{"air":{"permission":{"description":"Run the tool and remember this choice for this session.","version":1},"version":1}}},"kind":"allow_always","name":"Allow for this session","optionId":"allow_session"},{"_meta":{"jetbrains":{"air":{"permission":{"description":"Cancel this tool call","version":1},"version":1}}},"kind":"reject_once","name":"Cancel","optionId":"cancel"}],"sessionId":"session-1","toolCall":{"kind":"execute","status":"pending","toolCallId":"mcp-3"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","status":"in_progress","toolCallId":"mcp-3"}}} diff --git a/src/__tests__/scenarios/data/air/mcp-tool.jsonl b/src/__tests__/scenarios/data/air/mcp-tool.jsonl index 6a97c3260..b59e82444 100644 --- a/src/__tests__/scenarios/data/air/mcp-tool.jsonl +++ b/src/__tests__/scenarios/data/air/mcp-tool.jsonl @@ -1,7 +1,7 @@ {"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} -{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"List configured Model Context Protocol (MCP) tools.","input":null,"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} +{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect [server]]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"_meta":{"is_mcp_tool_call":true},"kind":"execute","rawInput":{"arguments":{"query":"acp"},"server":"docs","tool":"search"},"sessionUpdate":"tool_call","status":"in_progress","title":"mcp.docs.search","toolCallId":"mcp-1"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"rawOutput":{"error":null,"result":{"_meta":null,"content":[{"text":"3 hits","type":"text"}],"structuredContent":{"hits":3}}},"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"mcp-1"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"_meta":{"is_mcp_tool_call":true},"kind":"execute","rawInput":{"arguments":{"query":"acp"},"server":"docs","tool":"fail"},"sessionUpdate":"tool_call","status":"in_progress","title":"mcp.docs.fail","toolCallId":"mcp-2"}}} diff --git a/src/__tests__/scenarios/data/air/mcp-url-elicitation.jsonl b/src/__tests__/scenarios/data/air/mcp-url-elicitation.jsonl index c4f69721e..2bd9f6893 100644 --- a/src/__tests__/scenarios/data/air/mcp-url-elicitation.jsonl +++ b/src/__tests__/scenarios/data/air/mcp-url-elicitation.jsonl @@ -1,7 +1,7 @@ {"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} -{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"List configured Model Context Protocol (MCP) tools.","input":null,"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} +{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect [server]]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} {"direction":"request","method":"elicitation/create","params":{"_meta":null,"elicitationId":"el-1","message":"Sign in to the docs server","mode":"url","sessionId":"session-1","url":"https://example.com/login"}} {"direction":"codexResponse","method":"mcpServer/elicitation/request","params":{"_meta":null,"action":"accept","content":null}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"session_info_update","title":"Go"}}} diff --git a/src/__tests__/scenarios/data/air/native-subagent-session.jsonl b/src/__tests__/scenarios/data/air/native-subagent-session.jsonl index 0dd243efc..bd5c7dc75 100644 --- a/src/__tests__/scenarios/data/air/native-subagent-session.jsonl +++ b/src/__tests__/scenarios/data/air/native-subagent-session.jsonl @@ -1,7 +1,7 @@ {"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} -{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"List configured Model Context Protocol (MCP) tools.","input":null,"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} +{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect [server]]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"capabilities":{},"name":"Weather","sessionUpdate":"subagent_spawned","subagentSessionId":"child-thread","task":"Find the weather in Paris."}}} {"direction":"notify","method":"session/update","params":{"sessionId":"child-thread","update":{"_meta":{"terminal_info":{"cwd":"/workspace","terminal_id":"child-cmd"}},"content":[{"terminalId":"child-cmd","type":"terminal"}],"kind":"execute","rawInput":{"command":"curl wttr.in","cwd":"/workspace"},"sessionUpdate":"tool_call","status":"in_progress","title":"curl wttr.in","toolCallId":"child-cmd"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"child-thread","update":{"_meta":{"terminal_output_delta":{"data":"Sunny\n","terminal_id":"child-cmd"}},"sessionUpdate":"tool_call_update","toolCallId":"child-cmd"}}} diff --git a/src/__tests__/scenarios/data/air/nested-subagent-session.jsonl b/src/__tests__/scenarios/data/air/nested-subagent-session.jsonl index 872c48980..a1113c2fd 100644 --- a/src/__tests__/scenarios/data/air/nested-subagent-session.jsonl +++ b/src/__tests__/scenarios/data/air/nested-subagent-session.jsonl @@ -1,7 +1,7 @@ {"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} -{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"List configured Model Context Protocol (MCP) tools.","input":null,"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} +{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect [server]]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"capabilities":{},"name":"Weather","sessionUpdate":"subagent_spawned","subagentSessionId":"child-thread","task":"Find the weather in Paris."}}} {"direction":"notify","method":"session/update","params":{"sessionId":"child-thread","update":{"capabilities":{},"name":"Lyon","sessionUpdate":"subagent_spawned","subagentSessionId":"grandchild-thread","task":"Check Lyon."}}} {"direction":"notify","method":"session/update","params":{"sessionId":"grandchild-thread","update":{"_meta":{"terminal_info":{"cwd":"/workspace","terminal_id":"grandchild-cmd"}},"content":[{"terminalId":"grandchild-cmd","type":"terminal"}],"kind":"execute","rawInput":{"command":"curl wttr.in/Lyon","cwd":"/workspace"},"sessionUpdate":"tool_call","status":"in_progress","title":"curl wttr.in/Lyon","toolCallId":"grandchild-cmd"}}} diff --git a/src/__tests__/scenarios/data/air/network-and-sandbox-approval.jsonl b/src/__tests__/scenarios/data/air/network-and-sandbox-approval.jsonl index a32ce7d79..6d2584d8b 100644 --- a/src/__tests__/scenarios/data/air/network-and-sandbox-approval.jsonl +++ b/src/__tests__/scenarios/data/air/network-and-sandbox-approval.jsonl @@ -1,7 +1,7 @@ {"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} -{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"List configured Model Context Protocol (MCP) tools.","input":null,"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} +{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect [server]]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} {"direction":"request","method":"session/request_permission","params":{"_meta":{"jetbrains":{"air":{"permission":{"title":"Allow network access?","version":1},"version":1}}},"options":[{"kind":"allow_once","name":"Yes, just this once","optionId":"allow_once"},{"kind":"reject_once","name":"No, continue without running it","optionId":"decline"},{"kind":"reject_once","name":"No, and tell Codex what to do differently","optionId":"cancel"}],"sessionId":"session-1","toolCall":{"content":[{"content":{"text":"https access to example.com","type":"text"},"type":"content"}],"kind":"execute","locations":[{"path":"/workspace/a.txt"}],"rawInput":{"command":"curl https://example.com","cwd":"/workspace","url":"https://example.com"},"status":"pending","title":"https network access to example.com","toolCallId":"cmd-net"}}} {"direction":"codexResponse","method":"item/commandExecution/requestApproval","params":{"decision":"decline"}} {"direction":"request","method":"session/request_permission","params":{"_meta":{"jetbrains":{"air":{"permission":{"description":"Needs the cache.","title":"Grant permissions?","version":1},"version":1}}},"options":[{"kind":"allow_once","name":"Yes, grant these permissions for this turn","optionId":"allow_permissions_turn"},{"kind":"allow_once","name":"Yes, grant for this turn with strict auto review","optionId":"allow_permissions_turn_strict_auto_review"},{"kind":"allow_always","name":"Yes, grant these permissions for this session","optionId":"allow_permissions_session"},{"kind":"reject_once","name":"No, continue without permissions","optionId":"reject_permissions"}],"sessionId":"session-1","toolCall":{"content":[{"content":{"text":"Enable network access","type":"text"},"type":"content"}],"kind":"other","locations":[{"path":"/workspace/cache"}],"name":"request_permissions","rawInput":{"cwd":"/workspace","environmentId":null,"permissions":{"fileSystem":{"read":["/workspace/cache"],"write":null},"network":{"enabled":true}}},"status":"pending","title":"Additional sandbox permissions","toolCallId":"perm-1"}}} diff --git a/src/__tests__/scenarios/data/air/plan-deltas-and-turn-plan.jsonl b/src/__tests__/scenarios/data/air/plan-deltas-and-turn-plan.jsonl index 188c78684..18b76288d 100644 --- a/src/__tests__/scenarios/data/air/plan-deltas-and-turn-plan.jsonl +++ b/src/__tests__/scenarios/data/air/plan-deltas-and-turn-plan.jsonl @@ -1,7 +1,7 @@ {"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} -{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"List configured Model Context Protocol (MCP) tools.","input":null,"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} +{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect [server]]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"plan":{"content":"# Plan\n\n1. Do the thing.","planId":"plan-1","type":"markdown"},"sessionUpdate":"plan_update"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"entries":[{"content":"Read code","priority":"medium","status":"completed"},{"content":"Change code","priority":"medium","status":"in_progress"}],"sessionUpdate":"plan"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"session_info_update","title":"Go"}}} diff --git a/src/__tests__/scenarios/data/air/plan-review-permission.jsonl b/src/__tests__/scenarios/data/air/plan-review-permission.jsonl index 22b8185f4..834de0749 100644 --- a/src/__tests__/scenarios/data/air/plan-review-permission.jsonl +++ b/src/__tests__/scenarios/data/air/plan-review-permission.jsonl @@ -1,7 +1,7 @@ {"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"plan","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} -{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"List configured Model Context Protocol (MCP) tools.","input":null,"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} +{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect [server]]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"plan":{"content":"# Plan\n\n1. Make the change.","planId":"plan-2","type":"markdown"},"sessionUpdate":"plan_update"}}} {"direction":"request","method":"session/request_permission","params":{"options":[{"kind":"allow_once","name":"Yes, implement this plan","optionId":"implement_plan"},{"kind":"reject_once","name":"No, and tell Codex what to do differently","optionId":"revise_plan"}],"sessionId":"session-1","toolCall":{"kind":"switch_mode","rawInput":{"plan":"# Plan\n\n1. Make the change."},"status":"pending","title":"Implement this plan?","toolCallId":"plan-review:plan-2"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"rawOutput":"User kept the session in plan mode.","sessionUpdate":"tool_call_update","status":"completed","toolCallId":"plan-review:plan-2"}}} diff --git a/src/__tests__/scenarios/data/air/plan-stream.jsonl b/src/__tests__/scenarios/data/air/plan-stream.jsonl index 83038dd8e..33dfe5498 100644 --- a/src/__tests__/scenarios/data/air/plan-stream.jsonl +++ b/src/__tests__/scenarios/data/air/plan-stream.jsonl @@ -1,7 +1,7 @@ {"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} -{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"List configured Model Context Protocol (MCP) tools.","input":null,"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} +{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect [server]]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"plan":{"content":"# Plan\n\n","planId":"plan-3","type":"markdown"},"sessionUpdate":"plan_update"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"_meta":{"jetbrains":{"air":{"contentDelta":"1. Read.","version":1}}},"plan":{"content":"","planId":"plan-3","type":"markdown"},"sessionUpdate":"plan_update"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"_meta":{"jetbrains":{"air":{"contentDelta":"\n2. Write.","version":1}}},"plan":{"content":"","planId":"plan-3","type":"markdown"},"sessionUpdate":"plan_update"}}} diff --git a/src/__tests__/scenarios/data/air/read-search-list.jsonl b/src/__tests__/scenarios/data/air/read-search-list.jsonl index 346e1ea07..31d594f41 100644 --- a/src/__tests__/scenarios/data/air/read-search-list.jsonl +++ b/src/__tests__/scenarios/data/air/read-search-list.jsonl @@ -1,7 +1,7 @@ {"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} -{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"List configured Model Context Protocol (MCP) tools.","input":null,"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} +{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect [server]]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"kind":"read","locations":[{"path":"/workspace/src/app.ts"}],"sessionUpdate":"tool_call","status":"in_progress","title":"Read file '/workspace/src/app.ts'","toolCallId":"read-1"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"read-1"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"kind":"search","sessionUpdate":"tool_call","status":"in_progress","title":"Search for 'TODO' in src","toolCallId":"search-1"}}} diff --git a/src/__tests__/scenarios/data/air/subagent-activity.jsonl b/src/__tests__/scenarios/data/air/subagent-activity.jsonl index b78df0746..66186780f 100644 --- a/src/__tests__/scenarios/data/air/subagent-activity.jsonl +++ b/src/__tests__/scenarios/data/air/subagent-activity.jsonl @@ -1,7 +1,7 @@ {"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} -{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"List configured Model Context Protocol (MCP) tools.","input":null,"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} +{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect [server]]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"capabilities":{},"name":"Weather","sessionUpdate":"subagent_spawned","subagentSessionId":"child-thread","task":"Delegated task for Weather"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"child-thread","update":{"content":{"text":"Sunny","type":"text"},"messageId":"child-msg","sessionUpdate":"agent_message_chunk"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"subagent_state_update","state":"completed","subagentSessionId":"child-thread"}}} diff --git a/src/__tests__/scenarios/data/air/web-search.jsonl b/src/__tests__/scenarios/data/air/web-search.jsonl index 175edd938..43b746b78 100644 --- a/src/__tests__/scenarios/data/air/web-search.jsonl +++ b/src/__tests__/scenarios/data/air/web-search.jsonl @@ -1,7 +1,7 @@ {"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} -{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"List configured Model Context Protocol (MCP) tools.","input":null,"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} +{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect [server]]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"kind":"search","rawInput":{"action":null,"query":""},"sessionUpdate":"tool_call","status":"in_progress","title":"Web search","toolCallId":"web-1"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"rawInput":{"action":{"queries":null,"query":"acp protocol","type":"search"},"query":"acp protocol"},"sessionUpdate":"tool_call_update","status":"completed","title":"Web search: acp protocol","toolCallId":"web-1"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"kind":"search","rawInput":{"action":null,"query":""},"sessionUpdate":"tool_call","status":"in_progress","title":"Web search","toolCallId":"web-2"}}} diff --git a/src/mcp/McpCommand.ts b/src/mcp/McpCommand.ts new file mode 100644 index 000000000..38dda0543 --- /dev/null +++ b/src/mcp/McpCommand.ts @@ -0,0 +1,245 @@ +import type {CodexAcpClient} from "../CodexAcpClient"; +import type {McpServerStatus} from "../app-server/v2"; +import type {SessionState} from "../CodexAcpServer"; +import {isThreadNotFoundError, isUnknownThreadError} from "../CodexThreadErrors"; +import {logger} from "../Logger"; +import {listMcpServerStatus} from "./McpServerStatusList"; +import type {McpServerSignIn} from "./McpServerSignIn"; +import type {McpStartupResult} from "./McpStartupTracker"; +import { + formatNoteDetail, + formatStatus, + inlineCode, + type McpServerEntry, + NO_SERVERS_MESSAGE, + toEntry, +} from "./McpStatusMarkdown"; + +/** The maximum time that `/mcp reconnect` waits for the MCP servers to start. */ +export const MCP_RECONNECT_STARTUP_TIMEOUT_MS = 30_000; + +export const MCP_COMMAND_INPUT_HINT = "[reconnect [server]]"; + +/** The MCP servers of a session. `live` is false when the list has no live status of the session thread. */ +type McpServerList = { + servers: McpServerEntry[]; + live: boolean; +}; + +/** The result of one reload. A `null` startup means that some servers were still starting at the timeout. */ +type ReloadResult = { + error: string | null; + startup: McpStartupResult | null; + list: McpServerList; +}; + +/** Runs `/mcp` and `/mcp reconnect [server]`, and returns the markdown answer. */ +export class McpCommand { + private readonly codexAcpClient: CodexAcpClient; + private readonly runWithProcessCheck: (operation: () => Promise) => Promise; + private readonly signIn: McpServerSignIn; + + constructor( + codexAcpClient: CodexAcpClient, + runWithProcessCheck: (operation: () => Promise) => Promise, + signIn: McpServerSignIn, + ) { + this.codexAcpClient = codexAcpClient; + this.runWithProcessCheck = runWithProcessCheck; + this.signIn = signIn; + } + + /** Returns the answer, or `null` when `signal` aborted. After an abort, the caller must send nothing. */ + async run(rest: string, sessionState: SessionState, signal?: AbortSignal): Promise { + const args = rest.split(/\s+/).filter(arg => arg.length > 0); + if (args.length === 0) { + const list = await this.listServers(sessionState); + return signal?.aborted ? null : formatStatus(list.servers, new Map()); + } + if (args[0]!.toLowerCase() === "reconnect" && args.length <= 2) { + return await this.reconnect(sessionState, args[1] ?? null, signal); + } + return `Command "/mcp" accepts no arguments, or \`reconnect [server]\`.`; + } + + private async reconnect(sessionState: SessionState, serverName: string | null, signal?: AbortSignal): Promise { + const before = await this.listServers(sessionState); + if (before.servers.length === 0) { + return NO_SERVERS_MESSAGE; + } + if (serverName !== null && !before.servers.some(server => server.name === serverName)) { + const knownNames = before.servers.map(server => inlineCode(server.name)).join(", "); + return `Unknown MCP server ${inlineCode(serverName)}. Known servers: ${knownNames}.`; + } + + const notes: string[] = []; + if (serverName !== null) { + notes.push(`Codex cannot reconnect a single server. The reload applies to all MCP servers, not only ${inlineCode(serverName)}.`); + } + + let reload = await this.reloadAndWait(sessionState, signal); + if (reload.error !== null) { + notes.push(`Could not reload the MCP configuration: ${formatNoteDetail(reload.error)}`); + } else { + notes.push(RELOAD_NOTE); + const signIn = await this.signInServers(sessionState, reload, signal); + notes.push(...signIn.notes); + if (signIn.signedIn && !signal?.aborted) { + const secondReload = await this.reloadAndWait(sessionState, signal); + if (secondReload.error !== null) { + notes.push(`Could not reload the MCP configuration after the sign-in: ${formatNoteDetail(secondReload.error)}`); + reload = {...secondReload, startup: reload.startup}; + } else { + reload = secondReload; + } + } + } + + if (signal?.aborted) { + return null; + } + if (reload.startup === null) { + notes.push("Some MCP servers are still starting. Run `/mcp` again later to see their status."); + } + return `${formatNotes(notes)}\n\n${formatStatus(reload.list.servers, startupErrors(reload.startup))}`; + } + + /** Reloads the MCP configuration, and waits for the startup of the servers that the reloaded list contains. */ + private async reloadAndWait(sessionState: SessionState, signal: AbortSignal | undefined): Promise { + const afterVersion = this.codexAcpClient.getMcpServerStartupVersion(); + try { + await this.runWithProcessCheck(() => this.codexAcpClient.reloadMcpServers()); + } catch (err) { + logger.error(`Failed to reload the MCP configuration for session ${sessionState.sessionId}`, err); + return {error: errorMessage(err), startup: emptyStartupResult(), list: await this.listServers(sessionState)}; + } + const reloaded = await this.listServers(sessionState); + if (!reloaded.live) { + // Without the live status, Codex sends no startup events for the session thread. + return {error: null, startup: emptyStartupResult(), list: reloaded}; + } + const serverNames = reloaded.servers + .filter(server => server.listedByCodex && server.status !== "disabled") + .map(server => server.name); + const startup = await this.waitForStartup(sessionState.sessionId, serverNames, afterVersion, signal); + if (signal?.aborted) { + return {error: null, startup, list: reloaded}; + } + return {error: null, startup, list: await this.listServers(sessionState)}; + } + + /** Returns `null` at the timeout or when `signal` aborts. */ + private async waitForStartup( + threadId: string, + serverNames: string[], + afterVersion: number, + signal: AbortSignal | undefined, + ): Promise { + if (signal?.aborted) { + return null; + } + const stop = new AbortController(); + const onAbort = () => stop.abort(); + signal?.addEventListener("abort", onAbort, {once: true}); + const timer = setTimeout(() => stop.abort(), MCP_RECONNECT_STARTUP_TIMEOUT_MS); + try { + return await this.runWithProcessCheck(() => this.codexAcpClient.awaitMcpServerStartup( + serverNames, + afterVersion, + {threadId, signal: stop.signal}, + )); + } catch (err) { + if (stop.signal.aborted) { + return null; + } + throw err; + } finally { + clearTimeout(timer); + signal?.removeEventListener("abort", onAbort); + } + } + + private async signInServers( + sessionState: SessionState, + reload: ReloadResult, + signal: AbortSignal | undefined, + ): Promise<{notes: string[], signedIn: boolean}> { + const names = new Set(reload.list.servers.filter(server => server.status === "authenticationRequired").map(server => server.name)); + for (const failure of reload.startup?.failed ?? []) { + if (failure.failureReason === "reauthenticationRequired") { + names.add(failure.server); + } + } + const notes: string[] = []; + let signedIn = false; + for (const name of names) { + if (signal?.aborted) { + break; + } + const label = inlineCode(name); + switch (await this.signIn(sessionState.sessionId, name, signal)) { + case "unsupported": + notes.push(`MCP server ${label} needs authentication, but this client cannot open a sign-in page.`); + break; + case "signedIn": + notes.push(`Signed in to MCP server ${label}.`); + signedIn = true; + break; + case "failed": + notes.push(`The sign-in to MCP server ${label} failed or was cancelled.`); + break; + } + } + return {notes, signedIn}; + } + + private async listServers(sessionState: SessionState): Promise { + const {servers, live} = await this.listServerStatus(sessionState.sessionId); + const entries = servers.map(toEntry); + for (const name of sessionState.sessionMcpServers ?? []) { + if (!entries.some(entry => entry.name === name)) { + entries.push({name, status: null, toolCount: null, toolsError: null, authStatus: null, listedByCodex: false}); + } + } + return {servers: entries, live}; + } + + private async listServerStatus(threadId: string): Promise<{servers: McpServerStatus[], live: boolean}> { + try { + const servers = await this.runWithProcessCheck(() => listMcpServerStatus(this.codexAcpClient, threadId)); + return {servers, live: true}; + } catch (err) { + if (!isThreadNotFoundError(err) && !isUnknownThreadError(err)) { + throw err; + } + // The list without a thread has no live status. + logger.log(`Codex does not know thread ${threadId}, so the MCP server list has no live status`); + const servers = await this.runWithProcessCheck(() => listMcpServerStatus(this.codexAcpClient)); + return {servers, live: false}; + } + } +} + +const RELOAD_NOTE = "Reloaded the MCP configuration of all open sessions. " + + "Codex restarted the servers that failed, stopped, or changed. " + + "A connected server with unchanged settings keeps its connection."; + +function startupErrors(result: McpStartupResult | null): Map { + return new Map((result?.failed ?? []).map(failure => [failure.server, failure.error])); +} + +function emptyStartupResult(): McpStartupResult { + return {ready: [], failed: [], cancelled: []}; +} + +function errorMessage(err: unknown): string { + if (err instanceof Error) { + return err.message; + } + return String(err); +} + +/** Formats the reconnect notes as a list under a bold label, so that each note is a separate item. */ +function formatNotes(notes: string[]): string { + return ["**Reconnect:**", ...notes.map(note => `- ${note}`)].join("\n"); +} diff --git a/src/mcp/McpOauthCompletions.ts b/src/mcp/McpOauthCompletions.ts new file mode 100644 index 000000000..b8008d5b2 --- /dev/null +++ b/src/mcp/McpOauthCompletions.ts @@ -0,0 +1,77 @@ +import type {McpServerOauthLoginCompletedNotification} from "../app-server/v2"; + +type McpOauthCompletionWaiter = { + threadId: string; + resolve: (event: McpServerOauthLoginCompletedNotification) => void; + reject: (error: unknown) => void; +}; + +/** + * Routes the `mcpServer/oauthLogin/completed` notifications to the sign-ins that wait for them. + * Many sign-ins can wait at the same time. One notification completes every waiter of its server and thread. + */ +export class McpOauthCompletions { + /** The waiters, by server name. */ + private readonly waiters = new Map>(); + private closedError: Error | null = null; + + /** Completes the waiters of the server. A notification without a thread completes the waiters of every thread. */ + complete(event: McpServerOauthLoginCompletedNotification): void { + for (const waiter of [...this.waiters.get(event.name) ?? []]) { + if (event.threadId === null || event.threadId === waiter.threadId) { + waiter.resolve(event); + } + } + } + + /** Waits for the sign-in completion of the server. The wait rejects when `signal` aborts or when the Codex connection closes. */ + async await( + name: string, + threadId: string, + signal?: AbortSignal, + ): Promise { + signal?.throwIfAborted(); + if (this.closedError !== null) { + throw this.closedError; + } + return await new Promise((resolve, reject) => { + const onAbort = () => waiter.reject(signal?.reason); + const release = () => { + const waiters = this.waiters.get(name); + waiters?.delete(waiter); + if (waiters?.size === 0) { + this.waiters.delete(name); + } + signal?.removeEventListener("abort", onAbort); + }; + const waiter: McpOauthCompletionWaiter = { + threadId, + resolve: (event) => { + release(); + resolve(event); + }, + reject: (error) => { + release(); + reject(error); + }, + }; + signal?.addEventListener("abort", onAbort, {once: true}); + let waiters = this.waiters.get(name); + if (waiters === undefined) { + waiters = new Set(); + this.waiters.set(name, waiters); + } + waiters.add(waiter); + }); + } + + /** Rejects every wait, now and later, because the Codex connection closed. */ + dispose(): void { + this.closedError ??= new Error("Codex connection closed during the MCP server sign-in."); + for (const waiters of [...this.waiters.values()]) { + for (const waiter of [...waiters]) { + waiter.reject(this.closedError); + } + } + } +} diff --git a/src/mcp/McpServerSignIn.ts b/src/mcp/McpServerSignIn.ts new file mode 100644 index 000000000..348a7389e --- /dev/null +++ b/src/mcp/McpServerSignIn.ts @@ -0,0 +1,99 @@ +import {randomUUID} from "node:crypto"; +import * as acp from "@agentclientprotocol/sdk"; +import type {AcpClientConnection} from "../ACPSessionConnection"; +import type {CodexAcpClient} from "../CodexAcpClient"; +import {clientSupportsUrlElicitation} from "../ElicitationCapabilities"; +import {logger} from "../Logger"; + +/** The result of an MCP OAuth sign-in. `unsupported` means that the client cannot open a sign-in URL. */ +export type McpSignInResult = "signedIn" | "failed" | "unsupported"; + +/** + * Signs in to an MCP server for a session. It never throws: an error is a `failed` result. + * When `signal` aborts, the sign-in closes its dialog, stops the wait, and returns `failed`. + */ +export type McpServerSignIn = (sessionId: string, serverName: string, signal?: AbortSignal) => Promise; + +/** + * Creates the MCP OAuth sign-in. The sign-in opens the authorization URL through an ACP URL elicitation, + * so it works only when the client supports URL elicitation. + */ +export function createMcpServerSignIn( + connection: AcpClientConnection, + codexAcpClient: () => CodexAcpClient, + clientCapabilities: () => acp.ClientCapabilities | null, +): McpServerSignIn { + return async (sessionId, serverName, signal) => { + if (!clientSupportsUrlElicitation(clientCapabilities())) { + return "unsupported"; + } + try { + return await signInWithUrlElicitation(connection, codexAcpClient(), sessionId, serverName, signal) + ? "signedIn" + : "failed"; + } catch (err) { + if (!signal?.aborted) { + logger.error(`Failed to authenticate MCP server ${serverName}`, err); + } + return "failed"; + } + }; +} + +async function signInWithUrlElicitation( + connection: AcpClientConnection, + codexAcpClient: CodexAcpClient, + sessionId: string, + serverName: string, + signal: AbortSignal | undefined, +): Promise { + signal?.throwIfAborted(); + // The stop ends the completion wait on every exit, so a declined or aborted sign-in leaves no waiter. + const stop = new AbortController(); + const onAbort = () => stop.abort(signal?.reason); + signal?.addEventListener("abort", onAbort, {once: true}); + const stopped = new Promise((_, reject) => { + stop.signal.addEventListener("abort", () => reject(stop.signal.reason), {once: true}); + }); + void stopped.catch(() => {}); + const elicitationId = `mcp-oauth-${randomUUID()}`; + /** True while the client shows the dialog that the sign-in must close. */ + let dialogOpen = false; + try { + const completed = codexAcpClient.awaitMcpServerOauthLoginCompleted(serverName, sessionId, stop.signal); + void completed.catch(() => {}); + const login = await Promise.race([ + codexAcpClient.mcpServerOauthLogin({name: serverName, threadId: sessionId}), + stopped, + ]); + const elicitation = Promise.resolve(connection.request( + acp.methods.client.elicitation.create, + { + mode: "url", + sessionId, + message: `Authenticate with MCP server ${serverName}`, + url: login.authorizationUrl, + elicitationId, + }, + )); + void elicitation.catch(() => {}); + dialogOpen = true; + const first = await Promise.race([ + completed.then(result => ({type: "completed" as const, result})), + elicitation.then(response => ({type: "elicitation" as const, response})), + stopped, + ]); + if (first.type === "elicitation" && !acp.CreateElicitationResponse.isAccept(first.response)) { + dialogOpen = false; + return false; + } + const result = first.type === "completed" ? first.result : await Promise.race([completed, stopped]); + return result.success; + } finally { + stop.abort(); + signal?.removeEventListener("abort", onAbort); + if (dialogOpen) { + await connection.notify(acp.methods.client.elicitation.complete, {elicitationId}); + } + } +} diff --git a/src/mcp/McpServerStatusList.ts b/src/mcp/McpServerStatusList.ts new file mode 100644 index 000000000..77da99655 --- /dev/null +++ b/src/mcp/McpServerStatusList.ts @@ -0,0 +1,30 @@ +import type {CodexAcpClient} from "../CodexAcpClient"; +import type {McpServerStatus} from "../app-server/v2"; +import {logger} from "../Logger"; + +/** + * Returns the status of every MCP server. With `threadId`, the status includes the live connection + * state of that thread. The function reads all pages. + */ +export async function listMcpServerStatus(codexAcpClient: CodexAcpClient, threadId?: string): Promise> { + const servers: Array = []; + const seenCursors = new Set(); + let cursor: string | null = null; + do { + const page = await codexAcpClient.listMcpServers({ + cursor, + detail: "toolsAndAuthOnly", + ...(threadId === undefined ? {} : {threadId}), + }); + servers.push(...page.data); + cursor = page.nextCursor; + if (cursor !== null) { + if (seenCursors.has(cursor)) { + logger.log(`MCP server status list repeated the cursor ${cursor}`); + break; + } + seenCursors.add(cursor); + } + } while (cursor !== null); + return servers; +} diff --git a/src/mcp/McpStartupTracker.ts b/src/mcp/McpStartupTracker.ts new file mode 100644 index 000000000..a2bfd8d74 --- /dev/null +++ b/src/mcp/McpStartupTracker.ts @@ -0,0 +1,192 @@ +import type { + McpServerStartupFailureReason, + McpServerStartupState, + McpServerStatusUpdatedNotification, +} from "../app-server/v2"; + +export type McpStartupFailure = { + server: string; + error: string; + failureReason?: McpServerStartupFailureReason; +}; + +export type McpStartupResult = { + ready: Array; + failed: Array; + cancelled: Array; +}; + +export type McpServerStartupWaitOptions = { + /** The thread whose startup events count. The global events also count. */ + threadId: string; + signal?: AbortSignal; +}; + +type McpServerStartupSnapshot = { + status: McpServerStartupState; + error: string | null; + failureReason: McpServerStartupFailureReason | null; + version: number; +}; + +type McpServerStartupWaiter = { + serverNames: Array; + afterVersion: number; + threadId: string; + resolve: (result: McpStartupResult) => void; + reject: (error: unknown) => void; +}; + +/** + * Tracks the MCP server startup events of one Codex connection. + * Each event increments the version, so a caller can wait for the events that come after a version. + */ +export class McpStartupTracker { + private currentVersion = 0; + /** The last startup state of each MCP server, by server name and then by thread id. A `null` thread id is global. */ + private readonly states = new Map>(); + private readonly waiters: Array = []; + private closedError: Error | null = null; + + /** Returns the version of the last startup event. */ + version(): number { + return this.currentVersion; + } + + record(params: McpServerStatusUpdatedNotification): void { + this.currentVersion += 1; + let states = this.states.get(params.name); + if (states === undefined) { + states = new Map(); + this.states.set(params.name, states); + } + states.set(params.threadId, { + status: params.status, + error: params.error, + failureReason: params.failureReason ?? null, + version: this.currentVersion, + }); + for (const waiter of [...this.waiters]) { + const result = this.tryBuildResult(waiter.serverNames, waiter.afterVersion, waiter.threadId); + if (result !== null) { + waiter.resolve(result); + } + } + } + + /** + * Waits until each server reaches a terminal startup state after `afterVersion`. + * Only the events of `threadId` and the global events count. + * The wait rejects when `signal` aborts or when the Codex connection closes. + */ + async await( + serverNames: Array, + afterVersion: number, + options: McpServerStartupWaitOptions, + ): Promise { + const uniqueServerNames = Array.from(new Set(serverNames.map(name => name.trim()).filter(name => name.length > 0))); + if (uniqueServerNames.length === 0) { + return {ready: [], failed: [], cancelled: []}; + } + const {threadId, signal} = options; + const result = this.tryBuildResult(uniqueServerNames, afterVersion, threadId); + if (result !== null) { + return result; + } + signal?.throwIfAborted(); + if (this.closedError !== null) { + throw this.closedError; + } + + return await new Promise((resolve, reject) => { + const onAbort = () => waiter.reject(signal?.reason); + const release = () => { + const index = this.waiters.indexOf(waiter); + if (index >= 0) { + this.waiters.splice(index, 1); + } + signal?.removeEventListener("abort", onAbort); + }; + const waiter: McpServerStartupWaiter = { + serverNames: uniqueServerNames, + afterVersion, + threadId, + resolve: (startup) => { + release(); + resolve(startup); + }, + reject: (error) => { + release(); + reject(error); + }, + }; + signal?.addEventListener("abort", onAbort, {once: true}); + this.waiters.push(waiter); + }); + } + + /** Drops the startup states of a thread. The global states stay. */ + forgetThread(threadId: string): void { + for (const [serverName, states] of this.states) { + states.delete(threadId); + if (states.size === 0) { + this.states.delete(serverName); + } + } + } + + /** Rejects every wait, now and later, because the Codex connection closed. */ + dispose(): void { + this.closedError ??= new Error("Codex connection closed during the MCP server startup."); + for (const waiter of [...this.waiters]) { + waiter.reject(this.closedError); + } + } + + /** Returns the latest startup state of the server for the thread. A global state also counts. */ + private latestState(serverName: string, threadId: string): McpServerStartupSnapshot | undefined { + let latest: McpServerStartupSnapshot | undefined; + for (const [stateThreadId, state] of this.states.get(serverName) ?? []) { + if (stateThreadId !== null && stateThreadId !== threadId) { + continue; + } + if (latest === undefined || state.version > latest.version) { + latest = state; + } + } + return latest; + } + + private tryBuildResult(serverNames: Array, afterVersion: number, threadId: string): McpStartupResult | null { + const ready: Array = []; + const failed: Array = []; + const cancelled: Array = []; + + for (const serverName of serverNames) { + const state = this.latestState(serverName, threadId); + if (!state || state.version <= afterVersion) { + return null; + } + + switch (state.status) { + case "starting": + return null; + case "ready": + ready.push(serverName); + break; + case "failed": + failed.push({ + server: serverName, + error: state.error ?? "unknown MCP startup error", + ...(state.failureReason === null ? {} : {failureReason: state.failureReason}), + }); + break; + case "cancelled": + cancelled.push(serverName); + break; + } + } + + return {ready, failed, cancelled}; + } +} diff --git a/src/mcp/McpStatusMarkdown.ts b/src/mcp/McpStatusMarkdown.ts new file mode 100644 index 000000000..8c99f8cbe --- /dev/null +++ b/src/mcp/McpStatusMarkdown.ts @@ -0,0 +1,278 @@ +import type {McpAuthStatus, McpServerConnectionStatus, McpServerStatus} from "../app-server/v2"; + +/** One MCP server in the status list. */ +export type McpServerEntry = { + name: string; + status: McpServerConnectionStatus | null; + toolCount: number | null; + toolsError: string | null; + authStatus: McpAuthStatus | null; + /** False for a session MCP server that the Codex status list does not contain. */ + listedByCodex: boolean; +}; + +export const NO_SERVERS_MESSAGE = "No MCP servers are configured."; + +/** The order of the counts in the summary line. */ +const SUMMARY_ORDER: Array = [ + "connected", + "starting", + "notStarted", + "authenticationRequired", + "failed", + "cancelled", + "disabled", + null, +]; + +/** The order of the groups. The disabled servers come last, on one line. */ +const GROUP_ORDER: Array = [ + "failed", + "cancelled", + "authenticationRequired", + "notStarted", + "starting", + "connected", + null, +]; + +function statusLabel(status: McpServerConnectionStatus | null): string { + switch (status) { + case "connected": + return "Connected"; + case "starting": + return "Connecting"; + case "notStarted": + return "Not started"; + case "authenticationRequired": + return "Needs authentication"; + case "failed": + return "Failed"; + case "cancelled": + return "Cancelled"; + case "disabled": + return "Disabled"; + case null: + return "Unknown"; + } +} + +/** The statuses that this adapter knows. */ +const KNOWN_STATUSES = new Set([...GROUP_ORDER.filter((status): status is McpServerConnectionStatus => status !== null), "disabled"]); + +/** A status that a newer Codex reports and this adapter does not know becomes `null`, so that the list shows it as unknown. */ +export function toEntry(server: McpServerStatus): McpServerEntry { + const status = server.runtimeStatus; + return { + name: server.name, + status: status !== null && KNOWN_STATUSES.has(status) ? status : null, + toolCount: Object.keys(server.tools ?? {}).length, + toolsError: server.toolsError, + authStatus: server.authStatus, + listedByCodex: true, + }; +} + +/** + * Formats the servers as a markdown list, grouped by the status. + * `errors` maps a server name to its startup error. + * Inside a group, the servers keep the Codex order. + */ +export function formatStatus(servers: McpServerEntry[], errors: Map): string { + if (servers.length === 0) { + return NO_SERVERS_MESSAGE; + } + const counts = SUMMARY_ORDER + .map(status => ({status, count: servers.filter(server => server.status === status).length})) + .filter(entry => entry.count > 0) + .map(entry => `${entry.count} ${statusLabel(entry.status).toLowerCase()}`); + const lines = [`**MCP servers:** ${servers.length} (${counts.join(", ")})`]; + for (const status of GROUP_ORDER) { + const group = servers.filter(server => server.status === status); + if (group.length === 0) { + continue; + } + lines.push("", `**${statusLabel(status)}**`); + for (const server of group) { + const detail = serverDetail(server, errors.get(server.name) ?? null); + lines.push(detail === null ? `- ${inlineCode(server.name)}` : `- ${inlineCode(server.name)}: ${detail}`); + } + } + const disabled = servers.filter(server => server.status === "disabled"); + if (disabled.length > 0) { + lines.push("", `**Disabled:** ${disabled.map(server => inlineCode(server.name)).join(", ")}`); + } + if (servers.some(server => server.status !== null && RECONNECT_HINT_STATUSES.has(server.status))) { + lines.push("", RECONNECT_HINT); + } + return lines.join("\n"); +} + +export const RECONNECT_HINT = "Run `/mcp reconnect` to restart the servers that are not connected."; + +/** The statuses that a reconnect can change. */ +const RECONNECT_HINT_STATUSES = new Set(["failed", "cancelled", "authenticationRequired", "notStarted"]); + +function serverDetail(server: McpServerEntry, startupError: string | null): string | null { + if (server.status === "authenticationRequired") { + return NOT_SIGNED_IN; + } + const parts: string[] = []; + const toolCount = server.toolCount ?? 0; + if (server.status === "connected" || toolCount > 0) { + parts.push(`${toolCount} ${toolCount === 1 ? "tool" : "tools"}`); + } + if (server.authStatus === "notLoggedIn" && server.status !== "disabled") { + parts.push(NOT_SIGNED_IN); + } + const error = startupError ?? server.toolsError; + const cleanedError = error === null ? "" : formatNoteDetail(error); + if (cleanedError.length > 0) { + parts.push(cleanedError); + } + return parts.length === 0 ? null : parts.join("; "); +} + +const NOT_SIGNED_IN = "not signed in"; + +/** The maximum length of a cleaned error, without the ellipsis. */ +const MAX_ERROR_LENGTH = 160; + +const MAX_ERROR_SENTENCES = 2; + +/** The error chain segments that only wrap the real message. */ +const WRAPPER_SEGMENTS = [ + /^tool discovery failed$/i, + /^mcp startup failed$/i, + /^handshaking with mcp server failed$/i, + /^json-rpc error$/i, + /^startup error$/i, + /^error$/i, + /^mcp client for .+ failed to start$/i, + /^-?\d+$/, +]; + +/** + * Finds a Rust type path, such as `crate::module::Type`, with an optional `Transport [` prefix. + * The lookbehind skips an IPv6 address, such as `[::1]` or `[fe80::1]`, and a URL. + */ +const TYPE_PATH = /(?:\b[A-Z]\w*\s*\[\s*|(?` that closes the `<` at `start`, or -1 when the text has no such `>`. */ +function genericsEnd(text: string, start: number): number { + let depth = 0; + for (let index = start; index < text.length; index++) { + if (text[index] === "<") { + depth++; + } else if (text[index] === ">") { + depth--; + if (depth === 0) { + return index + 1; + } + } + } + return -1; +} + +/** + * Makes an MCP error chain short and readable, on one line. + * It removes the wrapper segments, the numeric codes, the Rust type paths, and the repeated messages. + * It keeps at most two sentences and about 160 characters. + * When nothing remains, it returns the shortened raw text. + */ +export function cleanErrorMessage(text: string): string { + const oneLine = text.replace(/\s+/g, " ").trim(); + const kept: string[] = []; + for (const rawSegment of oneLine.split(": ")) { + const segment = removeTypePaths(rawSegment.trim()); + if (segment.length === 0 || WRAPPER_SEGMENTS.some(pattern => pattern.test(segment))) { + continue; + } + const withoutEllipsis = withoutTrailingEllipsis(segment); + if (kept.some(previous => previous.startsWith(withoutEllipsis))) { + continue; + } + // A segment that repeats an earlier one and adds more text replaces it, such as `timeout: timeout after 30s`. + const repeated = kept.findIndex(previous => startsWithWords(segment, withoutTrailingEllipsis(previous))); + if (repeated >= 0) { + kept.splice(repeated, 1); + } + kept.push(segment); + } + if (kept.length === 0) { + return shorten(oneLine); + } + const sentences = kept.join(": ").split(SENTENCE_END); + return shorten(sentences.slice(0, MAX_ERROR_SENTENCES).join(" ")); +} + +function withoutTrailingEllipsis(text: string): string { + return text.replace(/(?:\.\.\.|…)$/, "").trim(); +} + +/** True when `text` starts with `prefix` and the prefix ends at a word end. */ +function startsWithWords(text: string, prefix: string): boolean { + return prefix.length > 0 && text.startsWith(prefix) && (text.length === prefix.length || /\W/.test(text[prefix.length]!)); +} + +/** Shortens the text by code points, so that the cut never splits a surrogate pair. */ +function shorten(text: string): string { + const codePoints = Array.from(text); + return codePoints.length > MAX_ERROR_LENGTH ? `${codePoints.slice(0, MAX_ERROR_LENGTH).join("").trimEnd()}…` : text; +} + +/** Cleans an error text and escapes the markdown characters, so that it is safe in a list item or a note. */ +export function formatNoteDetail(text: string): string { + return escapeMarkdown(cleanErrorMessage(text)); +} + +function escapeMarkdown(text: string): string { + return text.replace(/[\\`*_[\]<>~]/g, character => `\\${character}`); +} + +export const UNNAMED_SERVER = "(unnamed)"; + +/** + * Formats a server name as inline code. The fence is one backtick longer than the longest backtick run in the name. + * A name that starts or ends with a backtick or a space gets a space on each side, so that the markdown keeps it as is. + * An empty name shows as `(unnamed)`. + */ +export function inlineCode(text: string): string { + if (text.length === 0) { + return UNNAMED_SERVER; + } + const longestRun = Math.max(0, ...Array.from(text.matchAll(/`+/g), match => match[0].length)); + const fence = "`".repeat(longestRun + 1); + const padding = /^[` ]|[` ]$/.test(text) ? " " : ""; + return `${fence}${padding}${text}${padding}${fence}`; +} From eee8863254d8c732868642d24a81a0bb9600ed9c Mon Sep 17 00:00:00 2001 From: Nikita Ashikhmin Date: Fri, 2 Oct 2026 00:06:02 +0400 Subject: [PATCH 02/22] feat: send the SKILL.md path of a skill command to AIR A skill command now carries _meta.jetbrains.air.skillPath with the path that skills/list reports. AIR opens this file on a click on the skill chip in the transcript. Other clients get no metadata. --- src/AirExtension.ts | 1 + src/CodexCommands.ts | 5 ++- .../CodexACPAgent/CodexAcpClient.test.ts | 33 +++++++++++++++++++ .../data/available-commands-skills.json | 10 +++++- 4 files changed, 47 insertions(+), 2 deletions(-) diff --git a/src/AirExtension.ts b/src/AirExtension.ts index b97255fb6..7870a17ad 100644 --- a/src/AirExtension.ts +++ b/src/AirExtension.ts @@ -24,6 +24,7 @@ export const AIR_MESSAGE_PHASE_KEY = "phase"; export const AIR_GOAL_KEY = "goal"; export const AIR_KIND_KEY = "kind"; export const AIR_COMMAND_ACTION_KEY = "commandAction"; +export const AIR_SKILL_PATH_KEY = "skillPath"; export const AIR_PERMISSION_KEY = "permission"; export const AIR_CONTEXT_COMPACTION_KEY = "contextCompaction"; export const AIR_RAW_INPUT_RENDERING_KEY = "rawInputRendering"; diff --git a/src/CodexCommands.ts b/src/CodexCommands.ts index 5b294bc4e..fb3efb97a 100644 --- a/src/CodexCommands.ts +++ b/src/CodexCommands.ts @@ -1,7 +1,7 @@ import type * as acp from "@agentclientprotocol/sdk"; import type {AvailableCommand} from "@agentclientprotocol/sdk"; import {ACPSessionConnection, type AcpClientConnection} from "./ACPSessionConnection"; -import {AIR_COMMAND_ACTION_KEY, airOnlyMeta} from "./AirExtension"; +import {AIR_COMMAND_ACTION_KEY, AIR_SKILL_PATH_KEY, airOnlyMeta} from "./AirExtension"; import type {CodexAcpClient} from "./CodexAcpClient"; import type {RateLimitSnapshot, ReviewTarget, SkillsListEntry, SkillsListParams, TurnCompletedNotification} from "./app-server/v2"; import type {SessionState} from "./CodexAcpServer"; @@ -118,10 +118,13 @@ export class CodexCommands { const name = `$${skill.name}`; if (commands.has(name)) continue; const description = skill.shortDescription ?? skill.description ?? skill.name; + // Only AIR gets the SKILL.md path, in `_meta.jetbrains.air.skillPath`. A click on the skill chip opens it. + const meta = airOnlyMeta(airClient, AIR_SKILL_PATH_KEY, skill.path); commands.set(name, { name, description, input: null, + ...(meta ? {_meta: meta} : {}), }); } } diff --git a/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts b/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts index 0b87be46d..266e3c608 100644 --- a/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts +++ b/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts @@ -19,6 +19,7 @@ import type {Model, ReviewStartResponse, ThreadGoal, TurnCompletedNotification, import type {RateLimitsMap} from "../../RateLimitsMap"; import {ModelId} from "../../ModelId"; import {GOAL_CONTROL_METHOD} from "../../AcpExtensions"; +import {ClientCapabilities} from "../../tool-calls/ClientCapabilities"; import type {McpStartupResult} from "../../mcp/McpStartupTracker"; describe('ACP server test', { timeout: 40_000 }, () => { @@ -1804,6 +1805,38 @@ describe('ACP server test', { timeout: 40_000 }, () => { await expect(mockFixture.getAcpConnectionDump([])).toMatchFileSnapshot("data/available-commands-skills.json"); }); + it('sends the skill path only to AIR', async () => { + const mockFixture = createCodexMockTestFixture(); + const codexAcpAgent = mockFixture.getCodexAcpAgent(); + + vi.spyOn(mockFixture.getCodexAcpClient(), "listSkills").mockResolvedValue({ + data: [{ + cwd: "/workspace", + skills: [{ + name: "build", + description: "Build the project", + path: "/workspace/.agents/skills/build/SKILL.md", + scope: "repo", + enabled: true, + pluginId: null + }], + errors: [] + }] + }); + + // @ts-expect-error - exercising private helper + await codexAcpAgent.availableCommands.publish(createTestSessionState({ + sessionId: "session-id", + cwd: "/workspace", + clientCapabilities: ClientCapabilities.from({}), + })); + + const update = mockFixture.getAcpConnectionEvents([]) + .find(event => event.method === "sessionUpdate" && event.args[0].update.sessionUpdate === "available_commands_update"); + const skill = update?.args[0].update.availableCommands.find((command: acp.AvailableCommand) => command.name === "$build"); + expect(skill).toEqual({name: "$build", description: "Build the project", input: null}); + }); + it('publishes the commands again after a turn only when the skills changed', async () => { const mockFixture = createCodexMockTestFixture(); const codexAcpAgent = mockFixture.getCodexAcpAgent(); diff --git a/src/__tests__/CodexACPAgent/data/available-commands-skills.json b/src/__tests__/CodexACPAgent/data/available-commands-skills.json index 6b2a6a71d..529bcd213 100644 --- a/src/__tests__/CodexACPAgent/data/available-commands-skills.json +++ b/src/__tests__/CodexACPAgent/data/available-commands-skills.json @@ -101,7 +101,15 @@ { "name": "$build", "description": "Build", - "input": null + "input": null, + "_meta": { + "jetbrains": { + "air": { + "version": 1, + "skillPath": "/workspace" + } + } + } } ] } From b8bbdf579cddadccbd5bb28ae8724268cd637472 Mon Sep 17 00:00:00 2001 From: Nikita Ashikhmin Date: Fri, 2 Oct 2026 00:59:26 +0400 Subject: [PATCH 03/22] fix: end the compaction wait when Codex exits The compaction wait ended only on the close event of the connection. The exit of the Codex process disposes the connection and fires no close event. So the wait never ended when Codex exited during a compaction. The wait now also ends on the dispose event, as the MCP waits do. --- src/CodexAppServerClient.ts | 6 +++- .../compact-command-lifecycle.test.ts | 34 +++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/src/CodexAppServerClient.ts b/src/CodexAppServerClient.ts index 16067f331..19a881208 100644 --- a/src/CodexAppServerClient.ts +++ b/src/CodexAppServerClient.ts @@ -535,7 +535,10 @@ export class CodexAppServerClient { }); // The request acknowledgement can still be pending when the connection closes. void completed.catch(() => {}); - const closed = this.connection.onClose?.(() => rejectCompleted(new Error("Codex connection closed during compaction."))); + // The process exit disposes the connection and does not close it, so both events end the wait. + const rejectClosed = () => rejectCompleted(new Error("Codex connection closed during compaction.")); + const closed = this.connection.onClose?.(rejectClosed); + const disposed = this.connection.onDispose?.(rejectClosed); const completeCompaction = (event: CompactionCompletedNotification) => { if (compactTurnId !== null && event.params.turnId !== compactTurnId) return; resolveCompleted(event); @@ -562,6 +565,7 @@ export class CodexAppServerClient { releaseRoutingCapture(); releaseCompactionCapture(); closed?.dispose(); + disposed?.dispose(); } } diff --git a/src/__tests__/CodexACPAgent/compact-command-lifecycle.test.ts b/src/__tests__/CodexACPAgent/compact-command-lifecycle.test.ts index ee598dd73..47d4d3bca 100644 --- a/src/__tests__/CodexACPAgent/compact-command-lifecycle.test.ts +++ b/src/__tests__/CodexACPAgent/compact-command-lifecycle.test.ts @@ -1,5 +1,10 @@ +import {chmodSync, mkdtempSync, rmSync, writeFileSync} from "node:fs"; +import {tmpdir} from "node:os"; +import path from "node:path"; import {describe, expect, it, vi} from "vitest"; import type {ServerNotification} from "../../app-server"; +import {CodexAppServerClient} from "../../CodexAppServerClient"; +import {startCodexConnection} from "../../CodexJsonRpcConnection"; import type {Turn} from "../../app-server/v2"; import {createCodexMockTestFixture, createTestSessionState} from "../acp-test-utils"; @@ -100,6 +105,35 @@ describe("compact command lifecycle", () => { expect(dispose).toHaveBeenCalledOnce(); }); + it.skipIf(process.platform === "win32")("rejects a compact wait when the Codex process exits", async () => { + // The real connection path: the process exit disposes the vscode-jsonrpc connection and fires no close event. + const dir = mkdtempSync(path.join(tmpdir(), "codex-acp-compact-exit-")); + try { + const fakeCodex = path.join(dir, "codex"); + // The fake Codex acknowledges thread/compact/start and then never completes the compaction. + writeFileSync(fakeCodex, [ + "#!/bin/sh", + "read line", + "id=$(printf '%s' \"$line\" | sed -E 's/.*\"id\":([0-9]+).*/\\1/')", + "printf '{\"id\":%s,\"result\":{}}\\n' \"$id\"", + "exec sleep 30", + "", + ].join("\n")); + chmodSync(fakeCodex, 0o755); + const codex = startCodexConnection(fakeCodex); + const appServer = new CodexAppServerClient(codex.connection); + const start = vi.spyOn(appServer, "threadCompactStart"); + + const pending = appServer.runCompact({threadId: sessionId}); + await expect(start.mock.results[0]?.value).resolves.toEqual({}); + codex.process.kill(); + + await expect(pending).rejects.toThrow("Codex connection closed during compaction."); + } finally { + rmSync(dir, {recursive: true, force: true}); + } + }); + it("cancels the manual compact turn and finishes the ACP prompt", async () => { const fixture = await commandFixture(); const appServer = fixture.getCodexAppServerClient(); From ecca58da919d7856d6801c36c128888c31c3f613 Mon Sep 17 00:00:00 2001 From: Nikita Ashikhmin Date: Fri, 2 Oct 2026 01:04:30 +0400 Subject: [PATCH 04/22] refactor: move the MCP session startup report to src/mcp/ McpSessionStartup now owns the pending startup of each session, the startup wait with the timeout from _meta, the sign-in, and the report. CodexAcpServer keeps only the calls. The behaviour does not change. --- src/CodexAcpServer.ts | 190 +++------------------------ src/mcp/McpSessionStartup.ts | 243 +++++++++++++++++++++++++++++++++++ 2 files changed, 263 insertions(+), 170 deletions(-) create mode 100644 src/mcp/McpSessionStartup.ts diff --git a/src/CodexAcpServer.ts b/src/CodexAcpServer.ts index f32da6a37..a9e78627a 100644 --- a/src/CodexAcpServer.ts +++ b/src/CodexAcpServer.ts @@ -8,6 +8,7 @@ import {CodexElicitationHandler} from "./CodexElicitationHandler"; import {type CodexAuthRequest, getCodexAuthMethods, isCodexAuthRequest} from "./CodexAuthMethod"; import {clientSupportsUrlElicitation} from "./ElicitationCapabilities"; import {createMcpServerSignIn, type McpServerSignIn} from "./mcp/McpServerSignIn"; +import {getRequestedMcpServerNames, McpSessionStartup, parseMcpStartupAwaitTimeoutMs} from "./mcp/McpSessionStartup"; import { CodexAcpClient, type JsonObject, @@ -17,7 +18,6 @@ import { type UrlElicitationRequester } from "./CodexAcpClient"; import {CodexAppServerClient} from "./CodexAppServerClient"; -import type {McpStartupResult} from "./mcp/McpStartupTracker"; import {isNoActiveTurnError} from "./CodexThreadErrors"; import {type CodexConnection, startCodexConnection} from "./CodexJsonRpcConnection"; import {type AcpClientConnection, ACPSessionConnection, type UpdateSessionEvent} from "./ACPSessionConnection"; @@ -57,7 +57,6 @@ import {CodexCommands, GOAL_CONTINUATION_PROMPT} from "./CodexCommands"; import {SteeringQueue} from "./SteeringQueue"; import type {QuotaMeta} from "./QuotaMeta"; import {logger} from "./Logger"; -import {sanitizeMcpServerName} from "./McpServerName"; import type {ToolCallReports} from "./ToolCallReports"; import {ToolCallReportingConnection} from "./ToolCallReportingConnection"; import { @@ -90,7 +89,6 @@ import {DynamicToolReporter} from "./tool-calls/reporters/DynamicToolReporter"; import {FileChangeReporter} from "./tool-calls/reporters/FileChangeReporter"; import {ImageGenerationReporter} from "./tool-calls/reporters/ImageGenerationReporter"; import {ImageViewReporter} from "./tool-calls/reporters/ImageViewReporter"; -import {McpStartupReporter} from "./tool-calls/reporters/McpStartupReporter"; import {McpToolReporter} from "./tool-calls/reporters/McpToolReporter"; import {PlanReviewReporter} from "./tool-calls/reporters/PlanReviewReporter"; import {SubagentActivityReporter} from "./tool-calls/reporters/SubagentActivityReporter"; @@ -254,13 +252,6 @@ interface ActiveAuthState { authConfigured: boolean; } -interface PendingMcpStartupSession { - requestedServers: Set; - startup: Promise; - /** Stops the startup wait and the sign-in of the startup report. */ - abort: AbortController; -} - interface PendingTurnStart { promise: Promise; resolve: (turnId: string | null) => void; @@ -305,7 +296,7 @@ export class CodexAcpServer { private currentAuthStatus: AuthStatus | null; private readonly sessions: Map; - private readonly pendingMcpStartupSessions: Map; + private readonly mcpSessionStartup: McpSessionStartup; private readonly pendingTurnStarts: Map; private readonly activePrompts: Map; private readonly steeringQueues: Map; @@ -328,7 +319,6 @@ export class CodexAcpServer { codexProcessState?: CodexProcessState, ) { this.sessions = new Map(); - this.pendingMcpStartupSessions = new Map(); this.pendingTurnStarts = new Map(); this.activePrompts = new Map(); this.steeringQueues = new Map(); @@ -345,6 +335,14 @@ export class CodexAcpServer { () => this.codexAcpClient, () => this.clientCapabilities, ); + this.mcpSessionStartup = new McpSessionStartup( + this.connection, + () => this.codexAcpClient, + (operation) => this.runWithProcessCheck(operation), + this.mcpServerSignIn, + () => this.capabilities, + (sessionId) => this.sessions.has(sessionId) && !this.sessionIsClosing(sessionId), + ); this.defaultAuthRequest = defaultAuthRequest ?? null; this.codexProcessState = codexProcessState ?? null; this.captureStderr(); @@ -758,22 +756,15 @@ export class CodexAcpServer { const canPublishSessionUpdates = operation !== "fork"; if (requestedMcpServers.length > 0 && mcpServerStartupVersion !== null) { - const pendingStartup = this.createPendingMcpStartupSession( - sessionId, - requestedMcpServers, - mcpServerStartupVersion, - ); - if (canPublishSessionUpdates) { - this.pendingMcpStartupSessions.set(sessionId, pendingStartup); - } - const startupAwaitTimeoutMs = parseMcpStartupAwaitTimeoutMs(request._meta); - if (startupAwaitTimeoutMs !== undefined && startupAwaitTimeoutMs > 0) { + // A fork publishes no startup report, so nothing waits for the rest of the startup. + const startupWait = this.mcpSessionStartup.begin(sessionId, requestedMcpServers, mcpServerStartupVersion, { + publish: canPublishSessionUpdates, + awaitTimeoutMs: parseMcpStartupAwaitTimeoutMs(request._meta), + }); + if (startupWait !== null) { try { - await raceMcpStartupTimeout(pendingStartup.startup, startupAwaitTimeoutMs); + await startupWait; } catch (err) { - if (this.pendingMcpStartupSessions.get(sessionId) === pendingStartup) { - this.pendingMcpStartupSessions.delete(sessionId); - } // The session is installed already. A failed wait closes it, so the client never gets a half-open session. await this.closeSession({sessionId}).catch(closeError => { logger.error(`Failed to close session ${sessionId} after a failed MCP startup wait`, closeError); @@ -781,12 +772,6 @@ export class CodexAcpServer { throw err; } } - if (canPublishSessionUpdates) { - this.publishMcpStartupStatusAsync(sessionId); - } else { - // A fork publishes no startup report, so nothing waits for the rest of the startup. - pendingStartup.abort.abort(); - } } if (canPublishSessionUpdates) { @@ -982,8 +967,7 @@ export class CodexAcpServer { } finally { if (this.getSessionGeneration(params.sessionId) === closeGeneration) { this.sessions.delete(params.sessionId); - this.pendingMcpStartupSessions.get(params.sessionId)?.abort.abort(); - this.pendingMcpStartupSessions.delete(params.sessionId); + this.mcpSessionStartup.close(params.sessionId); this.codexAcpClient.appServerClient.mcpStartup.forgetThread(params.sessionId); this.pendingTurnStarts.delete(params.sessionId); this.activePrompts.delete(params.sessionId); @@ -1020,7 +1004,7 @@ export class CodexAcpServer { private hasLocalSession(sessionId: string): boolean { return this.sessions.has(sessionId) - || this.pendingMcpStartupSessions.has(sessionId) + || this.mcpSessionStartup.isPending(sessionId) || this.pendingTurnStarts.has(sessionId) || this.activePrompts.has(sessionId) || this.hasPendingSessionOpen(sessionId) @@ -2062,11 +2046,7 @@ export class CodexAcpServer { subscribed = false; if (requestedMcpServers.length > 0 && mcpServerStartupVersion !== null) { - this.pendingMcpStartupSessions.set( - sessionId, - this.createPendingMcpStartupSession(sessionId, requestedMcpServers, mcpServerStartupVersion), - ); - this.publishMcpStartupStatusAsync(sessionId); + this.mcpSessionStartup.begin(sessionId, requestedMcpServers, mcpServerStartupVersion, {publish: true}); } await this.publishAvailableCommands(sessionState, requestedSessionGeneration); @@ -2529,95 +2509,6 @@ export class CodexAcpServer { return []; } - private publishMcpStartupStatusAsync(sessionId: string): void { - void this.doPublishMcpStartupStatus(sessionId); - } - - private createPendingMcpStartupSession( - sessionId: string, - mcpServers: Array, - afterVersion: number, - ): PendingMcpStartupSession { - const requestedServers = new Set(getRequestedMcpServerNames(mcpServers)); - const abort = new AbortController(); - const startup = this.runWithProcessCheck(() => this.codexAcpClient.awaitMcpServerStartup( - Array.from(requestedServers), - afterVersion, - {threadId: sessionId, signal: abort.signal}, - )); - // An abort can reject the startup before a caller awaits it. - void startup.catch(() => {}); - return {requestedServers, startup, abort}; - } - - private async doPublishMcpStartupStatus(sessionId: string): Promise { - const pendingStartup = this.pendingMcpStartupSessions.get(sessionId); - if (!pendingStartup) { - return; - } - - try { - const mcpStartup = await pendingStartup.startup; - if (!this.sessions.has(sessionId) - || this.sessionIsClosing(sessionId) - || this.pendingMcpStartupSessions.get(sessionId) !== pendingStartup) { - return; - } - await this.publishMcpStartupStatus(sessionId, mcpStartup, pendingStartup.abort.signal, pendingStartup.requestedServers); - } catch (err) { - if (!pendingStartup.abort.signal.aborted) { - logger.error(`Failed to publish MCP startup status for session ${sessionId}`, err); - } - } finally { - if (this.pendingMcpStartupSessions.get(sessionId) === pendingStartup) { - this.pendingMcpStartupSessions.delete(sessionId); - } - } - } - - private async publishMcpStartupStatus( - sessionId: string, - mcpStartup: McpStartupResult, - signal: AbortSignal, - requestedServers?: Set - ): Promise { - const filteredStartup = requestedServers - ? { - ready: mcpStartup.ready.filter(server => requestedServers.has(server)), - failed: mcpStartup.failed.filter(server => requestedServers.has(server.server)), - cancelled: mcpStartup.cancelled.filter(server => requestedServers.has(server)), - } - : mcpStartup; - - const failuresAfterOauth: typeof filteredStartup.failed = []; - const readyAfterOauth = [...filteredStartup.ready]; - for (const failure of filteredStartup.failed) { - const signIn = failure.failureReason === "reauthenticationRequired" - ? await this.mcpServerSignIn(sessionId, failure.server, signal) - : "unsupported"; - if (signIn === "signedIn") { - readyAfterOauth.push(failure.server); - } else { - failuresAfterOauth.push(failure); - } - } - if (signal.aborted) { - return; - } - - const renderer = new AcpToolCallRenderer(this.capabilities); - for (const facts of McpStartupReporter.failures({ - ...filteredStartup, - ready: readyAfterOauth, - failed: failuresAfterOauth, - })) { - await this.connection.notify(acp.methods.client.session.update, { - sessionId, - update: renderer.render(facts), - }); - } - } - private trackActivePrompt(sessionId: string): ActivePrompt { let resolveCompletion: () => void = () => {}; const completion = new Promise((resolve) => { @@ -3473,47 +3364,6 @@ export class CodexAcpServer { } } -function getRequestedMcpServerNames(mcpServers: Array): Array { - return Array.from(new Set(mcpServers.map(server => sanitizeMcpServerName(server.name)))); -} - -const MCP_STARTUP_AWAIT_TIMEOUT_META_KEY = "mcpStartupAwaitTimeoutMs"; - -function parseMcpStartupAwaitTimeoutMs(meta: Record | null | undefined): number | undefined { - const value = meta?.[MCP_STARTUP_AWAIT_TIMEOUT_META_KEY]; - return typeof value === "number" && Number.isFinite(value) ? value : undefined; -} - -// Resolves once `startup` settles, or once `timeoutMs` elapses, whichever comes first. -// A startup rejection is only propagated if it happens before the timeout. -function raceMcpStartupTimeout(startup: Promise, timeoutMs: number): Promise { - return new Promise((resolve, reject) => { - let settled = false; - const timer = setTimeout(() => { - if (!settled) { - settled = true; - resolve(); - } - }, timeoutMs); - startup.then( - () => { - if (!settled) { - settled = true; - clearTimeout(timer); - resolve(); - } - }, - (err) => { - if (!settled) { - settled = true; - clearTimeout(timer); - reject(err); - } - }, - ); - }); -} - /** A close of the session stopped the read of its history during `session/load`. */ class SessionClosedDuringLoadError extends Error { constructor() { diff --git a/src/mcp/McpSessionStartup.ts b/src/mcp/McpSessionStartup.ts new file mode 100644 index 000000000..8179f328f --- /dev/null +++ b/src/mcp/McpSessionStartup.ts @@ -0,0 +1,243 @@ +import * as acp from "@agentclientprotocol/sdk"; +import type {AcpClientConnection} from "../ACPSessionConnection"; +import type {CodexAcpClient} from "../CodexAcpClient"; +import {logger} from "../Logger"; +import {sanitizeMcpServerName} from "../McpServerName"; +import {AcpToolCallRenderer} from "../tool-calls/AcpToolCallRenderer"; +import type {ClientCapabilities} from "../tool-calls/ClientCapabilities"; +import {McpStartupReporter} from "../tool-calls/reporters/McpStartupReporter"; +import type {McpServerSignIn} from "./McpServerSignIn"; +import type {McpStartupResult} from "./McpStartupTracker"; + +interface PendingMcpStartupSession { + requestedServers: Set; + startup: Promise; + /** Stops the startup wait and the sign-in of the startup report. */ + abort: AbortController; +} + +/** The options of {@link McpSessionStartup.begin}. */ +export interface McpSessionStartupOptions { + /** When false, the session gets no startup report, and nothing waits for the rest of the startup. */ + publish: boolean; + /** When positive, `begin` waits for the startup at most this time. */ + awaitTimeoutMs?: number | undefined; +} + +/** + * Reports the MCP server startup of a session. The report shows the servers that failed to start. + * Before the report, it signs in to each server that needs authentication. + */ +export class McpSessionStartup { + private readonly connection: AcpClientConnection; + private readonly codexAcpClient: () => CodexAcpClient; + private readonly runWithProcessCheck: (operation: () => Promise) => Promise; + private readonly signIn: McpServerSignIn; + private readonly capabilities: () => ClientCapabilities; + /** True when the session is open and does not close. */ + private readonly isSessionOpen: (sessionId: string) => boolean; + private readonly pendingSessions = new Map(); + + constructor( + connection: AcpClientConnection, + codexAcpClient: () => CodexAcpClient, + runWithProcessCheck: (operation: () => Promise) => Promise, + signIn: McpServerSignIn, + capabilities: () => ClientCapabilities, + isSessionOpen: (sessionId: string) => boolean, + ) { + this.connection = connection; + this.codexAcpClient = codexAcpClient; + this.runWithProcessCheck = runWithProcessCheck; + this.signIn = signIn; + this.capabilities = capabilities; + this.isSessionOpen = isSessionOpen; + } + + /** + * Starts the startup wait of the session and publishes the report when the startup ends. + * It returns a promise only when it waits for the startup, and returns null otherwise. + * The promise rejects when the startup fails before `awaitTimeoutMs`. Then the session has no pending startup. + */ + begin( + sessionId: string, + mcpServers: Array, + afterVersion: number, + options: McpSessionStartupOptions, + ): Promise | null { + const pendingStartup = this.createPendingSession(sessionId, mcpServers, afterVersion); + if (options.publish) { + this.pendingSessions.set(sessionId, pendingStartup); + } + const awaitTimeoutMs = options.awaitTimeoutMs; + if (awaitTimeoutMs !== undefined && awaitTimeoutMs > 0) { + return this.awaitStartup(sessionId, pendingStartup, awaitTimeoutMs, options.publish); + } + this.publishOrAbort(sessionId, pendingStartup, options.publish); + return null; + } + + /** Stops the startup wait and the sign-in of the session. */ + close(sessionId: string): void { + this.pendingSessions.get(sessionId)?.abort.abort(); + this.pendingSessions.delete(sessionId); + } + + isPending(sessionId: string): boolean { + return this.pendingSessions.has(sessionId); + } + + private async awaitStartup( + sessionId: string, + pendingStartup: PendingMcpStartupSession, + awaitTimeoutMs: number, + publish: boolean, + ): Promise { + try { + await raceMcpStartupTimeout(pendingStartup.startup, awaitTimeoutMs); + } catch (err) { + if (this.pendingSessions.get(sessionId) === pendingStartup) { + this.pendingSessions.delete(sessionId); + } + throw err; + } + this.publishOrAbort(sessionId, pendingStartup, publish); + } + + private publishOrAbort(sessionId: string, pendingStartup: PendingMcpStartupSession, publish: boolean): void { + if (publish) { + this.publishAsync(sessionId); + } else { + pendingStartup.abort.abort(); + } + } + + private publishAsync(sessionId: string): void { + void this.doPublish(sessionId); + } + + private createPendingSession( + sessionId: string, + mcpServers: Array, + afterVersion: number, + ): PendingMcpStartupSession { + const requestedServers = new Set(getRequestedMcpServerNames(mcpServers)); + const abort = new AbortController(); + const startup = this.runWithProcessCheck(() => this.codexAcpClient().awaitMcpServerStartup( + Array.from(requestedServers), + afterVersion, + {threadId: sessionId, signal: abort.signal}, + )); + // An abort can reject the startup before a caller awaits it. + void startup.catch(() => {}); + return {requestedServers, startup, abort}; + } + + private async doPublish(sessionId: string): Promise { + const pendingStartup = this.pendingSessions.get(sessionId); + if (!pendingStartup) { + return; + } + + try { + const mcpStartup = await pendingStartup.startup; + if (!this.isSessionOpen(sessionId) + || this.pendingSessions.get(sessionId) !== pendingStartup) { + return; + } + await this.publish(sessionId, mcpStartup, pendingStartup.abort.signal, pendingStartup.requestedServers); + } catch (err) { + if (!pendingStartup.abort.signal.aborted) { + logger.error(`Failed to publish MCP startup status for session ${sessionId}`, err); + } + } finally { + if (this.pendingSessions.get(sessionId) === pendingStartup) { + this.pendingSessions.delete(sessionId); + } + } + } + + private async publish( + sessionId: string, + mcpStartup: McpStartupResult, + signal: AbortSignal, + requestedServers?: Set + ): Promise { + const filteredStartup = requestedServers + ? { + ready: mcpStartup.ready.filter(server => requestedServers.has(server)), + failed: mcpStartup.failed.filter(server => requestedServers.has(server.server)), + cancelled: mcpStartup.cancelled.filter(server => requestedServers.has(server)), + } + : mcpStartup; + + const failuresAfterOauth: typeof filteredStartup.failed = []; + const readyAfterOauth = [...filteredStartup.ready]; + for (const failure of filteredStartup.failed) { + const signIn = failure.failureReason === "reauthenticationRequired" + ? await this.signIn(sessionId, failure.server, signal) + : "unsupported"; + if (signIn === "signedIn") { + readyAfterOauth.push(failure.server); + } else { + failuresAfterOauth.push(failure); + } + } + if (signal.aborted) { + return; + } + + const renderer = new AcpToolCallRenderer(this.capabilities()); + for (const facts of McpStartupReporter.failures({ + ...filteredStartup, + ready: readyAfterOauth, + failed: failuresAfterOauth, + })) { + await this.connection.notify(acp.methods.client.session.update, { + sessionId, + update: renderer.render(facts), + }); + } + } +} + +export function getRequestedMcpServerNames(mcpServers: Array): Array { + return Array.from(new Set(mcpServers.map(server => sanitizeMcpServerName(server.name)))); +} + +const MCP_STARTUP_AWAIT_TIMEOUT_META_KEY = "mcpStartupAwaitTimeoutMs"; + +export function parseMcpStartupAwaitTimeoutMs(meta: Record | null | undefined): number | undefined { + const value = meta?.[MCP_STARTUP_AWAIT_TIMEOUT_META_KEY]; + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + +// Resolves once `startup` settles, or once `timeoutMs` elapses, whichever comes first. +// A startup rejection is only propagated if it happens before the timeout. +function raceMcpStartupTimeout(startup: Promise, timeoutMs: number): Promise { + return new Promise((resolve, reject) => { + let settled = false; + const timer = setTimeout(() => { + if (!settled) { + settled = true; + resolve(); + } + }, timeoutMs); + startup.then( + () => { + if (!settled) { + settled = true; + clearTimeout(timer); + resolve(); + } + }, + (err) => { + if (!settled) { + settled = true; + clearTimeout(timer); + reject(err); + } + }, + ); + }); +} From 2a3f2f2fee777f3b7683bebf8f9a6a26528ddd2a Mon Sep 17 00:00:00 2001 From: Nikita Ashikhmin Date: Fri, 2 Oct 2026 01:05:51 +0400 Subject: [PATCH 05/22] docs: list the skillPath key of a skill command for AIR The AIR keys table and the presentation hints now state _meta.jetbrains.air.skillPath. The test also checks that an AIR client gets the path. --- docs/air-extensions.md | 3 +++ src/__tests__/CodexACPAgent/CodexAcpClient.test.ts | 11 ++++++++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/docs/air-extensions.md b/docs/air-extensions.md index 67c58aef2..bc45847f3 100644 --- a/docs/air-extensions.md +++ b/docs/air-extensions.md @@ -187,6 +187,7 @@ The adapter sends these keys only to AIR. "AIR" in the gate column means that th | `phase` | `agent_message_chunk._meta.jetbrains.air` | Codex message phase string | AIR | | `kind` | session mode `_meta.jetbrains.air` and `mode` config option value `_meta.jetbrains.air` | `standard`, `auto_review`, or `full_access` | AIR | | `commandAction` | available command `_meta.jetbrains.air` | command action object | AIR | +| `skillPath` | available command `_meta.jetbrains.air` of a `$skill` command | absolute path of the skill's `SKILL.md` | AIR | | `customAnswer` | `request_user_input` note field of an elicitation schema, `_meta.jetbrains.air` | `true` | AIR. The same field also carries the root key `_meta._askUserQuestionCustomAnswer: true`, because released AIR versions read only that key. | ## JetBrains shared keys @@ -992,6 +993,8 @@ The adapter sends these keys only to AIR: - An available command can carry `_meta.jetbrains.air.commandAction`: - `/plan` has `{kind: "setConfigOption", configId, value, resetValue, presentation: "state"}`. It switches the collaboration mode to plan. - `/goal` has `{kind: "prefixPrompt", presentation: "state"}`. +- A `$skill` command carries `_meta.jetbrains.air.skillPath`, the absolute path of its `SKILL.md` from `skills/list`. + AIR opens this file on a click on the skill chip. ## Removed keys diff --git a/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts b/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts index 266e3c608..e1930eb0a 100644 --- a/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts +++ b/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts @@ -1805,7 +1805,7 @@ describe('ACP server test', { timeout: 40_000 }, () => { await expect(mockFixture.getAcpConnectionDump([])).toMatchFileSnapshot("data/available-commands-skills.json"); }); - it('sends the skill path only to AIR', async () => { + it('sends the skill path to AIR and not to other clients', async () => { const mockFixture = createCodexMockTestFixture(); const codexAcpAgent = mockFixture.getCodexAcpAgent(); @@ -1835,6 +1835,15 @@ describe('ACP server test', { timeout: 40_000 }, () => { .find(event => event.method === "sessionUpdate" && event.args[0].update.sessionUpdate === "available_commands_update"); const skill = update?.args[0].update.availableCommands.find((command: acp.AvailableCommand) => command.name === "$build"); expect(skill).toEqual({name: "$build", description: "Build the project", input: null}); + + // @ts-expect-error - exercising private helper + await codexAcpAgent.availableCommands.publish(createTestSessionState({sessionId: "air-session-id", cwd: "/workspace"})); + + const airUpdate = mockFixture.getAcpConnectionEvents([]) + .filter(event => event.method === "sessionUpdate" && event.args[0].update.sessionUpdate === "available_commands_update") + .at(-1); + const airSkill = airUpdate?.args[0].update.availableCommands.find((command: acp.AvailableCommand) => command.name === "$build"); + expect(airSkill?._meta).toEqual({jetbrains: {air: {version: 1, skillPath: "/workspace/.agents/skills/build/SKILL.md"}}}); }); it('publishes the commands again after a turn only when the skills changed', async () => { From cea952118094bd3108b1b7118ae1fda8d5b13f6f Mon Sep 17 00:00:00 2001 From: Nikita Ashikhmin Date: Fri, 2 Oct 2026 01:13:42 +0400 Subject: [PATCH 06/22] fix: keep more of the MCP error text and limit the cleanup time Cap the raw error at 2000 characters, so that a long error does not block the event loop. Keep an IPv6 address, a quoted value, and a short non-Rust path such as Foo::Bar. Keep an HTTP status. Drop only a JSON-RPC code. Do not end a sentence after a list number or etc. Put a server name with a line break on one line. --- src/__tests__/McpStatusMarkdown.test.ts | 35 +++++++++++++++++++ src/mcp/McpStatusMarkdown.ts | 45 +++++++++++++++++++------ 2 files changed, 70 insertions(+), 10 deletions(-) diff --git a/src/__tests__/McpStatusMarkdown.test.ts b/src/__tests__/McpStatusMarkdown.test.ts index b723cb163..3d2dc8804 100644 --- a/src/__tests__/McpStatusMarkdown.test.ts +++ b/src/__tests__/McpStatusMarkdown.test.ts @@ -86,6 +86,36 @@ describe("cleanErrorMessage", () => { expect(cleanErrorMessage("Bad flag, e.g. --foo is unknown. Use i.e. the default. Third sentence.")) .toBe("Bad flag, e.g. --foo is unknown. Use i.e. the default."); }); + + it("does not end a sentence at a list number or at etc.", () => { + expect(cleanErrorMessage("Step 1. Open it! Then ()")).toBe("Step 1. Open it! Then ()"); + expect(cleanErrorMessage("Bad flags a, b, etc. are unknown. Use the defaults. Third sentence.")) + .toBe("Bad flags a, b, etc. are unknown. Use the defaults."); + }); + + it("cleans a long error with many segments fast", () => { + const text = Array.from({length: 20000}, (_, index) => `part ${index} x`).join(": "); + const start = performance.now(); + + const cleaned = cleanErrorMessage(text); + + expect(performance.now() - start).toBeLessThan(500); + expect(cleaned.startsWith("part 0 x: part 1 x")).toBe(true); + expect(cleaned.endsWith("…")).toBe(true); + }); + + it("keeps the text with :: that is not a Rust type path", () => { + expect(cleanErrorMessage("connect to fe80::abcd failed")).toBe("connect to fe80::abcd failed"); + expect(cleanErrorMessage("bad value 'a::b' in config")).toBe("bad value 'a::b' in config"); + expect(cleanErrorMessage("Can't locate Foo::Bar in @INC")).toBe("Can't locate Foo::Bar in @INC"); + }); + + it("keeps an HTTP status and removes a JSON-RPC code", () => { + expect(cleanErrorMessage("server returned status: 401")).toBe("server returned status: 401"); + expect(cleanErrorMessage("HTTP error: 503")).toBe("HTTP error: 503"); + expect(cleanErrorMessage("JSON-RPC error: 401: Unauthorized")).toBe("Unauthorized"); + expect(cleanErrorMessage("request failed: -32601: Method not found")).toBe("request failed: Method not found"); + }); }); describe("formatStatus", () => { @@ -227,4 +257,9 @@ describe("inlineCode", () => { it("shows an empty name as unnamed", () => { expect(inlineCode("")).toBe("(unnamed)"); }); + + it("puts a name with a line break on one line", () => { + expect(inlineCode("a\n# H")).toBe("`a # H`"); + expect(formatStatus([entry({name: "a\n# H", status: null})], new Map())).toContain("\n- `a # H`"); + }); }); diff --git a/src/mcp/McpStatusMarkdown.ts b/src/mcp/McpStatusMarkdown.ts index 8c99f8cbe..fdb2735d4 100644 --- a/src/mcp/McpStatusMarkdown.ts +++ b/src/mcp/McpStatusMarkdown.ts @@ -140,26 +140,45 @@ const MAX_ERROR_LENGTH = 160; const MAX_ERROR_SENTENCES = 2; +/** + * The maximum length of the raw error text that the cleanup reads. + * The cleanup compares each segment with the earlier ones, so a long text without a limit blocks the event loop. + */ +const MAX_INPUT_LENGTH = 2000; + +const JSON_RPC_WRAPPER = /^json-rpc error$/i; + +const NUMERIC_CODE = /^-?\d+$/; + /** The error chain segments that only wrap the real message. */ const WRAPPER_SEGMENTS = [ /^tool discovery failed$/i, /^mcp startup failed$/i, /^handshaking with mcp server failed$/i, - /^json-rpc error$/i, + JSON_RPC_WRAPPER, /^startup error$/i, /^error$/i, /^mcp client for .+ failed to start$/i, - /^-?\d+$/, + // A JSON-RPC error code. An HTTP status, such as `401`, stays. + /^-32\d{3}$/, ]; +const IDENTIFIER = String.raw`[A-Za-z_]\w*`; + /** - * Finds a Rust type path, such as `crate::module::Type`, with an optional `Transport [` prefix. - * The lookbehind skips an IPv6 address, such as `[::1]` or `[fe80::1]`, and a URL. + * Finds a Rust type path, such as `crate::module::Type`. + * A path in a `Transport [` prefix can have two parts. Other paths need three parts or generic arguments. + * Thus a short path of another language, such as the Perl `Foo::Bar`, and an IPv6 address, such as `fe80::abcd`, stay. + * The lookbehind skips an IPv6 address in a URL, such as `[fe80::1]`, and a quoted value, such as `'a::b'`. */ -const TYPE_PATH = /(?:\b[A-Z]\w*\s*\[\s*|(? pattern.test(segment))) { + // The code after a `JSON-RPC error` wrapper is a JSON-RPC code, also outside the reserved range. + const isJsonRpcCode = afterJsonRpcWrapper && NUMERIC_CODE.test(segment); + afterJsonRpcWrapper = JSON_RPC_WRAPPER.test(segment); + if (segment.length === 0 || isJsonRpcCode || WRAPPER_SEGMENTS.some(pattern => pattern.test(segment))) { continue; } const withoutEllipsis = withoutTrailingEllipsis(segment); @@ -264,10 +287,12 @@ export const UNNAMED_SERVER = "(unnamed)"; /** * Formats a server name as inline code. The fence is one backtick longer than the longest backtick run in the name. + * Each whitespace run becomes one space, so that a line break in the name cannot start a new markdown block. * A name that starts or ends with a backtick or a space gets a space on each side, so that the markdown keeps it as is. * An empty name shows as `(unnamed)`. */ -export function inlineCode(text: string): string { +export function inlineCode(name: string): string { + const text = name.replace(/\s+/g, " "); if (text.length === 0) { return UNNAMED_SERVER; } From 4771d51ce6c59a82ed66699b0a21f8360ad885b4 Mon Sep 17 00:00:00 2001 From: Nikita Ashikhmin Date: Fri, 2 Oct 2026 01:16:33 +0400 Subject: [PATCH 07/22] refactor: simplify the MCP session startup wait Make the requested servers of publish required and drop the unused unfiltered branch. Return the timeout race promise itself, so that the caller resumes at the same microtask as before. --- src/mcp/McpSessionStartup.ts | 49 +++++++++++++++--------------------- 1 file changed, 20 insertions(+), 29 deletions(-) diff --git a/src/mcp/McpSessionStartup.ts b/src/mcp/McpSessionStartup.ts index 8179f328f..1804e8d44 100644 --- a/src/mcp/McpSessionStartup.ts +++ b/src/mcp/McpSessionStartup.ts @@ -71,7 +71,13 @@ export class McpSessionStartup { } const awaitTimeoutMs = options.awaitTimeoutMs; if (awaitTimeoutMs !== undefined && awaitTimeoutMs > 0) { - return this.awaitStartup(sessionId, pendingStartup, awaitTimeoutMs, options.publish); + const startupWait = raceMcpStartupTimeout(pendingStartup.startup, awaitTimeoutMs); + // These handlers run before the caller resumes, because the caller awaits the same promise after them. + void startupWait.then( + () => this.publishOrAbort(sessionId, pendingStartup, options.publish), + () => this.forget(sessionId, pendingStartup), + ); + return startupWait; } this.publishOrAbort(sessionId, pendingStartup, options.publish); return null; @@ -87,23 +93,6 @@ export class McpSessionStartup { return this.pendingSessions.has(sessionId); } - private async awaitStartup( - sessionId: string, - pendingStartup: PendingMcpStartupSession, - awaitTimeoutMs: number, - publish: boolean, - ): Promise { - try { - await raceMcpStartupTimeout(pendingStartup.startup, awaitTimeoutMs); - } catch (err) { - if (this.pendingSessions.get(sessionId) === pendingStartup) { - this.pendingSessions.delete(sessionId); - } - throw err; - } - this.publishOrAbort(sessionId, pendingStartup, publish); - } - private publishOrAbort(sessionId: string, pendingStartup: PendingMcpStartupSession, publish: boolean): void { if (publish) { this.publishAsync(sessionId); @@ -112,6 +101,12 @@ export class McpSessionStartup { } } + private forget(sessionId: string, pendingStartup: PendingMcpStartupSession): void { + if (this.pendingSessions.get(sessionId) === pendingStartup) { + this.pendingSessions.delete(sessionId); + } + } + private publishAsync(sessionId: string): void { void this.doPublish(sessionId); } @@ -151,9 +146,7 @@ export class McpSessionStartup { logger.error(`Failed to publish MCP startup status for session ${sessionId}`, err); } } finally { - if (this.pendingSessions.get(sessionId) === pendingStartup) { - this.pendingSessions.delete(sessionId); - } + this.forget(sessionId, pendingStartup); } } @@ -161,15 +154,13 @@ export class McpSessionStartup { sessionId: string, mcpStartup: McpStartupResult, signal: AbortSignal, - requestedServers?: Set + requestedServers: Set, ): Promise { - const filteredStartup = requestedServers - ? { - ready: mcpStartup.ready.filter(server => requestedServers.has(server)), - failed: mcpStartup.failed.filter(server => requestedServers.has(server.server)), - cancelled: mcpStartup.cancelled.filter(server => requestedServers.has(server)), - } - : mcpStartup; + const filteredStartup = { + ready: mcpStartup.ready.filter(server => requestedServers.has(server)), + failed: mcpStartup.failed.filter(server => requestedServers.has(server.server)), + cancelled: mcpStartup.cancelled.filter(server => requestedServers.has(server)), + }; const failuresAfterOauth: typeof filteredStartup.failed = []; const readyAfterOauth = [...filteredStartup.ready]; From 9dda6aeab404b150f6399dc50a471b026cec97f1 Mon Sep 17 00:00:00 2001 From: Nikita Ashikhmin Date: Fri, 2 Oct 2026 01:16:33 +0400 Subject: [PATCH 08/22] test: kill the fake Codex process after the compact exit test --- src/__tests__/CodexACPAgent/compact-command-lifecycle.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/__tests__/CodexACPAgent/compact-command-lifecycle.test.ts b/src/__tests__/CodexACPAgent/compact-command-lifecycle.test.ts index 47d4d3bca..6bfba2148 100644 --- a/src/__tests__/CodexACPAgent/compact-command-lifecycle.test.ts +++ b/src/__tests__/CodexACPAgent/compact-command-lifecycle.test.ts @@ -108,6 +108,7 @@ describe("compact command lifecycle", () => { it.skipIf(process.platform === "win32")("rejects a compact wait when the Codex process exits", async () => { // The real connection path: the process exit disposes the vscode-jsonrpc connection and fires no close event. const dir = mkdtempSync(path.join(tmpdir(), "codex-acp-compact-exit-")); + let codex: ReturnType | undefined; try { const fakeCodex = path.join(dir, "codex"); // The fake Codex acknowledges thread/compact/start and then never completes the compaction. @@ -120,7 +121,7 @@ describe("compact command lifecycle", () => { "", ].join("\n")); chmodSync(fakeCodex, 0o755); - const codex = startCodexConnection(fakeCodex); + codex = startCodexConnection(fakeCodex); const appServer = new CodexAppServerClient(codex.connection); const start = vi.spyOn(appServer, "threadCompactStart"); @@ -130,6 +131,7 @@ describe("compact command lifecycle", () => { await expect(pending).rejects.toThrow("Codex connection closed during compaction."); } finally { + codex?.process.kill(); rmSync(dir, {recursive: true, force: true}); } }); From ff13488a3672384a0a337b16826ea2a537ae008e Mon Sep 17 00:00:00 2001 From: Nikita Ashikhmin Date: Fri, 2 Oct 2026 01:17:54 +0400 Subject: [PATCH 09/22] test: use a SKILL.md path in the skill command fixture Note in the docs that skillPath is an OS-native absolute path, not a URI. --- docs/air-extensions.md | 3 ++- src/__tests__/CodexACPAgent/CodexAcpClient.test.ts | 2 +- .../CodexACPAgent/data/available-commands-skills.json | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/air-extensions.md b/docs/air-extensions.md index bc45847f3..79f4a5935 100644 --- a/docs/air-extensions.md +++ b/docs/air-extensions.md @@ -187,7 +187,7 @@ The adapter sends these keys only to AIR. "AIR" in the gate column means that th | `phase` | `agent_message_chunk._meta.jetbrains.air` | Codex message phase string | AIR | | `kind` | session mode `_meta.jetbrains.air` and `mode` config option value `_meta.jetbrains.air` | `standard`, `auto_review`, or `full_access` | AIR | | `commandAction` | available command `_meta.jetbrains.air` | command action object | AIR | -| `skillPath` | available command `_meta.jetbrains.air` of a `$skill` command | absolute path of the skill's `SKILL.md` | AIR | +| `skillPath` | available command `_meta.jetbrains.air` of a `$skill` command | OS-native absolute path of the skill's `SKILL.md`, not a URI | AIR | | `customAnswer` | `request_user_input` note field of an elicitation schema, `_meta.jetbrains.air` | `true` | AIR. The same field also carries the root key `_meta._askUserQuestionCustomAnswer: true`, because released AIR versions read only that key. | ## JetBrains shared keys @@ -994,6 +994,7 @@ The adapter sends these keys only to AIR: - `/plan` has `{kind: "setConfigOption", configId, value, resetValue, presentation: "state"}`. It switches the collaboration mode to plan. - `/goal` has `{kind: "prefixPrompt", presentation: "state"}`. - A `$skill` command carries `_meta.jetbrains.air.skillPath`, the absolute path of its `SKILL.md` from `skills/list`. + The path is an OS-native absolute path, such as `C:\repo\SKILL.md` on Windows. It is not a URI. AIR opens this file on a click on the skill chip. ## Removed keys diff --git a/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts b/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts index e1930eb0a..5551e7b68 100644 --- a/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts +++ b/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts @@ -1782,7 +1782,7 @@ describe('ACP server test', { timeout: 40_000 }, () => { name: "build", description: "Build the project", shortDescription: "Build", - path: "/workspace", + path: "/workspace/.agents/skills/build/SKILL.md", scope: "user", enabled: true, pluginId: null diff --git a/src/__tests__/CodexACPAgent/data/available-commands-skills.json b/src/__tests__/CodexACPAgent/data/available-commands-skills.json index 529bcd213..36aabf5c6 100644 --- a/src/__tests__/CodexACPAgent/data/available-commands-skills.json +++ b/src/__tests__/CodexACPAgent/data/available-commands-skills.json @@ -106,7 +106,7 @@ "jetbrains": { "air": { "version": 1, - "skillPath": "/workspace" + "skillPath": "/workspace/.agents/skills/build/SKILL.md" } } } From acec35ea2080f22a86921913801dfd1adcf03ef1 Mon Sep 17 00:00:00 2001 From: Nikita Ashikhmin Date: Fri, 2 Oct 2026 10:10:04 +0400 Subject: [PATCH 10/22] perf: read the next history page while the adapter sends a page A session load now requests the next item page before it sends the current page. It reads at most one page ahead, so at most two pages are in memory. It does not read a page after the resume boundary. Each page keeps its 60 s timeout, and the close check stops the load as before. A turn page search still reads one page at a time. Measured with a local Codex app-server and real sessions, median of 4 runs: - 180 MB session: the load response goes from 486 ms to 382 ms. - 287 MB session: the load response goes from 566 ms to 442 ms. - 2.1 GB session: the load response goes from 5043 ms to 4572 ms. - 2.1 GB session with AIR capabilities: from 4276 ms to 3696 ms. The time to the first history update does not change. The history updates are byte-equal to the updates before the change. The peak adapter memory grows by up to about 165 MB on the 2.1 GB session. --- src/CodexAppServerClient.ts | 29 ++++++++++++++----- .../CodexACPAgent/load-session.test.ts | 9 ++++-- .../CodexACPAgent/thread-history.test.ts | 24 +++++++++++++++ 3 files changed, 52 insertions(+), 10 deletions(-) diff --git a/src/CodexAppServerClient.ts b/src/CodexAppServerClient.ts index 19a881208..c0aebb575 100644 --- a/src/CodexAppServerClient.ts +++ b/src/CodexAppServerClient.ts @@ -665,7 +665,7 @@ export class CodexAppServerClient { cursor, limit: HISTORY_PAGE_ITEMS, 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); @@ -1213,23 +1213,38 @@ function extractTurnRouting(notification: ServerNotification): { threadId: strin /** * The pages of a history list, from the first page. A page request fails when Codex does not answer * within {@link HISTORY_PAGE_TIMEOUT_MS}. The read fails when Codex returns a cursor a second time. + * + * With `endsHistory`, the next page is requested while the caller uses a page, so at most two pages are in memory. + * No page is requested after the page that `endsHistory` accepts. Without it, a page is requested when the caller asks. */ async function* historyPages( threadId: string, method: string, requestPage: (cursor: string | null) => Promise<{data: T[]; nextCursor: string | null}>, + endsHistory?: (page: T[]) => boolean, ): AsyncGenerator { + type Page = {data: T[]; nextCursor: string | null}; + const request = (cursor: string | null): Promise => { + const page = withHistoryTimeout(requestPage(cursor), method, threadId); + // A caller that stops early never reads the next page. Its failure is not an unhandled rejection. + page.catch(() => {}); + return page; + }; const seenCursors = new Set(); let cursor: string | null = null; + let next: Promise | null = null; do { - const page: {data: T[]; nextCursor: string | null} = await withHistoryTimeout(requestPage(cursor), method, threadId); - yield page.data; + const page: Page = await (next ?? request(cursor)); + next = null; cursor = page.nextCursor; - if (cursor !== null) { - if (seenCursors.has(cursor)) { - throw new Error("Codex returned a repeated thread history cursor"); - } + const repeated = cursor !== null && seenCursors.has(cursor); + if (cursor !== null && !repeated) { seenCursors.add(cursor); + if (endsHistory && !endsHistory(page.data)) next = request(cursor); + } + yield page.data; + if (repeated) { + throw new Error("Codex returned a repeated thread history cursor"); } } while (cursor !== null); } diff --git a/src/__tests__/CodexACPAgent/load-session.test.ts b/src/__tests__/CodexACPAgent/load-session.test.ts index ca517a47b..89a565937 100644 --- a/src/__tests__/CodexACPAgent/load-session.test.ts +++ b/src/__tests__/CodexACPAgent/load-session.test.ts @@ -592,12 +592,14 @@ describe("CodexACPAgent - loadSession", () => { appServer.threadItemsList = vi.fn() .mockResolvedValueOnce({data: [message("last")], nextCursor: null, backwardsCursor: null}) .mockResolvedValueOnce({data: [message("first")], nextCursor: "page-2", backwardsCursor: null}) + .mockResolvedValueOnce({data: [message("second")], nextCursor: "page-3", backwardsCursor: null}) + // The adapter reads this page while it sends the page before it. .mockImplementationOnce(async () => { closed = agent.closeSession({sessionId: "session-1"}); await closed; - return {data: [message("second")], nextCursor: "page-3", backwardsCursor: null}; + return {data: [message("third")], nextCursor: "page-4", backwardsCursor: null}; }) - .mockResolvedValue({data: [message("more")], nextCursor: "page-3", backwardsCursor: null}); + .mockResolvedValue({data: [message("more")], nextCursor: "page-4", backwardsCursor: null}); const closeSpy = vi.spyOn(client, "closeSession").mockResolvedValue(undefined as never); await agent.initialize({protocolVersion: 1}); @@ -606,13 +608,14 @@ describe("CodexACPAgent - loadSession", () => { await closed; // The page that the close interrupted is the last page read. The load does not close the session again. - expect(appServer.threadItemsList).toHaveBeenCalledTimes(3); + expect(appServer.threadItemsList).toHaveBeenCalledTimes(4); expect(closeSpy).toHaveBeenCalledTimes(1); const texts = JSON.stringify(fixture.getAcpConnectionEvents([]) .filter(event => event.method === "sessionUpdate") .map(event => event.args[0])); expect(texts).toContain("first"); expect(texts).not.toContain("second"); + expect(texts).not.toContain("third"); }); it("should not recover session mcp servers during loadSession when request omits them", async () => { diff --git a/src/__tests__/CodexACPAgent/thread-history.test.ts b/src/__tests__/CodexACPAgent/thread-history.test.ts index 717909090..8451d4c88 100644 --- a/src/__tests__/CodexACPAgent/thread-history.test.ts +++ b/src/__tests__/CodexACPAgent/thread-history.test.ts @@ -160,6 +160,30 @@ describe("paginated thread history", () => { } }); + it("requests the next item page while the caller uses a page, and no page after the resume boundary", async () => { + const fixture = createCodexMockTestFixture(); + const appServer = fixture.getCodexAppServerClient(); + const turn = messageTurn("large"); + turn.items = Array.from({length: 9}, (_, index) => ({...turn.items[1]!, id: `message-${index}`})); + const pages = vi.spyOn(appServer, "threadItemsList").mockImplementation(itemStore([turn], 3)); + const requestedPages = () => pages.mock.calls.map(([params]) => `${params.sortDirection}:${params.cursor ?? "start"}`); + + const reader = appServer.threadItemPages("history", {lastItemCursor: "item:message-4"})[Symbol.asyncIterator](); + const first = await reader.next(); + // The caller holds the first page. The second page is already requested. + expect(requestedPages()).toEqual(["desc:item:message-4", "asc:start", "asc:asc:3"]); + const second = await reader.next(); + const end = await reader.next(); + + expect([first.value, second.value].map(page => page?.map((item: ThreadItem) => item.id))).toEqual([ + ["message-0", "message-1", "message-2"], + ["message-3", "message-4"], + ]); + expect(end.done).toBe(true); + // The second page holds the boundary item, so the third page is never requested. + expect(requestedPages()).toEqual(["desc:item:message-4", "asc:start", "asc:asc:3"]); + }); + it("pages the items of one large turn", async () => { const fixture = createCodexMockTestFixture(); const appServer = fixture.getCodexAppServerClient(); From 410ffbdf6fd3fbb84902b73095604251d254bd0a Mon Sep 17 00:00:00 2001 From: Nikita Ashikhmin Date: Fri, 2 Oct 2026 11:51:07 +0400 Subject: [PATCH 11/22] fix: cut a session title to 256 characters with an ellipsis The fallback title is the whole text of the first prompt. A large prompt made a 25023-character title, and the IntelliJ client ignored it. The history title, the thread name and the session/list title had no limit too. All four paths now use one normalizeSessionTitle with the same limit as claude-agent-acp. --- src/CodexAcpClient.ts | 3 +- src/CodexAcpServer.ts | 14 +++--- src/CodexEventHandler.ts | 5 ++- src/SessionTitle.ts | 16 +++++++ .../session-info-update-events.test.ts | 44 +++++++++++++++++++ src/__tests__/SessionTitle.test.ts | 30 +++++++++++++ 6 files changed, 100 insertions(+), 12 deletions(-) create mode 100644 src/SessionTitle.ts create mode 100644 src/__tests__/SessionTitle.test.ts diff --git a/src/CodexAcpClient.ts b/src/CodexAcpClient.ts index 8c62782f7..7d15a6c69 100644 --- a/src/CodexAcpClient.ts +++ b/src/CodexAcpClient.ts @@ -29,6 +29,7 @@ import {AgentMode} from "./AgentMode"; import path from "node:path"; import {logger} from "./Logger"; import {sanitizeMcpServerName} from "./McpServerName"; +import {normalizeSessionTitle} from "./SessionTitle"; import type { AccountLoginCompletedNotification, AccountUpdatedNotification, @@ -1201,7 +1202,7 @@ export class CodexAcpClient { const mapThreadToSession = (thread: Thread) => ({ sessionId: thread.id, cwd: thread.cwd, - title: (thread.name ?? thread.preview) || null, + title: normalizeSessionTitle(thread.name ?? thread.preview), updatedAt: new Date(thread.updatedAt * 1000).toISOString(), }); diff --git a/src/CodexAcpServer.ts b/src/CodexAcpServer.ts index a9e78627a..a8fb1dd05 100644 --- a/src/CodexAcpServer.ts +++ b/src/CodexAcpServer.ts @@ -34,6 +34,7 @@ import type { } from "./app-server/v2"; import type {RateLimitsMap} from "./RateLimitsMap"; import {ModelId} from "./ModelId"; +import {normalizeSessionTitle} from "./SessionTitle"; import {AgentMode, MODE_CONFIG_ID} from "./AgentMode"; import { COLLABORATION_MODE_CONFIG_ID, @@ -2223,7 +2224,7 @@ export class CodexAcpServer { thread: Thread, firstItems: ThreadItem[], ): Promise { - const explicitTitle = this.normalizeSessionTitle(thread.name); + const explicitTitle = normalizeSessionTitle(thread.name); if (explicitTitle) { sessionState.sessionTitle = explicitTitle; sessionState.sessionTitleSource = "explicit"; @@ -2236,14 +2237,14 @@ export class CodexAcpServer { } const historyTitle = this.findFirstUserMessageTitle(firstItems) - ?? this.normalizeSessionTitle(thread.preview); + ?? normalizeSessionTitle(thread.preview); await this.publishFallbackSessionTitle(sessionState, historyTitle); } private findFirstUserMessageTitle(items: ThreadItem[]): string | null { for (const item of items) { if (item.type !== "userMessage") continue; - const title = this.normalizeSessionTitle(item.content + const title = normalizeSessionTitle(item.content .filter((input): input is Extract => input.type === "text") .map(input => input.text) .join(" ")); @@ -2310,17 +2311,12 @@ export class CodexAcpServer { } private createPromptFallbackTitle(prompt: acp.ContentBlock[]): string | null { - return this.normalizeSessionTitle(prompt + return normalizeSessionTitle(prompt .filter((block): block is Extract => block.type === "text") .map(block => block.text) .join(" ")); } - private normalizeSessionTitle(title: string | null | undefined): string | null { - const normalized = title?.replace(/\s+/g, " ").trim() ?? ""; - return normalized.length > 0 ? normalized : null; - } - private async createHistoryUpdates(item: ThreadItem, sessionState: SessionState): Promise { const renderer = new AcpToolCallRenderer(sessionState.clientCapabilities); switch (item.type) { diff --git a/src/CodexEventHandler.ts b/src/CodexEventHandler.ts index 1d01b0d91..c3ce6fd61 100644 --- a/src/CodexEventHandler.ts +++ b/src/CodexEventHandler.ts @@ -9,6 +9,7 @@ import type { } from "./CodexAcpServer"; import {type PlanEntry, RequestError} from "@agentclientprotocol/sdk"; import {ACPSessionConnection, type AcpClientConnection, type UpdateSessionEvent} from "./ACPSessionConnection"; +import {normalizeSessionTitle} from "./SessionTitle"; import type { AccountRateLimitsUpdatedNotification, AccountUpdatedNotification, @@ -528,14 +529,14 @@ export class CodexEventHandler { case "thread/tokenUsage/updated": return this.createUsageUpdate(notification.params); case "thread/name/updated": - this.sessionState.sessionTitle = notification.params.threadName ?? null; + this.sessionState.sessionTitle = normalizeSessionTitle(notification.params.threadName); this.sessionState.sessionTitleSource = notification.params.threadName == null ? "unset" : "explicit"; this.sessionState.titleGen?.observeRename(); return { sessionUpdate: "session_info_update", - title: notification.params.threadName ?? null, + title: this.sessionState.sessionTitle, }; case "thread/status/changed": return this.createCodexSessionInfoUpdate({ diff --git a/src/SessionTitle.ts b/src/SessionTitle.ts new file mode 100644 index 000000000..33cb53162 --- /dev/null +++ b/src/SessionTitle.ts @@ -0,0 +1,16 @@ +/** The maximum length of a session title that the adapter publishes, the ellipsis included. */ +export const MAX_SESSION_TITLE_LENGTH = 256; + +/** + * Collapses the whitespace of [title] and cuts it to [MAX_SESSION_TITLE_LENGTH] characters with a trailing ellipsis. + * Returns `null` for a blank title. + */ +export function normalizeSessionTitle(title: string | null | undefined): string | null { + const normalized = title?.replace(/\s+/g, " ").trim() ?? ""; + if (normalized.length === 0) return null; + if (normalized.length <= MAX_SESSION_TITLE_LENGTH) return normalized; + let cut = normalized.slice(0, MAX_SESSION_TITLE_LENGTH - 1); + // Do not leave half of a surrogate pair before the ellipsis. + if (/[\uD800-\uDBFF]$/.test(cut)) cut = cut.slice(0, -1); + return `${cut.trimEnd()}…`; +} diff --git a/src/__tests__/CodexACPAgent/session-info-update-events.test.ts b/src/__tests__/CodexACPAgent/session-info-update-events.test.ts index fe12e4296..d63244522 100644 --- a/src/__tests__/CodexACPAgent/session-info-update-events.test.ts +++ b/src/__tests__/CodexACPAgent/session-info-update-events.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from "vitest"; import type { ServerNotification } from "../../app-server"; import { setupPromptTestSession } from "../acp-test-utils"; +import { MAX_SESSION_TITLE_LENGTH } from "../../SessionTitle"; describe("CodexEventHandler - session info updates", () => { const sessionId = "test-session-id"; @@ -24,6 +25,49 @@ describe("CodexEventHandler - session info updates", () => { ); }); + it("cuts a long fallback session title to the limit", async () => { + const { mockFixture } = setupPromptTestSession({ + sessionId, + sessionTitleSource: "unset", + }); + + await mockFixture.getCodexAcpAgent().prompt({ + sessionId, + prompt: [{ type: "text", text: "a".repeat(25_023) }], + }); + + expect(mockFixture.getAcpConnectionEvents([])).toContainEqual({ + method: "sessionUpdate", + args: [{ sessionId, update: { sessionUpdate: "session_info_update", title: `${"a".repeat(MAX_SESSION_TITLE_LENGTH - 1)}…` } }], + }); + }); + + it("cuts a long thread name to the limit", async () => { + const { mockFixture } = setupPromptTestSession({ sessionId }); + + await mockFixture.getCodexAcpAgent().prompt({ + sessionId, + prompt: [{ type: "text", text: "test" }], + }); + + mockFixture.clearAcpConnectionDump(); + + mockFixture.sendServerNotification({ + method: "thread/name/updated", + params: { + threadId: sessionId, + threadName: "b".repeat(1_000), + }, + }); + + await vi.waitFor(() => { + expect(mockFixture.getAcpConnectionEvents([])).toContainEqual({ + method: "sessionUpdate", + args: [{ sessionId, update: { sessionUpdate: "session_info_update", title: `${"b".repeat(MAX_SESSION_TITLE_LENGTH - 1)}…` } }], + }); + }); + }); + it("does not replace an explicit session title with the prompt fallback", async () => { const { mockFixture } = setupPromptTestSession({ sessionId, diff --git a/src/__tests__/SessionTitle.test.ts b/src/__tests__/SessionTitle.test.ts new file mode 100644 index 000000000..73b15b39a --- /dev/null +++ b/src/__tests__/SessionTitle.test.ts @@ -0,0 +1,30 @@ +import {describe, expect, it} from "vitest"; +import {MAX_SESSION_TITLE_LENGTH, normalizeSessionTitle} from "../SessionTitle"; + +describe("normalizeSessionTitle", () => { + it("collapses whitespace and returns null for a blank title", () => { + expect(normalizeSessionTitle(" Fix the flaky\n test ")).toBe("Fix the flaky test"); + expect(normalizeSessionTitle(" \n ")).toBeNull(); + expect(normalizeSessionTitle(null)).toBeNull(); + expect(normalizeSessionTitle(undefined)).toBeNull(); + }); + + it("keeps a title at the limit unchanged", () => { + const title = "a".repeat(MAX_SESSION_TITLE_LENGTH); + + expect(normalizeSessionTitle(title)).toBe(title); + }); + + it("cuts a long title to the limit with an ellipsis", () => { + const title = normalizeSessionTitle("a".repeat(25_023)); + + expect(title).toBe(`${"a".repeat(MAX_SESSION_TITLE_LENGTH - 1)}…`); + expect(title).toHaveLength(MAX_SESSION_TITLE_LENGTH); + }); + + it("does not split a surrogate pair at the cut", () => { + const title = normalizeSessionTitle(`${"a".repeat(MAX_SESSION_TITLE_LENGTH - 2)}😀😀`); + + expect(title).toBe(`${"a".repeat(MAX_SESSION_TITLE_LENGTH - 2)}…`); + }); +}); From d8ffe819de2a2e0cba021d5ff1eca9cac4123a4a Mon Sep 17 00:00:00 2001 From: Nikita Ashikhmin Date: Fri, 2 Oct 2026 12:45:28 +0400 Subject: [PATCH 12/22] perf: skip the large fields of a finished tool call report A tool call that starts with a final status keeps only its small fields. So the adapter no longer stringifies its content, raw input, raw output and _meta. A history replay starts most tool calls this way. Load of a 2.1 GB session, 8 runs, median (min): - AIR client: 2952 ms (2827) before, 2641 ms (2595) after. - other client: 4881 ms (4659) before, 4259 ms (4072) after. - adapter CPU: 2.88 s before, 2.56 s after (AIR). A 180 MB session does not change (about 380 ms). The session/update stream is byte-equal to the parent commit for both sessions. --- src/ToolCallReports.ts | 13 ++++++++++--- src/__tests__/ToolCallReports.test.ts | 25 +++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 3 deletions(-) diff --git a/src/ToolCallReports.ts b/src/ToolCallReports.ts index 08717f849..567dba9a2 100644 --- a/src/ToolCallReports.ts +++ b/src/ToolCallReports.ts @@ -71,7 +71,11 @@ export class ToolCallReports { private recordStart(key: string, update: ToolCallReport): ToolCallReport { this.finishedToolCalls.delete(key); const fields = new Map(); - for (const [name, value] of reportedFields(update, this.compareMeta)) { + // A start with a terminal status, for example in a history replay, keeps only the small fields. + // `finish` drops the other fields at once, so the adapter does not stringify them. + const terminal = update.status === "completed" || update.status === "failed"; + const only = terminal ? FINISHED_FIELDS : undefined; + for (const [name, value] of reportedFields(update, this.compareMeta && !terminal, only)) { fields.set(name, value); } this.openToolCalls.set(key, fields); @@ -97,7 +101,9 @@ export class ToolCallReports { ): ToolCallReport | null { const prepared: Record = {...update}; const meta = isRecord(update._meta) ? {...update._meta} : undefined; - for (const [name, value] of reportedFields(update, this.compareMeta)) { + // With `recorded`, `fields` holds only the `recorded` fields and no `_meta` key. + // So no other field can be unchanged, and the adapter does not stringify it. + for (const [name, value] of reportedFields(update, this.compareMeta && recorded === undefined, recorded)) { if (fields.get(name) === value) { if (name.startsWith(META_FIELD_PREFIX)) { delete meta?.[name.slice(META_FIELD_PREFIX.length)]; @@ -154,10 +160,11 @@ export class ToolCallReports { } } -function reportedFields(update: ToolCallReport, withMeta: boolean): Array<[string, string]> { +function reportedFields(update: ToolCallReport, withMeta: boolean, only?: ReadonlySet): Array<[string, string]> { const fields: Array<[string, string]> = []; const record = update as Record; for (const name of COMPARED_FIELDS) { + if (only !== undefined && !only.has(name)) continue; const value = record[name]; if (value !== undefined) fields.push([name, JSON.stringify(value)]); } diff --git a/src/__tests__/ToolCallReports.test.ts b/src/__tests__/ToolCallReports.test.ts index d43d81d70..ce8785c1b 100644 --- a/src/__tests__/ToolCallReports.test.ts +++ b/src/__tests__/ToolCallReports.test.ts @@ -206,6 +206,31 @@ describe("ToolCallReports late output", () => { })).toEqual({sessionUpdate: "tool_call_update", toolCallId: "cmd-1", _meta: output}); }); + it("keeps the large fields of a repeated report after a start with the final status, and drops the small ones", () => { + const reports = new ToolCallReports(); + const start = { + sessionUpdate: "tool_call" as const, + toolCallId: "cmd-1", + title: "ls", + kind: "execute" as const, + status: "completed" as const, + rawInput: {command: "ls"}, + rawOutput: {stdout: "a.txt\n"}, + content: [{type: "content" as const, content: {type: "text" as const, text: "a.txt"}}], + _meta: {codex: {tool: "exec"}}, + }; + expect(reports.prepare("s", start)).toEqual(start); + + expect(reports.prepare("s", {...start, sessionUpdate: "tool_call_update"})).toEqual({ + sessionUpdate: "tool_call_update", + toolCallId: "cmd-1", + rawInput: {command: "ls"}, + rawOutput: {stdout: "a.txt\n"}, + content: [{type: "content", content: {type: "text", text: "a.txt"}}], + _meta: {codex: {tool: "exec"}}, + }); + }); + it("accepts output again when the tool call id starts a new tool call", () => { const reports = new ToolCallReports(); reports.prepare("s", {sessionUpdate: "tool_call", toolCallId: "cmd-1", title: "ls", status: "completed"}); From f9e89fc73af5986083e70af77215689deceb8e49 Mon Sep 17 00:00:00 2001 From: Nikita Ashikhmin Date: Fri, 2 Oct 2026 12:54:25 +0400 Subject: [PATCH 13/22] perf: write the ACP stream to stdout as strings The SDK writer encoded each message with TextEncoder and wrote it through a web stream adapter. The adapter now writes each line as a string to the Node stream. A write waits for drain when the stream buffer is full, so the backpressure stays. After an error, an end or a close of stdout, each write rejects, so the ACP connection closes as before. The process still exits when stdin closes. Load time, 8 runs, median (min), with the parent commit first: - 2.1 GB session, AIR client: 2543 ms (2519), then 2326 ms (2251). - 2.1 GB session, other client: 4105 ms (4055), then 3678 ms (3598). - 180 MB session, AIR client: 367 ms (360), then 349 ms (336). Adapter CPU for the 2.1 GB session goes from 3.94 s to 3.35 s. The session/update stream is byte-equal to the parent commit. --- src/StdUtils.ts | 48 ++++++++++++++-- src/__tests__/StdUtils.test.ts | 100 ++++++++++++++++++++++++++++++++- 2 files changed, 141 insertions(+), 7 deletions(-) diff --git a/src/StdUtils.ts b/src/StdUtils.ts index 96e8d7048..fe5e25d10 100644 --- a/src/StdUtils.ts +++ b/src/StdUtils.ts @@ -1,5 +1,5 @@ import {StringDecoder} from "node:string_decoder"; -import {Readable, Writable} from "node:stream"; +import {finished, Readable, Writable} from "node:stream"; import {Emitter} from "vscode-jsonrpc/node"; import type {DataCallback, Disposable, Message, MessageReader, MessageWriter, PartialMessageInfo} from "vscode-jsonrpc/node"; import * as acp from "@agentclientprotocol/sdk"; @@ -69,10 +69,48 @@ export function createJSONRPCReader(readable: Readable): MessageReader { } } -export function createJsonStream(readable: Readable, writable: Writable){ - const input = Writable.toWeb(writable); - const output = Readable.toWeb(readable) as ReadableStream; - return acp.ndJsonStream(input, output); +export function createJsonStream(readable: Readable, writable: Writable): acp.Stream { + const writeLine = createLineWriter(writable); + // The SDK writes only its parse error replies to this stream. + const errorReplies = new WritableStream({write: chunk => writeLine(chunk)}); + const input = Readable.toWeb(readable) as ReadableStream; + const stream = acp.ndJsonStream(errorReplies, input); + // The SDK encodes each message with TextEncoder and writes it through a web stream adapter. + // A string write to the Node stream encodes it in native code, and is faster for a large history. + const messages = new WritableStream({ + write: message => writeLine(JSON.stringify(message) + "\n"), + }); + return {readable: stream.readable, writable: messages}; +} + +/** + * Returns a function that writes one line to `writable`, with the same contract as `Writable.toWeb`. + * + * The promise waits for `drain` when the stream buffer is full, so a large history keeps the backpressure. + * After an error, an end or a close of the stream, each write rejects, so the ACP connection closes. + */ +export function createLineWriter(writable: Writable): (line: string | Uint8Array) => Promise { + let failure: Error | undefined; + const waiting = new Set<{resolve: () => void; reject: (error: Error) => void}>(); + const stopWatching = finished(writable, error => { + stopWatching(); + // `finished` no longer listens, so a later error must not become an uncaught exception. + writable.on("error", () => {}); + writable.off("drain", onDrain); + failure = error ?? new Error("The output stream ended"); + for (const waiter of waiting) waiter.reject(failure); + waiting.clear(); + }); + const onDrain = () => { + for (const waiter of waiting) waiter.resolve(); + waiting.clear(); + }; + writable.on("drain", onDrain); + return line => { + if (failure !== undefined) return Promise.reject(failure); + if (writable.write(line)) return Promise.resolve(); + return new Promise((resolve, reject) => waiting.add({resolve, reject})); + }; } /** diff --git a/src/__tests__/StdUtils.test.ts b/src/__tests__/StdUtils.test.ts index bfe0ee469..ac42cb0b0 100644 --- a/src/__tests__/StdUtils.test.ts +++ b/src/__tests__/StdUtils.test.ts @@ -1,6 +1,6 @@ -import {PassThrough} from "node:stream"; +import {PassThrough, Writable} from "node:stream"; import {describe, expect, it} from "vitest"; -import {createJSONRPCReader} from "../StdUtils"; +import {createJsonStream, createJSONRPCReader, createLineWriter} from "../StdUtils"; function read(chunks: Buffer[]): Promise { const stream = new PassThrough(); @@ -65,3 +65,99 @@ describe("createJSONRPCReader", () => { expect(performance.now() - started).toBeLessThan(3000); }); }); + +/** A stream that keeps each write callback until the test calls `release`, and then calls back at once. */ +function slowWritable(highWaterMark: number) { + const written: string[] = []; + let held: ((error?: Error | null) => void) | undefined; + let released = false; + const writable = new Writable({ + highWaterMark, + write(chunk: Buffer, _encoding, callback) { + written.push(chunk.toString("utf8")); + if (released) callback(); else held = callback; + }, + }); + const release = (error?: Error) => { + released = true; + held?.(error); + }; + return {writable, written, release}; +} + +function settled(promise: Promise): Promise { + return Promise.race([ + promise.then(() => "resolved", (error: Error) => `rejected: ${error.message}`), + new Promise(resolve => setTimeout(() => resolve("pending"), 20)), + ]); +} + +describe("createLineWriter", () => { + it("waits for drain when the stream buffer is full", async () => { + const {writable, written, release} = slowWritable(16); + const writeLine = createLineWriter(writable); + + expect(await settled(writeLine("short\n"))).toBe("resolved"); + const large = writeLine("x".repeat(1024) + "\n"); + expect(await settled(large)).toBe("pending"); + + release(); + expect(await settled(large)).toBe("resolved"); + expect(written).toEqual(["short\n", "x".repeat(1024) + "\n"]); + }); + + it("rejects the waiting write and each later write when the stream fails", async () => { + const {writable, release} = slowWritable(16); + const writeLine = createLineWriter(writable); + const waiting = writeLine("x".repeat(1024) + "\n"); + + release(Object.assign(new Error("write EPIPE"), {code: "EPIPE"})); + + expect(await settled(waiting)).toBe("rejected: write EPIPE"); + expect(await settled(writeLine("next\n"))).toBe("rejected: write EPIPE"); + // A later error of the stream is not an uncaught exception. + writable.emit("error", new Error("write EPIPE")); + }); + + it("rejects a write after the stream ended", async () => { + const writable = new PassThrough(); + writable.resume(); + const writeLine = createLineWriter(writable); + writable.end(); + await new Promise(resolve => writable.on("finish", resolve)); + await new Promise(resolve => setImmediate(resolve)); + + expect(await settled(writeLine("late\n"))).toBe("rejected: The output stream ended"); + }); +}); + +describe("createJsonStream", () => { + it("writes each message as one JSON line with the bytes of JSON.stringify", async () => { + const output = new PassThrough(); + const chunks: Buffer[] = []; + output.on("data", (chunk: Buffer) => chunks.push(chunk)); + const stream = createJsonStream(new PassThrough(), output); + const message = {jsonrpc: "2.0" as const, method: "session/update", params: {text: "я \u2028 \ud83d\ude00 \ud800"}}; + + const writer = stream.writable.getWriter(); + await writer.write(message); + await writer.write({jsonrpc: "2.0", id: 1, result: {}}); + + expect(Buffer.concat(chunks)).toEqual(Buffer.from( + new TextEncoder().encode(JSON.stringify(message) + "\n" + JSON.stringify({jsonrpc: "2.0", id: 1, result: {}}) + "\n"), + )); + }); + + it("rejects a message write after the output stream failed", async () => { + const {writable, release} = slowWritable(16); + const stream = createJsonStream(new PassThrough(), writable); + const writer = stream.writable.getWriter(); + const waiting = writer.write({jsonrpc: "2.0", method: "m", params: {text: "x".repeat(1024)}}); + expect(await settled(waiting)).toBe("pending"); + + release(new Error("write EPIPE")); + + expect(await settled(waiting)).toBe("rejected: write EPIPE"); + expect(await settled(writer.write({jsonrpc: "2.0", id: 1, result: {}}))).toBe("rejected: write EPIPE"); + }); +}); From 96def94dd9266867f3e56193124963ca33e70340 Mon Sep 17 00:00:00 2001 From: Nikita Ashikhmin Date: Fri, 2 Oct 2026 13:03:23 +0400 Subject: [PATCH 14/22] perf: read the account while session/load resumes the thread The account read of session/load waits for the app-server workspace routing discovery. That is a network call to the ChatGPT backend. The load now starts the read and thread/resume at the same time, and reads the account once. The auth state of the session uses that read. A login with the default auth request keeps the old order, because it can change the provider of the resume. When the agent needs a login, the load still fails with auth_required and closes the resumed thread. The auth error also wins when thread/resume fails. The client exposes readAuthRequirement instead of authRequired. The tests mock the new method. Load time, 8 runs, median, with the parent commit first: - 2.1 GB session, AIR client: 2295 ms, then 2242 ms. - 2.1 GB session, other client: 3618 ms, then 3598 ms. - 180 MB session: no change (about 345 ms). The gain is the time of the account read, which is short with a fast backend. The session/update stream is byte-equal to the parent commit. --- src/CodexAcpClient.ts | 17 +++- src/CodexAcpServer.ts | 52 ++++++++--- .../CodexACPAgent/CodexAcpClient.test.ts | 34 +++---- .../CodexACPAgent/auth-status.test.ts | 2 +- .../cancel-turn-registration-race.test.ts | 2 +- .../CodexACPAgent/fast-mode-config.test.ts | 2 +- .../CodexACPAgent/list-sessions.test.ts | 10 +- .../CodexACPAgent/load-session-auth.test.ts | 91 +++++++++++++++++++ .../CodexACPAgent/load-session.test.ts | 16 ++-- .../CodexACPAgent/mcp-config-merge.test.ts | 6 +- .../CodexACPAgent/mcp-session.test.ts | 4 +- .../CodexACPAgent/model-filtering.test.ts | 2 +- .../CodexACPAgent/new-session-logout.test.ts | 8 +- src/__tests__/CodexACPAgent/providers.test.ts | 4 +- .../CodexACPAgent/session-close.test.ts | 10 +- .../CodexACPAgent/session-compaction.test.ts | 2 +- .../session-config-options.test.ts | 4 +- .../CodexACPAgent/session-delete.test.ts | 4 +- .../CodexACPAgent/session-fork.test.ts | 6 +- .../CodexACPAgent/session-notices.test.ts | 2 +- .../thread-active-writer.test.ts | 2 +- .../unmaterialized-thread.test.ts | 2 +- src/__tests__/scenarios/scenario-harness.ts | 2 +- 23 files changed, 205 insertions(+), 79 deletions(-) create mode 100644 src/__tests__/CodexACPAgent/load-session-auth.test.ts diff --git a/src/CodexAcpClient.ts b/src/CodexAcpClient.ts index 7d15a6c69..cae7a5bf0 100644 --- a/src/CodexAcpClient.ts +++ b/src/CodexAcpClient.ts @@ -107,6 +107,14 @@ export type CreateUrlElicitationRequest = Extract; +/** The answer of {@link CodexAcpClient.readAuthRequirement}. */ +export interface AuthRequirement { + /** Whether the agent needs a login before it opens a session. */ + required: boolean; + /** The account read that gave the answer, or `null` when the adapter did not read the account. */ + account: GetAccountResponse | null; +} + export interface UrlElicitationRequester { elicitUrl(request: UrlElicitationRequest): Promise; completeElicitation(): Promise; @@ -351,17 +359,18 @@ export class CodexAcpClient { await accountUpdatedPromise; } - async authRequired(): Promise { + /** Reads whether the agent needs a login, with the account read that gave the answer. */ + async readAuthRequirement(): Promise { if (this.gatewayConfig != null) { // The authentication is already in progress: // the gateway config is set during the authentication request processing. // We assume that custom model providers will handle authentication themselves, // so Codex will not need to require it. - return false; + return {required: false, account: null}; } - const response = await this.codexClient.accountRead({refreshToken: false}) - return response.requiresOpenaiAuth && !response.account; + const response = await this.codexClient.accountRead({refreshToken: false}); + return {required: response.requiresOpenaiAuth && !response.account, account: response}; } /** diff --git a/src/CodexAcpServer.ts b/src/CodexAcpServer.ts index a8fb1dd05..a6fac2d06 100644 --- a/src/CodexAcpServer.ts +++ b/src/CodexAcpServer.ts @@ -25,6 +25,7 @@ import type {InputModality, ReasoningEffort, ServerNotification} from "./app-ser import type { Account, AccountUpdatedNotification, + GetAccountResponse, Model, ReasoningEffortOption, Thread, @@ -543,10 +544,14 @@ export class CodexAcpServer { } } - async checkAuthorization(){ - const authNeeded = await this.runWithProcessCheck(() => this.codexAcpClient.authRequired()); - logger.log("Auth requirement checked", {authRequired: authNeeded}); - if (authNeeded) { + /** + * Logs in with the default auth request, or throws `auth_required`, when the agent needs a login. + * Returns the account read that found no login needed, or `null` when there was no read or a login ran. + */ + async checkAuthorization(): Promise { + const requirement = await this.runWithProcessCheck(() => this.codexAcpClient.readAuthRequirement()); + logger.log("Auth requirement checked", {authRequired: requirement.required}); + if (requirement.required) { if (this.defaultAuthRequest) { logger.log("Authenticating with default auth request...", { authRequest: this.defaultAuthRequest @@ -557,7 +562,9 @@ export class CodexAcpServer { logger.log("Authentication required but no default auth request provided, return to IDE"); throw RequestError.authRequired(); } + return null; } + return requirement.account; } async getOrCreateSession(request: acp.NewSessionRequest | acp.ResumeSessionRequest): Promise<[SessionId, LegacySessionModelState, SessionModeState]> { @@ -789,7 +796,11 @@ export class CodexAcpServer { return [sessionId, sessionModelState, sessionModeState]; } - private async getAuthStateForProvider(authProvider: string | null): Promise { + /** Reads the account of `authProvider` and pushes the auth status. `knownAccount` is an account read that the caller already has. */ + private async getAuthStateForProvider( + authProvider: string | null, + knownAccount: GetAccountResponse | null = null, + ): Promise { if (!this.authProviderUsesOpenAiAccount(authProvider)) { await this.publishAuthStatus(authProvider, null); return { @@ -797,7 +808,7 @@ export class CodexAcpServer { authConfigured: true, }; } - const accountResponse = await this.runWithProcessCheck(() => this.codexAcpClient.getAccount()); + const accountResponse = knownAccount ?? await this.runWithProcessCheck(() => this.codexAcpClient.getAccount()); await this.publishAuthStatus(authProvider, accountResponse.account); return { account: accountResponse.account, @@ -1961,7 +1972,14 @@ export class CodexAcpServer { history: AsyncIterable; }> { const requestedSessionGeneration = this.beginSessionOpen(request.sessionId); - await this.checkAuthorization(); + // The account read can wait for a network call of the app-server, so it runs while + // `thread/resume` reads the rollout. A login with the default auth request can change + // the provider of the resume, so the login keeps the order. + const authorization = this.defaultAuthRequest + ? Promise.resolve(await this.checkAuthorization()) + : this.checkAuthorization(); + // The load awaits the result below. This stops an unhandled rejection while `thread/resume` runs. + authorization.catch(() => {}); const requestedMcpServers = request.mcpServers ?? []; const mcpServerStartupVersion = requestedMcpServers.length > 0 ? this.codexAcpClient.getMcpServerStartupVersion() @@ -1970,12 +1988,20 @@ export class CodexAcpServer { logger.log(`Load existing session: ${request.sessionId}...`); let subscribed = false; let sessionMetadata: SessionMetadataWithThread; + let knownAccount: GetAccountResponse | null; try { - sessionMetadata = await this.runWithProcessCheck(() => - this.codexAcpClient.loadSession(request, () => { - subscribed = true; - }) - ); + try { + sessionMetadata = await this.runWithProcessCheck(() => + this.codexAcpClient.loadSession(request, () => { + subscribed = true; + }) + ); + } catch (err) { + // The auth error comes first, as it did when the check ran before `thread/resume`. + await authorization; + throw err; + } + knownAccount = await authorization; } catch (err) { if (subscribed) { await this.cleanupStaleSessionOpen(request.sessionId, requestedSessionGeneration); @@ -1987,7 +2013,7 @@ export class CodexAcpServer { const authProvider = sessionMetadata.modelProvider ?? this.codexAcpClient.getModelProvider(); let authState: ActiveAuthState; try { - authState = await this.getAuthStateForProvider(authProvider); + authState = await this.getAuthStateForProvider(authProvider, knownAccount); } catch (err) { if (subscribed) { await this.cleanupStaleSessionOpen(request.sessionId, requestedSessionGeneration); diff --git a/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts b/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts index 5551e7b68..5bf5da8d4 100644 --- a/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts +++ b/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts @@ -353,7 +353,7 @@ describe('ACP server test', { timeout: 40_000 }, () => { }; await codexAcpAgent.authenticate(authRequest); - expect(await gatewayFixture.getCodexAcpClient().authRequired()).toBe(false); + expect(await gatewayFixture.getCodexAcpClient().readAuthRequirement()).toEqual({required: false, account: null}); const authenticatedResponse = await gatewayFixture.getCodexAcpAgent().extMethod("authentication/status", {}); expect(authenticatedResponse).toEqual({type: "gateway", name: "custom-gateway"}); @@ -721,7 +721,7 @@ describe('ACP server test', { timeout: 40_000 }, () => { const codexAcpClient = mockFixture.getCodexAcpClient(); const codexAppServerClient = mockFixture.getCodexAppServerClient(); - vi.spyOn(codexAcpClient, "authRequired").mockResolvedValue(false); + vi.spyOn(codexAcpClient, "readAuthRequirement").mockResolvedValue({required: false, account: null}); vi.spyOn(codexAcpClient, "getAccount").mockResolvedValue({account: null, requiresOpenaiAuth: false}); vi.spyOn(codexAppServerClient, "skillsExtraRootsSet").mockResolvedValue(undefined); vi.spyOn(codexAppServerClient, "listSkills").mockResolvedValue({data: []}); @@ -820,7 +820,7 @@ describe('ACP server test', { timeout: 40_000 }, () => { const codexAcpClient = mockFixture.getCodexAcpClient(); const codexAppServerClient = mockFixture.getCodexAppServerClient(); - vi.spyOn(codexAcpClient, "authRequired").mockResolvedValue(false); + vi.spyOn(codexAcpClient, "readAuthRequirement").mockResolvedValue({required: false, account: null}); const getAccountSpy = vi.spyOn(codexAcpClient, "getAccount").mockResolvedValue({ account: null, requiresOpenaiAuth: true, @@ -995,7 +995,7 @@ describe('ACP server test', { timeout: 40_000 }, () => { const codexAcpAgent = mockFixture.getCodexAcpAgent(); const codexAppServerClient = mockFixture.getCodexAppServerClient(); - vi.spyOn(codexAcpAgent, "checkAuthorization").mockResolvedValue(undefined); + vi.spyOn(codexAcpAgent, "checkAuthorization").mockResolvedValue(null); const threadStartSpy = vi.spyOn(codexAppServerClient, "threadStart").mockResolvedValue({ thread: { id: "thread-id" } as any, model: "gpt-5", @@ -1077,7 +1077,7 @@ describe('ACP server test', { timeout: 40_000 }, () => { const codexAcpClient = mockFixture.getCodexAcpClient(); const mcpStartup = deferred(); - vi.spyOn(codexAcpClient, "authRequired").mockResolvedValue(false); + vi.spyOn(codexAcpClient, "readAuthRequirement").mockResolvedValue({required: false, account: null}); vi.spyOn(codexAcpClient, "getAccount").mockResolvedValue({account: null, requiresOpenaiAuth: false}); vi.spyOn(codexAcpClient, "listSkills").mockResolvedValue({data: []}); vi.spyOn(codexAcpClient, "resumeSession").mockResolvedValue({ @@ -1120,7 +1120,7 @@ describe('ACP server test', { timeout: 40_000 }, () => { const codexAcpClient = mockFixture.getCodexAcpClient(); const mcpStartup = deferred(); - vi.spyOn(codexAcpClient, "authRequired").mockResolvedValue(false); + vi.spyOn(codexAcpClient, "readAuthRequirement").mockResolvedValue({required: false, account: null}); vi.spyOn(codexAcpClient, "getAccount").mockResolvedValue({account: null, requiresOpenaiAuth: false}); vi.spyOn(codexAcpClient, "listSkills").mockResolvedValue({data: []}); vi.spyOn(codexAcpClient, "newSession").mockResolvedValue({ @@ -1148,7 +1148,7 @@ describe('ACP server test', { timeout: 40_000 }, () => { const codexAcpClient = mockFixture.getCodexAcpClient(); const mcpStartup = deferred(); - vi.spyOn(codexAcpClient, "authRequired").mockResolvedValue(false); + vi.spyOn(codexAcpClient, "readAuthRequirement").mockResolvedValue({required: false, account: null}); vi.spyOn(codexAcpClient, "getAccount").mockResolvedValue({account: null, requiresOpenaiAuth: false}); vi.spyOn(codexAcpClient, "listSkills").mockResolvedValue({data: []}); vi.spyOn(codexAcpClient, "newSession").mockResolvedValue({ @@ -1174,7 +1174,7 @@ describe('ACP server test', { timeout: 40_000 }, () => { const codexAcpAgent = mockFixture.getCodexAcpAgent(); const codexAcpClient = mockFixture.getCodexAcpClient(); - vi.spyOn(codexAcpClient, "authRequired").mockResolvedValue(false); + vi.spyOn(codexAcpClient, "readAuthRequirement").mockResolvedValue({required: false, account: null}); vi.spyOn(codexAcpClient, "getAccount").mockResolvedValue({account: null, requiresOpenaiAuth: false}); vi.spyOn(codexAcpClient, "listSkills").mockResolvedValue({data: []}); vi.spyOn(codexAcpClient, "newSession").mockResolvedValue({ @@ -1206,7 +1206,7 @@ describe('ACP server test', { timeout: 40_000 }, () => { const codexAcpClient = mockFixture.getCodexAcpClient(); const mcpStartup = deferred(); - vi.spyOn(codexAcpClient, "authRequired").mockResolvedValue(false); + vi.spyOn(codexAcpClient, "readAuthRequirement").mockResolvedValue({required: false, account: null}); vi.spyOn(codexAcpClient, "getAccount").mockResolvedValue({account: null, requiresOpenaiAuth: false}); vi.spyOn(codexAcpClient, "listSkills").mockResolvedValue({data: []}); vi.spyOn(codexAcpClient, "newSession").mockResolvedValue({ @@ -1744,7 +1744,7 @@ describe('ACP server test', { timeout: 40_000 }, () => { const sessionId = "not-existing-session"; await fixture.getCodexAcpAgent().initialize({protocolVersion: 1}); - fixture.getCodexAcpClient().authRequired = vi.fn().mockResolvedValue(false); + fixture.getCodexAcpClient().readAuthRequirement = vi.fn().mockResolvedValue({required: false, account: null}); fixture.clearCodexConnectionDump(); await expect( @@ -3564,7 +3564,7 @@ describe('ACP server test', { timeout: 40_000 }, () => { const codexAcpAgent = fixture.getCodexAcpAgent(); await codexAcpAgent.initialize({protocolVersion: 1}); - fixture.getCodexAcpClient().authRequired = vi.fn().mockResolvedValue(false); + fixture.getCodexAcpClient().readAuthRequirement = vi.fn().mockResolvedValue({required: false, account: null}); const newSessionResponse = await codexAcpAgent.newSession({cwd: "", mcpServers: []}); @@ -3584,7 +3584,7 @@ describe('ACP server test', { timeout: 40_000 }, () => { const model = createTestModel(); const currentModelId = ModelId.create(model.id, model.defaultReasoningEffort).toString(); - vi.spyOn(codexAcpClient, "authRequired").mockResolvedValue(false); + vi.spyOn(codexAcpClient, "readAuthRequirement").mockResolvedValue({required: false, account: null}); vi.spyOn(codexAcpClient, "getModelProvider").mockReturnValue("openai"); const getAccountSpy = vi.spyOn(codexAcpClient, "getAccount") .mockResolvedValueOnce({ @@ -3663,7 +3663,7 @@ describe('ACP server test', { timeout: 40_000 }, () => { const model = createTestModel(); const currentModelId = ModelId.create(model.id, model.defaultReasoningEffort).toString(); - vi.spyOn(codexAcpClient, "authRequired").mockResolvedValue(false); + vi.spyOn(codexAcpClient, "readAuthRequirement").mockResolvedValue({required: false, account: null}); vi.spyOn(codexAcpClient, "getModelProvider").mockReturnValue(null); const getAccountSpy = vi.spyOn(codexAcpClient, "getAccount") .mockResolvedValue({ @@ -3716,7 +3716,7 @@ describe('ACP server test', { timeout: 40_000 }, () => { const model = createTestModel(); const currentModelId = ModelId.create(model.id, model.defaultReasoningEffort).toString(); - vi.spyOn(codexAcpClient, "authRequired").mockResolvedValue(false); + vi.spyOn(codexAcpClient, "readAuthRequirement").mockResolvedValue({required: false, account: null}); vi.spyOn(codexAcpClient, "getModelProvider").mockReturnValue("custom-provider"); const getAccountSpy = vi.spyOn(codexAcpClient, "getAccount") .mockResolvedValue({ @@ -3755,7 +3755,7 @@ describe('ACP server test', { timeout: 40_000 }, () => { const codexAcpAgent = fixture.getCodexAcpAgent(); await codexAcpAgent.initialize({protocolVersion: 1}); - fixture.getCodexAcpClient().authRequired = vi.fn().mockResolvedValue(false); + fixture.getCodexAcpClient().readAuthRequirement = vi.fn().mockResolvedValue({required: false, account: null}); vi.spyOn(fixture.getCodexAcpClient(), "listSkills").mockResolvedValue({ data: [{ cwd: "/workspace", @@ -3780,7 +3780,7 @@ describe('ACP server test', { timeout: 40_000 }, () => { const codexAcpAgent = fixture.getCodexAcpAgent(); await codexAcpAgent.initialize({protocolVersion: 1}); - fixture.getCodexAcpClient().authRequired = vi.fn().mockResolvedValue(false); + fixture.getCodexAcpClient().readAuthRequirement = vi.fn().mockResolvedValue({required: false, account: null}); vi.spyOn(fixture.getCodexAcpClient(), "listMcpServers").mockResolvedValue({ data: [ { @@ -3825,7 +3825,7 @@ describe('ACP server test', { timeout: 40_000 }, () => { const codexAcpAgent = fixture.getCodexAcpAgent(); await codexAcpAgent.initialize({protocolVersion: 1}); - fixture.getCodexAcpClient().authRequired = vi.fn().mockResolvedValue(false); + fixture.getCodexAcpClient().readAuthRequirement = vi.fn().mockResolvedValue({required: false, account: null}); const newSessionResponse = await codexAcpAgent.newSession({cwd: "", mcpServers: []}); const prompt: acp.ContentBlock[] = [ diff --git a/src/__tests__/CodexACPAgent/auth-status.test.ts b/src/__tests__/CodexACPAgent/auth-status.test.ts index 23811794f..d2eb0e5bc 100644 --- a/src/__tests__/CodexACPAgent/auth-status.test.ts +++ b/src/__tests__/CodexACPAgent/auth-status.test.ts @@ -66,7 +66,7 @@ async function createPromptableSession(fixture: CodexMockTestFixture): Promise { }); const slowModel = createTestModel({id: "slow-model"}); - vi.spyOn(codexAcpClient, "authRequired").mockResolvedValue(false); + vi.spyOn(codexAcpClient, "readAuthRequirement").mockResolvedValue({required: false, account: null}); vi.spyOn(codexAcpClient, "getAccount").mockResolvedValue({account: null, requiresOpenaiAuth: false}); vi.spyOn(codexAcpClient, "newSession").mockResolvedValue({ sessionId: "session-id", diff --git a/src/__tests__/CodexACPAgent/list-sessions.test.ts b/src/__tests__/CodexACPAgent/list-sessions.test.ts index ce569e9c6..3aaeb970e 100644 --- a/src/__tests__/CodexACPAgent/list-sessions.test.ts +++ b/src/__tests__/CodexACPAgent/list-sessions.test.ts @@ -10,7 +10,7 @@ describe("CodexACPAgent - list sessions", () => { const codexAcpClient = fixture.getCodexAcpClient(); const codexAppServerClient = fixture.getCodexAppServerClient(); - codexAcpClient.authRequired = vi.fn().mockResolvedValue(false); + codexAcpClient.readAuthRequirement = vi.fn().mockResolvedValue({required: false, account: null}); const threadA: Thread = { id: "sess-1", @@ -108,7 +108,7 @@ describe("CodexACPAgent - list sessions", () => { it("sends one thread/list request for an empty page", async () => { const fixture = createCodexMockTestFixture(); const codexAppServerClient = fixture.getCodexAppServerClient(); - fixture.getCodexAcpClient().authRequired = vi.fn().mockResolvedValue(false); + fixture.getCodexAcpClient().readAuthRequirement = vi.fn().mockResolvedValue({required: false, account: null}); codexAppServerClient.threadList = vi.fn().mockResolvedValue({data: [], nextCursor: null}); codexAppServerClient.threadLoadedList = vi.fn().mockResolvedValue({data: [], nextCursor: null}); @@ -124,7 +124,7 @@ describe("CodexACPAgent - list sessions", () => { const codexAcpClient = fixture.getCodexAcpClient(); const codexAppServerClient = fixture.getCodexAppServerClient(); - codexAcpClient.authRequired = vi.fn().mockResolvedValue(false); + codexAcpClient.readAuthRequirement = vi.fn().mockResolvedValue({required: false, account: null}); const matchingThread: Thread = { id: "sess-win", @@ -195,7 +195,7 @@ describe("CodexACPAgent - list sessions", () => { const codexAcpClient = fixture.getCodexAcpClient(); const codexAppServerClient = fixture.getCodexAppServerClient(); - codexAcpClient.authRequired = vi.fn().mockResolvedValue(false); + codexAcpClient.readAuthRequirement = vi.fn().mockResolvedValue({required: false, account: null}); const thread: Thread = { id: "sess-1", @@ -249,7 +249,7 @@ describe("CodexACPAgent - list sessions", () => { const codexAcpClient = fixture.getCodexAcpClient(); const codexAppServerClient = fixture.getCodexAppServerClient(); - vi.spyOn(codexAcpClient, "authRequired").mockResolvedValue(false); + vi.spyOn(codexAcpClient, "readAuthRequirement").mockResolvedValue({required: false, account: null}); vi.spyOn(codexAcpClient, "getAccount").mockResolvedValue({ account: null, requiresOpenaiAuth: false, diff --git a/src/__tests__/CodexACPAgent/load-session-auth.test.ts b/src/__tests__/CodexACPAgent/load-session-auth.test.ts new file mode 100644 index 000000000..abbe455fc --- /dev/null +++ b/src/__tests__/CodexACPAgent/load-session-auth.test.ts @@ -0,0 +1,91 @@ +import {describe, expect, it, vi} from "vitest"; +import {createCodexMockTestFixture, createTestModel, type CodexMockTestFixture} from "../acp-test-utils"; +import type {GetAccountResponse} from "../../app-server/v2"; + +const SESSION_ID = "session-1"; +const SIGNED_IN: GetAccountResponse = { + account: {type: "chatgpt", email: "user@example.com", planType: "plus"}, + requiresOpenaiAuth: true, +} as GetAccountResponse; + +/** A fixture whose `thread/resume` answers a session with one history message. */ +function loadFixture(): CodexMockTestFixture { + const fixture = createCodexMockTestFixture(); + const client = fixture.getCodexAcpClient(); + const appServer = fixture.getCodexAppServerClient(); + client.listSkills = vi.fn().mockResolvedValue({data: []}); + const model = createTestModel(); + appServer.listModels = vi.fn().mockResolvedValue({data: [model], nextCursor: null}); + appServer.threadResume = vi.fn().mockResolvedValue({ + thread: {id: SESSION_ID, historyMode: "paginated", turns: [], cwd: "/test/project", name: null, preview: ""}, + itemsBackwardsCursor: "item:last", + model: model.id, + modelProvider: "openai", + cwd: "/test/project", + approvalPolicy: "never", + sandbox: {type: "dangerFullAccess"}, + reasoningEffort: model.defaultReasoningEffort, + }); + appServer.threadItemsList = vi.fn().mockResolvedValue({ + data: [{turnId: "turn-1", item: {type: "agentMessage", id: "m-1", text: "history", phase: null, memoryCitation: null, delivery: null, questions: null}}], + nextCursor: null, + backwardsCursor: null, + }); + return fixture; +} + +/** Initializes the agent and waits for the account read that `initialize` starts. */ +async function initialize(fixture: CodexMockTestFixture): Promise { + await fixture.getCodexAcpAgent().initialize({protocolVersion: 1}); + for (let round = 0; round < 5; round += 1) { + await new Promise(resolve => setImmediate(resolve)); + } + vi.mocked(fixture.getCodexAppServerClient().accountRead).mockClear(); +} + +describe("CodexACPAgent - loadSession auth", () => { + it("resumes the thread while the account read runs, and reads the account once", async () => { + const fixture = loadFixture(); + const appServer = fixture.getCodexAppServerClient(); + let answer: (response: GetAccountResponse) => void = () => {}; + appServer.accountRead = vi.fn() + .mockResolvedValueOnce(SIGNED_IN) + .mockImplementation(() => new Promise(resolve => answer = resolve)); + await initialize(fixture); + + const load = fixture.getCodexAcpAgent().loadSession({sessionId: SESSION_ID, cwd: "/test/project", mcpServers: []}); + await vi.waitFor(() => expect(appServer.threadResume).toHaveBeenCalledTimes(1)); + answer(SIGNED_IN); + await load; + + expect(appServer.accountRead).toHaveBeenCalledTimes(1); + expect(fixture.getCodexAcpAgent().getSessionState(SESSION_ID).account).toEqual(SIGNED_IN.account); + }); + + it("fails with auth_required and closes the resumed thread when the agent needs a login", async () => { + const fixture = loadFixture(); + const client = fixture.getCodexAcpClient(); + const appServer = fixture.getCodexAppServerClient(); + appServer.accountRead = vi.fn().mockResolvedValue({account: null, requiresOpenaiAuth: true}); + const closeSpy = vi.spyOn(client, "closeSession").mockResolvedValue(undefined as never); + await initialize(fixture); + + await expect(fixture.getCodexAcpAgent().loadSession({sessionId: SESSION_ID, cwd: "/test/project", mcpServers: []})) + .rejects.toMatchObject({code: -32000}); + + expect(closeSpy).toHaveBeenCalledWith(SESSION_ID); + expect(appServer.threadItemsList).not.toHaveBeenCalled(); + expect(() => fixture.getCodexAcpAgent().getSessionState(SESSION_ID)).toThrow(); + }); + + it("fails with auth_required when the resume also fails", async () => { + const fixture = loadFixture(); + const appServer = fixture.getCodexAppServerClient(); + appServer.accountRead = vi.fn().mockResolvedValue({account: null, requiresOpenaiAuth: true}); + appServer.threadResume = vi.fn().mockRejectedValue(new Error("no rollout found for thread id session-1")); + await initialize(fixture); + + await expect(fixture.getCodexAcpAgent().loadSession({sessionId: SESSION_ID, cwd: "/test/project", mcpServers: []})) + .rejects.toMatchObject({code: -32000}); + }); +}); diff --git a/src/__tests__/CodexACPAgent/load-session.test.ts b/src/__tests__/CodexACPAgent/load-session.test.ts index 89a565937..eefdb3529 100644 --- a/src/__tests__/CodexACPAgent/load-session.test.ts +++ b/src/__tests__/CodexACPAgent/load-session.test.ts @@ -25,7 +25,7 @@ describe("CodexACPAgent - loadSession", () => { const agent = fixture.getCodexAcpAgent(); const client = fixture.getCodexAcpClient(); const appServer = fixture.getCodexAppServerClient(); - client.authRequired = vi.fn().mockResolvedValue(false); + client.readAuthRequirement = vi.fn().mockResolvedValue({required: false, account: null}); client.getAccount = vi.fn().mockResolvedValue({account: null, requiresOpenaiAuth: false}); client.listSkills = vi.fn().mockResolvedValue({data: []}); const model = createTestModel(); @@ -58,7 +58,7 @@ describe("CodexACPAgent - loadSession", () => { const agent = fixture.getCodexAcpAgent(); const client = fixture.getCodexAcpClient(); const appServer = fixture.getCodexAppServerClient(); - client.authRequired = vi.fn().mockResolvedValue(false); + client.readAuthRequirement = vi.fn().mockResolvedValue({required: false, account: null}); client.getAccount = vi.fn().mockResolvedValue({account: null, requiresOpenaiAuth: false}); client.listSkills = vi.fn().mockResolvedValue({data: []}); const model = createTestModel(); @@ -115,7 +115,7 @@ describe("CodexACPAgent - loadSession", () => { const agent = fixture.getCodexAcpAgent(); const client = fixture.getCodexAcpClient(); const appServer = fixture.getCodexAppServerClient(); - client.authRequired = vi.fn().mockResolvedValue(false); + client.readAuthRequirement = vi.fn().mockResolvedValue({required: false, account: null}); client.getAccount = vi.fn().mockResolvedValue({account: null, requiresOpenaiAuth: false}); client.listSkills = vi.fn().mockResolvedValue({data: []}); const model = createTestModel(); @@ -302,7 +302,7 @@ describe("CodexACPAgent - loadSession", () => { const codexAcpClient = fixture.getCodexAcpClient(); const codexAppServerClient = fixture.getCodexAppServerClient(); - codexAcpClient.authRequired = vi.fn().mockResolvedValue(false); + codexAcpClient.readAuthRequirement = vi.fn().mockResolvedValue({required: false, account: null}); codexAcpClient.getAccount = vi.fn().mockResolvedValue({ account: null, requiresOpenaiAuth: false, @@ -537,7 +537,7 @@ describe("CodexACPAgent - loadSession", () => { const agent = fixture.getCodexAcpAgent(); const client = fixture.getCodexAcpClient(); const appServer = fixture.getCodexAppServerClient(); - client.authRequired = vi.fn().mockResolvedValue(false); + client.readAuthRequirement = vi.fn().mockResolvedValue({required: false, account: null}); client.getAccount = vi.fn().mockResolvedValue({account: null, requiresOpenaiAuth: false}); client.listSkills = vi.fn().mockResolvedValue({data: []}); const model = createTestModel(); @@ -572,7 +572,7 @@ describe("CodexACPAgent - loadSession", () => { const agent = fixture.getCodexAcpAgent(); const client = fixture.getCodexAcpClient(); const appServer = fixture.getCodexAppServerClient(); - client.authRequired = vi.fn().mockResolvedValue(false); + client.readAuthRequirement = vi.fn().mockResolvedValue({required: false, account: null}); client.getAccount = vi.fn().mockResolvedValue({account: null, requiresOpenaiAuth: false}); client.listSkills = vi.fn().mockResolvedValue({data: []}); const model = createTestModel(); @@ -624,7 +624,7 @@ describe("CodexACPAgent - loadSession", () => { const codexAcpClient = fixture.getCodexAcpClient(); const codexAppServerClient = fixture.getCodexAppServerClient(); - codexAcpClient.authRequired = vi.fn().mockResolvedValue(false); + codexAcpClient.readAuthRequirement = vi.fn().mockResolvedValue({required: false, account: null}); codexAcpClient.getAccount = vi.fn().mockResolvedValue({ account: null, requiresOpenaiAuth: false, @@ -716,7 +716,7 @@ describe("CodexACPAgent - loadSession", () => { const codexAcpClient = fixture.getCodexAcpClient(); const codexAppServerClient = fixture.getCodexAppServerClient(); - codexAcpClient.authRequired = vi.fn().mockResolvedValue(false); + codexAcpClient.readAuthRequirement = vi.fn().mockResolvedValue({required: false, account: null}); codexAcpClient.getAccount = vi.fn().mockResolvedValue({ account: null, requiresOpenaiAuth: false, diff --git a/src/__tests__/CodexACPAgent/mcp-config-merge.test.ts b/src/__tests__/CodexACPAgent/mcp-config-merge.test.ts index 897edfef5..b5666a08b 100644 --- a/src/__tests__/CodexACPAgent/mcp-config-merge.test.ts +++ b/src/__tests__/CodexACPAgent/mcp-config-merge.test.ts @@ -54,7 +54,7 @@ url = "https://example.com/mcp" const codexAcpAgent = fixture.getCodexAcpAgent(); await codexAcpAgent.initialize({protocolVersion: 1}); - fixture.getCodexAcpClient().authRequired = vi.fn().mockResolvedValue(false); + fixture.getCodexAcpClient().readAuthRequirement = vi.fn().mockResolvedValue({required: false, account: null}); const conflictingMcp: McpServerStdio = { name: "shared-mcp", @@ -82,7 +82,7 @@ url = "https://example.com/mcp" it('should preserve a project url-based MCP when ACP passes a command-type MCP with the same name', async () => { const codexAcpAgent = fixture.getCodexAcpAgent(); await codexAcpAgent.initialize({protocolVersion: 1}); - fixture.getCodexAcpClient().authRequired = vi.fn().mockResolvedValue(false); + fixture.getCodexAcpClient().readAuthRequirement = vi.fn().mockResolvedValue({required: false, account: null}); const conflictingMcp = { name: "project-mcp", @@ -102,7 +102,7 @@ url = "https://example.com/mcp" const codexAcpAgent = fixture.getCodexAcpAgent(); await codexAcpAgent.initialize({protocolVersion: 1}); - fixture.getCodexAcpClient().authRequired = vi.fn().mockResolvedValue(false); + fixture.getCodexAcpClient().readAuthRequirement = vi.fn().mockResolvedValue({required: false, account: null}); const conflictingMcp: McpServerStdio = { name: "shared-mcp", diff --git a/src/__tests__/CodexACPAgent/mcp-session.test.ts b/src/__tests__/CodexACPAgent/mcp-session.test.ts index 988dc648f..d187bb1ff 100644 --- a/src/__tests__/CodexACPAgent/mcp-session.test.ts +++ b/src/__tests__/CodexACPAgent/mcp-session.test.ts @@ -17,7 +17,7 @@ describe('MCP session configuration', { timeout: 40_000 }, () => { const codexAcpAgent = fixture.getCodexAcpAgent(); await codexAcpAgent.initialize({protocolVersion: 1}); - fixture.getCodexAcpClient().authRequired = vi.fn().mockResolvedValue(false); + fixture.getCodexAcpClient().readAuthRequirement = vi.fn().mockResolvedValue({required: false, account: null}); const mcpServer: McpServerStdio = { name: "test-mcp", command: "./node_modules/.bin/mcp-hello-world", args: ["example"], env: [{name:"example", value: "example"}] }; @@ -35,7 +35,7 @@ describe('MCP session configuration', { timeout: 40_000 }, () => { const codexAcpAgent = fixture.getCodexAcpAgent(); await codexAcpAgent.initialize({protocolVersion: 1}); - fixture.getCodexAcpClient().authRequired = vi.fn().mockResolvedValue(false); + fixture.getCodexAcpClient().readAuthRequirement = vi.fn().mockResolvedValue({required: false, account: null}); const mcpServer: McpServerStdio = { name: "test-mcp", command: "./node_modules/.bin/mcp-hello-world", args: ["example"], env: [{name:"example", value: "example"}] }; diff --git a/src/__tests__/CodexACPAgent/model-filtering.test.ts b/src/__tests__/CodexACPAgent/model-filtering.test.ts index 9cdf12cc7..df967cdee 100644 --- a/src/__tests__/CodexACPAgent/model-filtering.test.ts +++ b/src/__tests__/CodexACPAgent/model-filtering.test.ts @@ -141,7 +141,7 @@ describe("Model filtering", () => { }, ]; - vi.spyOn(codexAcpClient, "authRequired").mockResolvedValue(false); + vi.spyOn(codexAcpClient, "readAuthRequirement").mockResolvedValue({required: false, account: null}); vi.spyOn(codexAcpClient, "newSession").mockResolvedValue({ sessionId: "session-id", currentModelId: "gpt-5.2[medium]", diff --git a/src/__tests__/CodexACPAgent/new-session-logout.test.ts b/src/__tests__/CodexACPAgent/new-session-logout.test.ts index 01a7b429e..4fbe6713d 100644 --- a/src/__tests__/CodexACPAgent/new-session-logout.test.ts +++ b/src/__tests__/CodexACPAgent/new-session-logout.test.ts @@ -8,7 +8,7 @@ describe("New session logout handling", () => { const codexAcpAgent = fixture.getCodexAcpAgent(); const codexAcpClient = fixture.getCodexAcpClient(); const codexAppServerClient = fixture.getCodexAppServerClient(); - vi.spyOn(codexAcpClient, "authRequired").mockResolvedValue(false); + vi.spyOn(codexAcpClient, "readAuthRequirement").mockResolvedValue({required: false, account: null}); const errorMessage = `Internal error: "failed to reload config: Your access token could not be refreshed because your refresh token was already used. Please log out and sign in again."`; vi.spyOn(codexAppServerClient, "threadStart").mockRejectedValue(new Error(errorMessage)); @@ -27,7 +27,7 @@ describe("New session logout handling", () => { const codexAcpAgent = fixture.getCodexAcpAgent(); const codexAcpClient = fixture.getCodexAcpClient(); const codexAppServerClient = fixture.getCodexAppServerClient(); - vi.spyOn(codexAcpClient, "authRequired").mockResolvedValue(false); + vi.spyOn(codexAcpClient, "readAuthRequirement").mockResolvedValue({required: false, account: null}); const errorMessage = `Internal error: "failed to reload config: Failed to load cloud requirements (workspace-managed policies)."`; vi.spyOn(codexAppServerClient, "threadStart").mockRejectedValue(new Error(errorMessage)); @@ -46,7 +46,7 @@ describe("New session logout handling", () => { const codexAcpAgent = fixture.getCodexAcpAgent(); const codexAcpClient = fixture.getCodexAcpClient(); const codexAppServerClient = fixture.getCodexAppServerClient(); - vi.spyOn(codexAcpClient, "authRequired").mockResolvedValue(false); + vi.spyOn(codexAcpClient, "readAuthRequirement").mockResolvedValue({required: false, account: null}); const logoutSpy = vi.spyOn(codexAcpClient, "logout").mockResolvedValue(); const errorMessage = 'Internal error: "failed to reload config: filesystem path `/tmp` must be absolute, use `~/...`, or start with `:`"'; @@ -65,7 +65,7 @@ describe("New session logout handling", () => { const codexAcpClient = fixture.getCodexAcpClient(); const model = createTestModel(); const currentModelId = ModelId.create(model.id, model.defaultReasoningEffort).toString(); - vi.spyOn(codexAcpClient, "authRequired").mockResolvedValue(false); + vi.spyOn(codexAcpClient, "readAuthRequirement").mockResolvedValue({required: false, account: null}); const getAccountSpy = vi.spyOn(codexAcpClient, "getAccount") .mockResolvedValueOnce({ account: { type: "apiKey" }, diff --git a/src/__tests__/CodexACPAgent/providers.test.ts b/src/__tests__/CodexACPAgent/providers.test.ts index 5a7240395..c2f793037 100644 --- a/src/__tests__/CodexACPAgent/providers.test.ts +++ b/src/__tests__/CodexACPAgent/providers.test.ts @@ -138,7 +138,7 @@ describe("Configurable LLM providers (providers/*)", () => { const codexAcpClient = fixture.getCodexAcpClient(); const codexAppServerClient = fixture.getCodexAppServerClient(); - vi.spyOn(codexAcpClient, "authRequired").mockResolvedValue(false); + vi.spyOn(codexAcpClient, "readAuthRequirement").mockResolvedValue({required: false, account: null}); const threadStartSpy = vi.spyOn(codexAppServerClient, "threadStart") .mockRejectedValue(new Error("stop after capturing config")); @@ -175,7 +175,7 @@ describe("Configurable LLM providers (providers/*)", () => { const codexAcpClient = fixture.getCodexAcpClient(); const codexAppServerClient = fixture.getCodexAppServerClient(); - vi.spyOn(codexAcpClient, "authRequired").mockResolvedValue(false); + vi.spyOn(codexAcpClient, "readAuthRequirement").mockResolvedValue({required: false, account: null}); const threadStartSpy = vi.spyOn(codexAppServerClient, "threadStart") .mockRejectedValue(new Error("stop after capturing config")); diff --git a/src/__tests__/CodexACPAgent/session-close.test.ts b/src/__tests__/CodexACPAgent/session-close.test.ts index 3c174e6f5..9207f372a 100644 --- a/src/__tests__/CodexACPAgent/session-close.test.ts +++ b/src/__tests__/CodexACPAgent/session-close.test.ts @@ -184,7 +184,7 @@ describe("ACP session close", () => { args: ["broken"], env: [], }; - vi.spyOn(codexAcpClient, "authRequired").mockResolvedValue(false); + vi.spyOn(codexAcpClient, "readAuthRequirement").mockResolvedValue({required: false, account: null}); vi.spyOn(codexAcpClient, "getAccount").mockResolvedValue({account: null, requiresOpenaiAuth: false}); vi.spyOn(codexAcpClient, "listSkills").mockResolvedValue({data: []}); vi.spyOn(codexAcpClient, "newSession").mockResolvedValue({ @@ -255,7 +255,7 @@ describe("ACP session close", () => { const codexAcpAgent = fixture.getCodexAcpAgent(); const codexAcpClient = fixture.getCodexAcpClient(); await codexAcpAgent.initialize({protocolVersion: 1, clientCapabilities: {elicitation: {url: {}}}}); - vi.spyOn(codexAcpClient, "authRequired").mockResolvedValue(false); + vi.spyOn(codexAcpClient, "readAuthRequirement").mockResolvedValue({required: false, account: null}); vi.spyOn(codexAcpClient, "getAccount").mockResolvedValue({account: null, requiresOpenaiAuth: false}); vi.spyOn(codexAcpClient, "listSkills").mockResolvedValue({data: []}); vi.spyOn(codexAcpClient, "newSession").mockResolvedValue({ @@ -416,7 +416,7 @@ describe("ACP session close", () => { const fixture = createCodexMockTestFixture(); const codexAcpAgent = fixture.getCodexAcpAgent(); const codexAcpClient = fixture.getCodexAcpClient(); - vi.spyOn(codexAcpClient, "authRequired").mockResolvedValue(false); + vi.spyOn(codexAcpClient, "readAuthRequirement").mockResolvedValue({required: false, account: null}); vi.spyOn(codexAcpClient, "resumeSession").mockImplementation(async (_request, onSubscribed) => { onSubscribed?.(); throw new Error("model list failed"); @@ -436,7 +436,7 @@ describe("ACP session close", () => { const fixture = createCodexMockTestFixture(); const codexAcpAgent = fixture.getCodexAcpAgent(); const codexAcpClient = fixture.getCodexAcpClient(); - vi.spyOn(codexAcpClient, "authRequired").mockResolvedValue(false); + vi.spyOn(codexAcpClient, "readAuthRequirement").mockResolvedValue({required: false, account: null}); vi.spyOn(codexAcpClient, "resumeSession").mockImplementation(async (_request, onSubscribed) => { onSubscribed?.(); return createSessionMetadata(); @@ -538,7 +538,7 @@ async function createSession(options: { const codexAcpClient = fixture.getCodexAcpClient(); const model = createTestModel(); - vi.spyOn(codexAcpClient, "authRequired").mockResolvedValue(false); + vi.spyOn(codexAcpClient, "readAuthRequirement").mockResolvedValue({required: false, account: null}); vi.spyOn(codexAcpClient, "getAccount").mockResolvedValue({account: null, requiresOpenaiAuth: false}); vi.spyOn(codexAcpClient, "listSkills").mockResolvedValue({data: []}); vi.spyOn(codexAcpClient, "newSession").mockResolvedValue({ diff --git a/src/__tests__/CodexACPAgent/session-compaction.test.ts b/src/__tests__/CodexACPAgent/session-compaction.test.ts index 3fe56f636..666a1f648 100644 --- a/src/__tests__/CodexACPAgent/session-compaction.test.ts +++ b/src/__tests__/CodexACPAgent/session-compaction.test.ts @@ -346,7 +346,7 @@ describe("session compaction", () => { const client = fixture.getCodexAcpClient(); const appServer = fixture.getCodexAppServerClient(); const model = createTestModel(); - vi.spyOn(client, "authRequired").mockResolvedValue(false); + vi.spyOn(client, "readAuthRequirement").mockResolvedValue({required: false, account: null}); vi.spyOn(client, "getAccount").mockResolvedValue({account: null, requiresOpenaiAuth: false}); vi.spyOn(client, "listSkills").mockResolvedValue({data: []}); vi.spyOn(appServer, "listModels").mockResolvedValue({data: [model], nextCursor: null}); diff --git a/src/__tests__/CodexACPAgent/session-config-options.test.ts b/src/__tests__/CodexACPAgent/session-config-options.test.ts index eea818396..f66be6f0c 100644 --- a/src/__tests__/CodexACPAgent/session-config-options.test.ts +++ b/src/__tests__/CodexACPAgent/session-config-options.test.ts @@ -47,7 +47,7 @@ async function createSession( const codexAcpAgent = fixture.getCodexAcpAgent(); const codexAcpClient = fixture.getCodexAcpClient(); - vi.spyOn(codexAcpClient, "authRequired").mockResolvedValue(false); + vi.spyOn(codexAcpClient, "readAuthRequirement").mockResolvedValue({required: false, account: null}); vi.spyOn(codexAcpClient, "getAccount").mockResolvedValue({account: null, requiresOpenaiAuth: false}); vi.spyOn(codexAcpClient, "newSession").mockResolvedValue({ sessionId: "session-id", @@ -427,7 +427,7 @@ describe("Session config options", () => { const codexAcpClient = fixture.getCodexAcpClient(); const {fast} = buildModels(); - vi.spyOn(codexAcpClient, "authRequired").mockResolvedValue(false); + vi.spyOn(codexAcpClient, "readAuthRequirement").mockResolvedValue({required: false, account: null}); vi.spyOn(codexAcpClient, "getAccount").mockResolvedValue({account: null, requiresOpenaiAuth: false}); vi.spyOn(codexAcpClient, "newSession").mockResolvedValue({ sessionId: "session-id", diff --git a/src/__tests__/CodexACPAgent/session-delete.test.ts b/src/__tests__/CodexACPAgent/session-delete.test.ts index ba0207ed0..2afdc6f12 100644 --- a/src/__tests__/CodexACPAgent/session-delete.test.ts +++ b/src/__tests__/CodexACPAgent/session-delete.test.ts @@ -69,7 +69,7 @@ describe("ACP session delete", () => { const codexAcpAgent = fixture.getCodexAcpAgent(); const codexAcpClient = fixture.getCodexAcpClient(); const archive = deferred(); - vi.spyOn(codexAcpClient, "authRequired").mockResolvedValue(false); + vi.spyOn(codexAcpClient, "readAuthRequirement").mockResolvedValue({required: false, account: null}); vi.spyOn(codexAcpClient, "deleteSession").mockReturnValue(archive.promise); const resumeSessionSpy = vi.spyOn(codexAcpClient, "resumeSession"); @@ -122,7 +122,7 @@ async function createSession(): Promise<{ const codexAcpClient = fixture.getCodexAcpClient(); const model = createTestModel(); - vi.spyOn(codexAcpClient, "authRequired").mockResolvedValue(false); + vi.spyOn(codexAcpClient, "readAuthRequirement").mockResolvedValue({required: false, account: null}); vi.spyOn(codexAcpClient, "getAccount").mockResolvedValue({account: null, requiresOpenaiAuth: false}); vi.spyOn(codexAcpClient, "newSession").mockResolvedValue({ sessionId, diff --git a/src/__tests__/CodexACPAgent/session-fork.test.ts b/src/__tests__/CodexACPAgent/session-fork.test.ts index 2ea92d7ab..c97805093 100644 --- a/src/__tests__/CodexACPAgent/session-fork.test.ts +++ b/src/__tests__/CodexACPAgent/session-fork.test.ts @@ -9,7 +9,7 @@ describe("ACP session fork", () => { const client = fixture.getCodexAcpClient(); const model = createTestModel({id: "gpt-5"}); - vi.spyOn(client, "authRequired").mockResolvedValue(false); + vi.spyOn(client, "readAuthRequirement").mockResolvedValue({required: false, account: null}); vi.spyOn(client, "getAccount").mockResolvedValue({account: null, requiresOpenaiAuth: false}); vi.spyOn(client, "listSkills").mockResolvedValue({data: []}); const forkSpy = vi.spyOn(client, "forkSession").mockResolvedValue({ @@ -51,7 +51,7 @@ describe("ACP session fork", () => { const client = fixture.getCodexAcpClient(); const mcpStartup = deferred(); - vi.spyOn(client, "authRequired").mockResolvedValue(false); + vi.spyOn(client, "readAuthRequirement").mockResolvedValue({required: false, account: null}); vi.spyOn(client, "getAccount").mockResolvedValue({account: null, requiresOpenaiAuth: false}); vi.spyOn(client, "listSkills").mockResolvedValue({data: []}); vi.spyOn(client, "forkSession").mockResolvedValue({ @@ -91,7 +91,7 @@ describe("ACP session fork", () => { const agent = fixture.getCodexAcpAgent(); const client = fixture.getCodexAcpClient(); - vi.spyOn(client, "authRequired").mockResolvedValue(false); + vi.spyOn(client, "readAuthRequirement").mockResolvedValue({required: false, account: null}); vi.spyOn(client, "getAccount").mockResolvedValue({account: null, requiresOpenaiAuth: false}); vi.spyOn(client, "listSkills").mockResolvedValue({data: []}); vi.spyOn(client, "forkSession").mockResolvedValue({ diff --git a/src/__tests__/CodexACPAgent/session-notices.test.ts b/src/__tests__/CodexACPAgent/session-notices.test.ts index 336263d7b..0e8fc8cbb 100644 --- a/src/__tests__/CodexACPAgent/session-notices.test.ts +++ b/src/__tests__/CodexACPAgent/session-notices.test.ts @@ -157,7 +157,7 @@ describe("session notices", () => { const client = fixture.getCodexAcpClient(); const appServer = fixture.getCodexAppServerClient(); const model = createTestModel(); - vi.spyOn(client, "authRequired").mockResolvedValue(false); + vi.spyOn(client, "readAuthRequirement").mockResolvedValue({required: false, account: null}); vi.spyOn(client, "getAccount").mockResolvedValue({account: null, requiresOpenaiAuth: false}); vi.spyOn(client, "listSkills").mockResolvedValue({data: []}); vi.spyOn(appServer, "listModels").mockResolvedValue({data: [model], nextCursor: null}); diff --git a/src/__tests__/CodexACPAgent/thread-active-writer.test.ts b/src/__tests__/CodexACPAgent/thread-active-writer.test.ts index 098b5a095..cd5ddc42e 100644 --- a/src/__tests__/CodexACPAgent/thread-active-writer.test.ts +++ b/src/__tests__/CodexACPAgent/thread-active-writer.test.ts @@ -57,7 +57,7 @@ function createFixture() { const fixture = createCodexMockTestFixture(); const client = fixture.getCodexAcpClient(); const appServer = fixture.getCodexAppServerClient(); - client.authRequired = vi.fn().mockResolvedValue(false); + client.readAuthRequirement = vi.fn().mockResolvedValue({required: false, account: null}); client.getAccount = vi.fn().mockResolvedValue({account: null, requiresOpenaiAuth: false}); client.listSkills = vi.fn().mockResolvedValue({data: []}); appServer.listModels = vi.fn().mockResolvedValue({data: [createTestModel()], nextCursor: null}); diff --git a/src/__tests__/CodexACPAgent/unmaterialized-thread.test.ts b/src/__tests__/CodexACPAgent/unmaterialized-thread.test.ts index 6004a60ce..089a77c8a 100644 --- a/src/__tests__/CodexACPAgent/unmaterialized-thread.test.ts +++ b/src/__tests__/CodexACPAgent/unmaterialized-thread.test.ts @@ -49,7 +49,7 @@ function createFixture() { const fixture = createCodexMockTestFixture(); const client = fixture.getCodexAcpClient(); const appServer = fixture.getCodexAppServerClient(); - client.authRequired = vi.fn().mockResolvedValue(false); + client.readAuthRequirement = vi.fn().mockResolvedValue({required: false, account: null}); client.getAccount = vi.fn().mockResolvedValue({account: null, requiresOpenaiAuth: false}); client.listSkills = vi.fn().mockResolvedValue({data: []}); appServer.listModels = vi.fn().mockResolvedValue({data: [createTestModel()], nextCursor: null}); diff --git a/src/__tests__/scenarios/scenario-harness.ts b/src/__tests__/scenarios/scenario-harness.ts index d628f77fa..7f4b1892d 100644 --- a/src/__tests__/scenarios/scenario-harness.ts +++ b/src/__tests__/scenarios/scenario-harness.ts @@ -119,7 +119,7 @@ async function runInWorkspace( const anyClient = client as any; const anyAppServer = appServer as any; - anyClient.authRequired = vi.fn().mockResolvedValue(false); + anyClient.readAuthRequirement = vi.fn().mockResolvedValue({required: false, account: null}); anyClient.getAccount = vi.fn().mockResolvedValue({account: null, requiresOpenaiAuth: false}); anyClient.listSkills = vi.fn().mockResolvedValue({data: []}); anyClient.newSession = vi.fn().mockResolvedValue({ From faeccfbbea25c84e8778af17c215f3e3928d4ae6 Mon Sep 17 00:00:00 2001 From: Nikita Ashikhmin Date: Fri, 2 Oct 2026 13:07:19 +0400 Subject: [PATCH 15/22] fix: open a session when the ChatGPT backend is not reachable Codex 0.159 account/read runs a workspace routing discovery for a ChatGPT login. That is a network call to the ChatGPT backend. When the backend is not reachable, account/read fails with "workspace routing discovery failed". When the backend does not answer, it fails after 15 s with "workspace routing discovery timed out". session/new, session/resume, session/load and session/list then failed before Codex opened the thread. Such an error does not mean "not logged in". isAccountReadUnavailableError matches the internal error code and the whole text of these errors. A refused token (401) and other errors stay errors, as before. On such an error the session open continues, and the adapter pushes no auth status. AIR keeps the last status, because AIR shows a login request for a none status. The adapter does not log out. A missing login fails the resume or the first turn with the real error. session/new and session/resume now read the account once, so a slow backend costs one wait, not two. session/load waits for the account read at most 1 s after its start when thread/resume is done. Only the routing discovery is slow, and it runs only for an account that the app-server has. A read that ends later sets the account of the session and pushes the status. If it finds that the agent needs a login, the adapter pushes none. Checked with an isolated CODEX_HOME and a 180 MB session: - unresolvable backend: the load failed in 0.3 s. Now it answers in 0.33 s with the full history. - backend that does not answer: the load failed after about 15 s. Now it answers in 1.26 s with the full history. - unresolvable backend: session/new failed. Now it opens the session. No case pushes an auth status. --- src/CodexAcpServer.ts | 113 +++++++++- src/CodexThreadErrors.ts | 29 +++ .../CodexACPAgent/CodexAcpClient.test.ts | 2 +- .../account-read-unavailable.test.ts | 205 ++++++++++++++++++ src/__tests__/CodexThreadErrors.test.ts | 28 +++ 5 files changed, 365 insertions(+), 12 deletions(-) create mode 100644 src/__tests__/CodexACPAgent/account-read-unavailable.test.ts diff --git a/src/CodexAcpServer.ts b/src/CodexAcpServer.ts index a6fac2d06..de18a4bc9 100644 --- a/src/CodexAcpServer.ts +++ b/src/CodexAcpServer.ts @@ -10,6 +10,7 @@ import {clientSupportsUrlElicitation} from "./ElicitationCapabilities"; import {createMcpServerSignIn, type McpServerSignIn} from "./mcp/McpServerSignIn"; import {getRequestedMcpServerNames, McpSessionStartup, parseMcpStartupAwaitTimeoutMs} from "./mcp/McpSessionStartup"; import { + type AuthRequirement, CodexAcpClient, type JsonObject, OPENAI_PROVIDER_ID, @@ -18,7 +19,7 @@ import { type UrlElicitationRequester } from "./CodexAcpClient"; import {CodexAppServerClient} from "./CodexAppServerClient"; -import {isNoActiveTurnError} from "./CodexThreadErrors"; +import {isAccountReadUnavailableError, isNoActiveTurnError} from "./CodexThreadErrors"; import {type CodexConnection, startCodexConnection} from "./CodexJsonRpcConnection"; import {type AcpClientConnection, ACPSessionConnection, type UpdateSessionEvent} from "./ACPSessionConnection"; import type {InputModality, ReasoningEffort, ServerNotification} from "./app-server"; @@ -241,6 +242,14 @@ const TITLE_GENERATION_SETTLE_TIMEOUT_MS = 10_000; */ const NO_ACTIVE_TURN_RETRY_DELAYS_MS = [25, 50, 100, 200, 400]; +/** + * How long after its start `session/load` waits for the account read when `thread/resume` is done. + * A read that needs no network call ends well before this time, so a missing login still fails the load. + * A slower read waits for the ChatGPT backend, which the app-server waits for up to 15 s. + * Such a read cannot find a missing login, so the load answers without it, see `applyLateAccountRead`. + */ +const ACCOUNT_READ_LOAD_GRACE_MS = 1_000; + function clientSupportsTypedSessionFailures(capabilities: acp.ClientCapabilities | null): boolean { return clientSupportsAirCapability(capabilities, AIR_SESSION_FAILURE_KEY); } @@ -254,6 +263,25 @@ interface ActiveAuthState { authConfigured: boolean; } +/** + * An account read that a session open already has. `null` means no read. + * `"unavailable"` means a read that failed without an answer about the login, see {@link isAccountReadUnavailableError}. + */ +type KnownAccount = GetAccountResponse | "unavailable" | null; + +/** The value of `promise`, or `"pending"` when it does not settle within `ms`. A rejection rejects. */ +async function settledWithin(promise: Promise, ms: number): Promise { + let timer: NodeJS.Timeout | undefined; + const timeout = new Promise<"pending">(resolve => { + timer = setTimeout(() => resolve("pending"), Math.max(0, ms)); + }); + try { + return await Promise.race([promise, timeout]); + } finally { + clearTimeout(timer); + } +} + interface PendingTurnStart { promise: Promise; resolve: (turnId: string | null) => void; @@ -296,6 +324,8 @@ export class CodexAcpServer { private booleanConfigOptionsSupported: boolean; /** Last `authStatus` pushed to the client; used to suppress duplicates. */ private currentAuthStatus: AuthStatus | null; + /** The account of the last account read of a session open. A session keeps it when a read is unavailable. */ + private lastReadAccount: Account | null = null; private readonly sessions: Map; private readonly mcpSessionStartup: McpSessionStartup; @@ -547,9 +577,19 @@ export class CodexAcpServer { /** * Logs in with the default auth request, or throws `auth_required`, when the agent needs a login. * Returns the account read that found no login needed, or `null` when there was no read or a login ran. + * Returns `"unavailable"` when the read failed without an answer about the login, see + * {@link isAccountReadUnavailableError}. The session open then continues: a missing login fails the + * resume or the first turn with the real error. */ - async checkAuthorization(): Promise { - const requirement = await this.runWithProcessCheck(() => this.codexAcpClient.readAuthRequirement()); + async checkAuthorization(): Promise { + let requirement: AuthRequirement; + try { + requirement = await this.runWithProcessCheck(() => this.codexAcpClient.readAuthRequirement()); + } catch (error) { + if (!isAccountReadUnavailableError(error)) throw error; + logger.log("Account read unavailable, the login state is unknown", {error: String(error)}); + return "unavailable"; + } logger.log("Auth requirement checked", {authRequired: requirement.required}); if (requirement.required) { if (this.defaultAuthRequest) { @@ -666,7 +706,7 @@ export class CodexAcpServer { const requestedSessionGeneration = operation === "resume" ? this.beginSessionOpen(existingSessionRequest.sessionId) : null; - await this.checkAuthorization(); + const knownAccount = await this.checkAuthorization(); const requestedMcpServers = request.mcpServers ?? []; const mcpServerStartupVersion = requestedMcpServers.length > 0 ? this.codexAcpClient.getMcpServerStartupVersion() @@ -702,7 +742,7 @@ export class CodexAcpServer { const authProvider = sessionMetadata.modelProvider ?? this.codexAcpClient.getModelProvider(); let authState: ActiveAuthState; try { - authState = await this.getAuthStateForProvider(authProvider); + authState = await this.getAuthStateForProvider(authProvider, knownAccount); } catch (err) { if (resumeSubscribed && requestedSessionGeneration !== null) { await this.cleanupStaleSessionOpen(sessionId, requestedSessionGeneration); @@ -796,10 +836,17 @@ export class CodexAcpServer { return [sessionId, sessionModelState, sessionModeState]; } - /** Reads the account of `authProvider` and pushes the auth status. `knownAccount` is an account read that the caller already has. */ + /** + * Reads the account of `authProvider` and pushes the auth status. `knownAccount` is an account read that + * the caller already has. + * + * When the read is unavailable, the adapter pushes no status, so the client keeps the last status. + * A `none` status would be false here: AIR shows a login request for it. The session keeps the + * account of the last read. + */ private async getAuthStateForProvider( authProvider: string | null, - knownAccount: GetAccountResponse | null = null, + knownAccount: KnownAccount = null, ): Promise { if (!this.authProviderUsesOpenAiAccount(authProvider)) { await this.publishAuthStatus(authProvider, null); @@ -808,7 +855,17 @@ export class CodexAcpServer { authConfigured: true, }; } - const accountResponse = knownAccount ?? await this.runWithProcessCheck(() => this.codexAcpClient.getAccount()); + const accountResponse = knownAccount === "unavailable" + ? null + : knownAccount ?? await this.readAccountIfAvailable(); + if (accountResponse === null) { + logger.log("No account read, so the auth status stays as it was"); + return { + account: this.lastReadAccount, + authConfigured: true, + }; + } + this.lastReadAccount = accountResponse.account; await this.publishAuthStatus(authProvider, accountResponse.account); return { account: accountResponse.account, @@ -816,6 +873,36 @@ export class CodexAcpServer { }; } + /** Reads the account, or returns `null` when the read failed without an answer about the login. */ + private async readAccountIfAvailable(): Promise { + try { + return await this.runWithProcessCheck(() => this.codexAcpClient.getAccount()); + } catch (error) { + if (!isAccountReadUnavailableError(error)) throw error; + logger.log("Account read unavailable, the login state is unknown", {error: String(error)}); + return null; + } + } + + /** + * Applies the account read of a load that answered before the read ended. + * A read that found no login needed sets the account of the session and pushes the auth status. + * When the read found that the agent needs a login, a new read pushes the `none` status. + * The client then shows a login request, as it does for an `auth_required` error of the load. + */ + private applyLateAccountRead(sessionState: SessionState, authorization: Promise): void { + void authorization.then( + async knownAccount => { + if (knownAccount === "unavailable") return; + const authState = await this.getAuthStateForProvider(sessionState.authProvider, knownAccount); + if (this.sessions.get(sessionState.sessionId) !== sessionState) return; + sessionState.account = authState.account; + sessionState.authConfigured = authState.authConfigured; + }, + () => this.publishAuthStatusRead(), + ).catch(error => logger.log("Failed to apply a late account read", {error: String(error)})); + } + private authProviderUsesOpenAiAccount(authProvider: string | null): boolean { return authProvider === null || authProvider === "openai"; } @@ -1972,6 +2059,7 @@ export class CodexAcpServer { history: AsyncIterable; }> { const requestedSessionGeneration = this.beginSessionOpen(request.sessionId); + const loadStartedAt = performance.now(); // The account read can wait for a network call of the app-server, so it runs while // `thread/resume` reads the rollout. A login with the default auth request can change // the provider of the resume, so the login keeps the order. @@ -1988,7 +2076,7 @@ export class CodexAcpServer { logger.log(`Load existing session: ${request.sessionId}...`); let subscribed = false; let sessionMetadata: SessionMetadataWithThread; - let knownAccount: GetAccountResponse | null; + let knownAccount: KnownAccount | "pending"; try { try { sessionMetadata = await this.runWithProcessCheck(() => @@ -2001,7 +2089,7 @@ export class CodexAcpServer { await authorization; throw err; } - knownAccount = await authorization; + knownAccount = await settledWithin(authorization, ACCOUNT_READ_LOAD_GRACE_MS - (performance.now() - loadStartedAt)); } catch (err) { if (subscribed) { await this.cleanupStaleSessionOpen(request.sessionId, requestedSessionGeneration); @@ -2013,7 +2101,7 @@ export class CodexAcpServer { const authProvider = sessionMetadata.modelProvider ?? this.codexAcpClient.getModelProvider(); let authState: ActiveAuthState; try { - authState = await this.getAuthStateForProvider(authProvider, knownAccount); + authState = await this.getAuthStateForProvider(authProvider, knownAccount === "pending" ? "unavailable" : knownAccount); } catch (err) { if (subscribed) { await this.cleanupStaleSessionOpen(request.sessionId, requestedSessionGeneration); @@ -2070,6 +2158,9 @@ export class CodexAcpServer { () => sessionState.sessionTitleSource, ); this.installSessionState(sessionState); + if (knownAccount === "pending") { + this.applyLateAccountRead(sessionState, authorization); + } subscribed = false; if (requestedMcpServers.length > 0 && mcpServerStartupVersion !== null) { diff --git a/src/CodexThreadErrors.ts b/src/CodexThreadErrors.ts index b0aa6e866..46a87a9bc 100644 --- a/src/CodexThreadErrors.ts +++ b/src/CodexThreadErrors.ts @@ -95,3 +95,32 @@ export function threadActiveWriterRequestError(threadId: string, err: unknown): "This Codex session is in use by another Codex client (the Codex app, the CLI or an IDE extension). Close the session there or quit that client, then try again.", ); } + +/** The JSON-RPC code of an app-server internal error. */ +const INTERNAL_ERROR_CODE = -32603; + +/** + * The `account/read` errors of the app-server workspace routing discovery that say nothing about the login. + * The discovery runs only for a ChatGPT login that the app-server already holds. It makes a network call + * to the ChatGPT backend, so it fails when the backend is down or not reachable. + * `workspace routing discovery unauthorized (401)` is not in the set: the backend refused the token. + */ +const ACCOUNT_READ_UNAVAILABLE_MESSAGES = new Set([ + "workspace routing discovery failed", + "workspace routing discovery timed out", + "workspace routing discovery cancelled", + "workspace routing discovery cancelled during shutdown", +]); + +/** + * True when `account/read` failed without an answer about the login, because the ChatGPT backend did not answer. + * Such a failure does not mean "not logged in". The app-server sends it as an internal error with only the text + * of the routing error, so the match needs the code and the whole message. Another phrasing does not match, + * and the error then stays an error, as before. + */ +export function isAccountReadUnavailableError(err: unknown): boolean { + if (err === null || typeof err !== "object" || (err as {code?: unknown}).code !== INTERNAL_ERROR_CODE) { + return false; + } + return ACCOUNT_READ_UNAVAILABLE_MESSAGES.has(errorText(err)); +} diff --git a/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts b/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts index 5bf5da8d4..e49cfb39e 100644 --- a/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts +++ b/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts @@ -100,11 +100,11 @@ describe('ACP server test', { timeout: 40_000 }, () => { "account/updated", // Reads the connection auth identity for the `auth/status_update` push // when no session is open yet. + // The auth check of session/new. The session open reuses this read, so it reads the account once. "account/read", "thread/start", "model/list", "thread/started", - "account/read", "skills/list", ]); expect(loginRequest).toEqual({ diff --git a/src/__tests__/CodexACPAgent/account-read-unavailable.test.ts b/src/__tests__/CodexACPAgent/account-read-unavailable.test.ts new file mode 100644 index 000000000..59fe60fa8 --- /dev/null +++ b/src/__tests__/CodexACPAgent/account-read-unavailable.test.ts @@ -0,0 +1,205 @@ +import {describe, expect, it, vi} from "vitest"; +import {ResponseError} from "vscode-jsonrpc/node"; +import { + createCodexMockTestFixture, + createTestModel, + type CodexMockTestFixture, + type MethodCallEvent, +} from "../acp-test-utils"; +import {AUTH_STATUS_UPDATE_METHOD, type AuthStatus} from "../../AuthStatusMeta"; +import {ModelId} from "../../ModelId"; +import type {GetAccountResponse} from "../../app-server/v2"; + +const SESSION_ID = "session-1"; +const SIGNED_IN: GetAccountResponse = { + account: {type: "chatgpt", email: "user@example.com", planType: "plus"}, + requiresOpenaiAuth: true, +} as GetAccountResponse; + +/** The error of `account/read` when the ChatGPT backend is not reachable. */ +function routingFailure(): ResponseError { + return new ResponseError(-32603, "workspace routing discovery failed"); +} + +function authStatusUpdates(fixture: CodexMockTestFixture): AuthStatus[] { + return fixture.getAcpConnectionEvents([]) + .filter((event: MethodCallEvent) => event.method === "notify" && event.args[0] === AUTH_STATUS_UPDATE_METHOD) + .map((event: MethodCallEvent) => (event.args[1] as {authStatus: AuthStatus}).authStatus); +} + +function historyTexts(fixture: CodexMockTestFixture): string[] { + return fixture.getAcpConnectionEvents([]) + .filter((event: MethodCallEvent) => event.method === "sessionUpdate") + .map((event: MethodCallEvent) => event.args[0].update) + .filter(update => update.sessionUpdate === "agent_message_chunk") + .map(update => update.content.text); +} + +async function drainScheduledWork(): Promise { + for (let round = 0; round < 5; round += 1) { + await new Promise(resolve => setImmediate(resolve)); + } +} + +/** A fixture whose `thread/resume` answers a session with one history message. */ +function fixtureWithSession(): CodexMockTestFixture { + const fixture = createCodexMockTestFixture(); + const client = fixture.getCodexAcpClient(); + const appServer = fixture.getCodexAppServerClient(); + client.listSkills = vi.fn().mockResolvedValue({data: []}); + const model = createTestModel(); + appServer.listModels = vi.fn().mockResolvedValue({data: [model], nextCursor: null}); + appServer.accountLogout = vi.fn().mockResolvedValue({}); + appServer.threadResume = vi.fn().mockResolvedValue({ + thread: {id: SESSION_ID, historyMode: "paginated", turns: [], cwd: "/test/project", name: null, preview: ""}, + itemsBackwardsCursor: "item:last", + model: model.id, + modelProvider: "openai", + cwd: "/test/project", + approvalPolicy: "never", + sandbox: {type: "dangerFullAccess"}, + reasoningEffort: model.defaultReasoningEffort, + }); + appServer.threadItemsList = vi.fn().mockResolvedValue({ + data: [{turnId: "turn-1", item: {type: "agentMessage", id: "m-1", text: "history", phase: null, memoryCitation: null, delivery: null, questions: null}}], + nextCursor: null, + backwardsCursor: null, + }); + const metadata = { + sessionId: SESSION_ID, + currentModelId: ModelId.create(model.id, model.defaultReasoningEffort).toString(), + models: [model], + collaborationMode: "default" as const, + modelProvider: "openai", + additionalDirectories: [], + }; + vi.spyOn(client, "newSession").mockResolvedValue(metadata); + vi.spyOn(client, "resumeSession").mockResolvedValue(metadata); + return fixture; +} + +async function initialize(fixture: CodexMockTestFixture): Promise { + await fixture.getCodexAcpAgent().initialize({protocolVersion: 1}); + await drainScheduledWork(); +} + +describe("CodexACPAgent - an unavailable account read", () => { + it("loads the session and streams the history, with no auth status and no logout", async () => { + const fixture = fixtureWithSession(); + const appServer = fixture.getCodexAppServerClient(); + appServer.accountRead = vi.fn().mockRejectedValue(routingFailure()); + await initialize(fixture); + + await fixture.getCodexAcpAgent().loadSession({sessionId: SESSION_ID, cwd: "/test/project", mcpServers: []}); + await drainScheduledWork(); + + expect(historyTexts(fixture)).toEqual(["history"]); + expect(authStatusUpdates(fixture)).toEqual([]); + expect(appServer.accountLogout).not.toHaveBeenCalled(); + // One read for `initialize`, and one read for the load. + expect(appServer.accountRead).toHaveBeenCalledTimes(2); + }); + + it("keeps the last auth status and the last account when the read fails later", async () => { + const fixture = fixtureWithSession(); + const appServer = fixture.getCodexAppServerClient(); + appServer.accountRead = vi.fn().mockResolvedValueOnce(SIGNED_IN).mockResolvedValueOnce(SIGNED_IN); + await initialize(fixture); + await fixture.getCodexAcpAgent().newSession({cwd: "/test/project", mcpServers: []}); + await fixture.getCodexAcpAgent().closeSession({sessionId: SESSION_ID}); + const before = authStatusUpdates(fixture); + appServer.accountRead = vi.fn().mockRejectedValue(routingFailure()); + + await fixture.getCodexAcpAgent().loadSession({sessionId: SESSION_ID, cwd: "/test/project", mcpServers: []}); + await drainScheduledWork(); + + expect(before).toEqual([{kind: "account", label: "ChatGPT Plus", account: {email: "user@example.com", plan: "plus"}}]); + expect(authStatusUpdates(fixture)).toEqual(before); + expect(fixture.getCodexAcpAgent().getSessionState(SESSION_ID).account).toEqual(SIGNED_IN.account); + }); + + it.each(["new", "resume"] as const)("opens a session with session/%s, with no auth status and no logout", async operation => { + const fixture = fixtureWithSession(); + const appServer = fixture.getCodexAppServerClient(); + appServer.accountRead = vi.fn().mockRejectedValue(routingFailure()); + await initialize(fixture); + + const agent = fixture.getCodexAcpAgent(); + const response = operation === "new" + ? await agent.newSession({cwd: "/test/project", mcpServers: []}) + : await agent.resumeSession({sessionId: SESSION_ID, cwd: "/test/project", mcpServers: []}); + await drainScheduledWork(); + + expect(response).toBeDefined(); + expect(agent.getSessionState(SESSION_ID)).toBeDefined(); + expect(authStatusUpdates(fixture)).toEqual([]); + expect(appServer.accountLogout).not.toHaveBeenCalled(); + // One read for `initialize`, and one read for the session open. + expect(appServer.accountRead).toHaveBeenCalledTimes(2); + }); + + it("lists the sessions", async () => { + const fixture = fixtureWithSession(); + const appServer = fixture.getCodexAppServerClient(); + appServer.accountRead = vi.fn().mockRejectedValue(routingFailure()); + appServer.threadList = vi.fn().mockResolvedValue({data: [], nextCursor: null}); + await initialize(fixture); + + await expect(fixture.getCodexAcpAgent().listSessions({cwd: "/test/project"})).resolves.toMatchObject({sessions: []}); + }); + + it("answers the load after the grace time when the read waits for the backend, and applies the read later", async () => { + const fixture = fixtureWithSession(); + const appServer = fixture.getCodexAppServerClient(); + let answer: (response: GetAccountResponse) => void = () => {}; + appServer.accountRead = vi.fn() + .mockRejectedValueOnce(routingFailure()) + .mockImplementation(() => new Promise(resolve => answer = resolve)); + await initialize(fixture); + + const started = performance.now(); + await fixture.getCodexAcpAgent().loadSession({sessionId: SESSION_ID, cwd: "/test/project", mcpServers: []}); + const elapsed = performance.now() - started; + + expect(elapsed).toBeGreaterThanOrEqual(900); + expect(elapsed).toBeLessThan(5_000); + expect(historyTexts(fixture)).toEqual(["history"]); + expect(authStatusUpdates(fixture)).toEqual([]); + + answer(SIGNED_IN); + await vi.waitFor(() => expect(authStatusUpdates(fixture)).toHaveLength(1)); + expect(authStatusUpdates(fixture)[0]).toMatchObject({kind: "account"}); + expect(fixture.getCodexAcpAgent().getSessionState(SESSION_ID).account).toEqual(SIGNED_IN.account); + }); + + it("pushes the none status when a late read finds that the agent needs a login", async () => { + const fixture = fixtureWithSession(); + const appServer = fixture.getCodexAppServerClient(); + let answer: (response: GetAccountResponse) => void = () => {}; + appServer.accountRead = vi.fn() + .mockRejectedValueOnce(routingFailure()) + .mockImplementationOnce(() => new Promise(resolve => answer = resolve)) + .mockResolvedValue({account: null, requiresOpenaiAuth: true}); + await initialize(fixture); + + await fixture.getCodexAcpAgent().loadSession({sessionId: SESSION_ID, cwd: "/test/project", mcpServers: []}); + expect(authStatusUpdates(fixture)).toEqual([]); + + answer({account: null, requiresOpenaiAuth: true}); + await vi.waitFor(() => expect(authStatusUpdates(fixture)).toHaveLength(1)); + expect(authStatusUpdates(fixture)[0]).toMatchObject({kind: "none"}); + expect(appServer.accountLogout).not.toHaveBeenCalled(); + }); + + it("fails the load with the error of a refused token, as before", async () => { + const fixture = fixtureWithSession(); + const appServer = fixture.getCodexAppServerClient(); + appServer.accountRead = vi.fn().mockRejectedValue(new ResponseError(-32603, "workspace routing discovery unauthorized (401)")); + await initialize(fixture); + + await expect(fixture.getCodexAcpAgent().loadSession({sessionId: SESSION_ID, cwd: "/test/project", mcpServers: []})) + .rejects.toThrow("workspace routing discovery unauthorized (401)"); + expect(authStatusUpdates(fixture)).toEqual([]); + expect(appServer.accountLogout).not.toHaveBeenCalled(); + }); +}); diff --git a/src/__tests__/CodexThreadErrors.test.ts b/src/__tests__/CodexThreadErrors.test.ts index 4cb4ad265..0c5d84719 100644 --- a/src/__tests__/CodexThreadErrors.test.ts +++ b/src/__tests__/CodexThreadErrors.test.ts @@ -1,5 +1,7 @@ import {describe, expect, it} from "vitest"; +import {ResponseError} from "vscode-jsonrpc/node"; import { + isAccountReadUnavailableError, isInvalidThreadIdError, isMissingRolloutError, isThreadActiveWriterError, @@ -65,3 +67,29 @@ describe("CodexThreadErrors", () => { expect(isUnknownThreadError(null)).toBe(false); }); }); + +describe("isAccountReadUnavailableError", () => { + // The texts of the routing errors in codex-rs/app-server account_processor/workspace_routing.rs (rust-v0.159.1). + it.each([ + "workspace routing discovery failed", + "workspace routing discovery timed out", + "workspace routing discovery cancelled", + "workspace routing discovery cancelled during shutdown", + ])("matches the internal error %s", message => { + expect(isAccountReadUnavailableError(new ResponseError(-32603, message))).toBe(true); + }); + + it.each([ + // The backend refused the token, so this is an auth error. + new ResponseError(-32603, "workspace routing discovery unauthorized (401)"), + new ResponseError(-32603, "selected workspace missing from routing discovery"), + new ResponseError(-32600, "workspace routing discovery failed"), + new ResponseError(-32603, "account/read: workspace routing discovery failed"), + new Error("workspace routing discovery failed"), + "workspace routing discovery failed", + null, + undefined, + ])("does not match %s", error => { + expect(isAccountReadUnavailableError(error)).toBe(false); + }); +}); From d851f2611b58e10fa193773da71954c94ff1a877 Mon Sep 17 00:00:00 2001 From: Nikita Ashikhmin Date: Fri, 2 Oct 2026 13:30:14 +0400 Subject: [PATCH 16/22] fix: report a late auth failure of the account read A late account read of session/load that fails with a revoked token (401) now pushes the none status at once. A missing workspace and a missing account id count as the same auth failure. Before, the adapter read the account again, got the same error, and only logged it. When another client changed the login during the read, the adapter reads the account again. Any other late error is logged as an error and pushes nothing. A late answer applies only while its load is the current open of the session. A close or a new load of the session drops it. An auth status that came after the read started also drops it. A failed thread/resume now waits for the account read only for the rest of the 1 s grace time. Before, it waited for the whole read, up to 15 s. The last known account now follows every successful read and every account/updated, and a logout clears it. The first auth status read pushes none for a revoked token. authentication/status answers chat-gpt with the last known email when the read is unavailable. It answers unauthenticated for a revoked token. authenticate with chat-gpt or chat-gpt-device-code keeps a stored login when the read is unavailable. It starts a new login for a revoked token. The unavailable set no longer has the two "cancelled" texts. The app-server never closes the semaphore of the first one, and the second one comes from a shutdown. The KDoc says that a 403 and a 5xx of accounts/check also give "discovery failed". It also says that the 15 s timeout covers the config load and the token refresh. The grace tests use fake timers. New tests cover a late 401, a late other error, a close and a second load before the late answer, a failed resume with a pending read, and refreshAuthState with an unavailable read. --- src/AuthStatusMeta.ts | 27 ++ src/CodexAcpClient.ts | 32 +- src/CodexAcpServer.ts | 167 +++++++-- src/CodexThreadErrors.ts | 69 +++- .../CodexACPAgent/CodexAcpClient.test.ts | 3 +- .../account-read-unavailable.test.ts | 349 ++++++++++++++++-- .../CodexACPAgent/auth-status.test.ts | 21 ++ src/__tests__/CodexThreadErrors.test.ts | 46 ++- 8 files changed, 625 insertions(+), 89 deletions(-) diff --git a/src/AuthStatusMeta.ts b/src/AuthStatusMeta.ts index 639e123d2..88ca881b0 100644 --- a/src/AuthStatusMeta.ts +++ b/src/AuthStatusMeta.ts @@ -225,6 +225,33 @@ export function fromAccountUpdated( } } +/** + * Maps the `account/updated` push onto the last known account of the agent. A logout gives `null`. + * The push carries no email, so a ChatGPT account keeps the email of `previous` when `previous` is a + * ChatGPT account too. The push cannot fill the other account shapes, so the other modes keep `previous` + * until the next account read. + */ +export function accountFromUpdated(notification: AccountUpdatedNotification, previous: Account | null): Account | null { + switch (notification.authMode) { + case null: + return null; + case "chatgpt": + case "chatgptAuthTokens": { + const previousChatGpt = previous?.type === "chatgpt" ? previous : null; + return { + type: "chatgpt", + email: previousChatGpt?.email ?? null, + planType: notification.planType ?? previousChatGpt?.planType ?? "unknown", + }; + } + case "apikey": + case "personalAccessToken": + return {type: "apiKey"}; + default: + return previous; + } +} + /** * Do two payloads carry the same information? Compares every rendered field, * so a richer read of the same login is NOT the same. Callers use it to drop a diff --git a/src/CodexAcpClient.ts b/src/CodexAcpClient.ts index cae7a5bf0..0275afc2c 100644 --- a/src/CodexAcpClient.ts +++ b/src/CodexAcpClient.ts @@ -28,6 +28,7 @@ import {ModelId} from "./ModelId"; import {AgentMode} from "./AgentMode"; import path from "node:path"; import {logger} from "./Logger"; +import {isAccountReadAuthFailureError, isAccountReadUnavailableError} from "./CodexThreadErrors"; import {sanitizeMcpServerName} from "./McpServerName"; import {normalizeSessionTitle} from "./SessionTitle"; import type { @@ -220,8 +221,7 @@ export class CodexAcpClient { } private async authenticateWithChatGpt(): Promise { - const accountResponse = await this.codexClient.accountRead({refreshToken: true}); - if (accountResponse.account?.type === "chatgpt") { + if (await this.hasWorkingChatGptLogin()) { return true; } const loginCompletedPromise = this.awaitNextLoginCompleted(); @@ -233,9 +233,33 @@ export class CodexAcpClient { return result.success; } + /** + * Reads with a token refresh whether the agent has a ChatGPT login that a new login does not need to replace. + * + * An unavailable read answers `true`: only a ChatGPT login runs the routing discovery that failed, see + * {@link isAccountReadUnavailableError}, and a new login cannot run without the network either. + * A read that failed because the login does not work answers `false`, so a new login replaces it, see + * {@link isAccountReadAuthFailureError}. Another error rejects. + */ + private async hasWorkingChatGptLogin(): Promise { + try { + const accountResponse = await this.codexClient.accountRead({refreshToken: true}); + return accountResponse.account?.type === "chatgpt"; + } catch (error) { + if (isAccountReadUnavailableError(error)) { + logger.log("Account read unavailable, so the stored ChatGPT login stays", {error: String(error)}); + return true; + } + if (isAccountReadAuthFailureError(error)) { + logger.log("The stored ChatGPT login does not work, so a new login starts", {error: String(error)}); + return false; + } + throw error; + } + } + private async authenticateWithChatGptDeviceCode(urlElicitationRequester?: UrlElicitationRequester): Promise { - const accountResponse = await this.codexClient.accountRead({refreshToken: true}); - if (accountResponse.account?.type === "chatgpt") { + if (await this.hasWorkingChatGptLogin()) { return true; } if (!urlElicitationRequester) { diff --git a/src/CodexAcpServer.ts b/src/CodexAcpServer.ts index de18a4bc9..3f71d2e2d 100644 --- a/src/CodexAcpServer.ts +++ b/src/CodexAcpServer.ts @@ -10,7 +10,6 @@ import {clientSupportsUrlElicitation} from "./ElicitationCapabilities"; import {createMcpServerSignIn, type McpServerSignIn} from "./mcp/McpServerSignIn"; import {getRequestedMcpServerNames, McpSessionStartup, parseMcpStartupAwaitTimeoutMs} from "./mcp/McpSessionStartup"; import { - type AuthRequirement, CodexAcpClient, type JsonObject, OPENAI_PROVIDER_ID, @@ -19,7 +18,12 @@ import { type UrlElicitationRequester } from "./CodexAcpClient"; import {CodexAppServerClient} from "./CodexAppServerClient"; -import {isAccountReadUnavailableError, isNoActiveTurnError} from "./CodexThreadErrors"; +import { + isAccountReadAccountChangedError, + isAccountReadAuthFailureError, + isAccountReadUnavailableError, + isNoActiveTurnError, +} from "./CodexThreadErrors"; import {type CodexConnection, startCodexConnection} from "./CodexJsonRpcConnection"; import {type AcpClientConnection, ACPSessionConnection, type UpdateSessionEvent} from "./ACPSessionConnection"; import type {InputModality, ReasoningEffort, ServerNotification} from "./app-server"; @@ -67,6 +71,7 @@ import { AUTH_STATUS_UPDATE_METHOD, authStatusCapability, type AuthStatus, + type AuthenticationStatusResponse, GOAL_CONTROL_ACTIONS, GOAL_CONTROL_METHOD, GOAL_EXTENSION_VERSION, @@ -127,6 +132,7 @@ import { import {CodexSubagentEventRouter} from "./subagents/CodexSubagentEventRouter"; import {nameFromAgentPath} from "./subagents/CodexAgentPath"; import { + accountFromUpdated, fromAccount, fromAccountUpdated, gatewayStatus, @@ -243,10 +249,11 @@ const TITLE_GENERATION_SETTLE_TIMEOUT_MS = 10_000; const NO_ACTIVE_TURN_RETRY_DELAYS_MS = [25, 50, 100, 200, 400]; /** - * How long after its start `session/load` waits for the account read when `thread/resume` is done. - * A read that needs no network call ends well before this time, so a missing login still fails the load. - * A slower read waits for the ChatGPT backend, which the app-server waits for up to 15 s. - * Such a read cannot find a missing login, so the load answers without it, see `applyLateAccountRead`. + * How long after its start `session/load` waits for the account read when `thread/resume` is done or failed. + * A missing login needs no network call, so the read answers fast and the load fails with `auth_required`. + * A ChatGPT login waits for the ChatGPT backend, which the app-server waits for up to 15 s. + * Such a read can still find a login that does not work, for example a revoked token (401). + * The load then answers without the read, and `applyLateAccountRead` reports the answer later. */ const ACCOUNT_READ_LOAD_GRACE_MS = 1_000; @@ -269,6 +276,11 @@ interface ActiveAuthState { */ type KnownAccount = GetAccountResponse | "unavailable" | null; +/** True for the `auth_required` error that {@link CodexAcpServer.checkAuthorization} throws. */ +function isAuthRequiredError(error: unknown): boolean { + return error instanceof RequestError && error.code === RequestError.authRequired().code; +} + /** The value of `promise`, or `"pending"` when it does not settle within `ms`. A rejection rejects. */ async function settledWithin(promise: Promise, ms: number): Promise { let timer: NodeJS.Timeout | undefined; @@ -324,8 +336,16 @@ export class CodexAcpServer { private booleanConfigOptionsSupported: boolean; /** Last `authStatus` pushed to the client; used to suppress duplicates. */ private currentAuthStatus: AuthStatus | null; - /** The account of the last account read of a session open. A session keeps it when a read is unavailable. */ + /** + * The last known account of the agent. Every successful account read and every `account/updated` sets it, + * and a logout clears it. A session open takes it when the account read is unavailable. + */ private lastReadAccount: Account | null = null; + /** + * Counts the calls of {@link setAuthStatus}, also the calls that push nothing. A late account read + * compares it with the value at the start of the read: a change means a newer status. + */ + private authStatusVersion = 0; private readonly sessions: Map; private readonly mcpSessionStartup: McpSessionStartup; @@ -490,7 +510,7 @@ export class CodexAcpServer { } switch (methodRequest.method) { case "authentication/status": - return await this.runWithProcessCheck(() => this.codexAcpClient.getAuthenticationStatus()); + return await this.readAuthenticationStatus(); case "authentication/logout": { await this.logout({}); return {}; @@ -574,6 +594,31 @@ export class CodexAcpServer { } } + /** + * Answers `authentication/status`. + * + * An unavailable account read still tells that the agent has a ChatGPT login, because only such a login + * runs the routing discovery, see {@link isAccountReadUnavailableError}. The answer is then `chat-gpt` with + * the email of the last known account, or an empty email. A read that failed because the login does not + * work answers `unauthenticated`, see {@link isAccountReadAuthFailureError}. Another error rejects. + */ + private async readAuthenticationStatus(): Promise { + try { + return await this.runWithProcessCheck(() => this.codexAcpClient.getAuthenticationStatus()); + } catch (error) { + if (isAccountReadUnavailableError(error)) { + logger.log("Account read unavailable, so the authentication status uses the last known account", {error: String(error)}); + const account = this.lastReadAccount; + return {type: "chat-gpt", email: account?.type === "chatgpt" ? account.email ?? "" : ""}; + } + if (isAccountReadAuthFailureError(error)) { + logger.log("The account read found that the agent needs a login", {error: String(error)}); + return {type: "unauthenticated"}; + } + throw error; + } + } + /** * Logs in with the default auth request, or throws `auth_required`, when the agent needs a login. * Returns the account read that found no login needed, or `null` when there was no read or a login ran. @@ -582,14 +627,8 @@ export class CodexAcpServer { * resume or the first turn with the real error. */ async checkAuthorization(): Promise { - let requirement: AuthRequirement; - try { - requirement = await this.runWithProcessCheck(() => this.codexAcpClient.readAuthRequirement()); - } catch (error) { - if (!isAccountReadUnavailableError(error)) throw error; - logger.log("Account read unavailable, the login state is unknown", {error: String(error)}); - return "unavailable"; - } + const requirement = await this.readAccountOrUnavailable(() => this.codexAcpClient.readAuthRequirement()); + if (requirement === "unavailable") return "unavailable"; logger.log("Auth requirement checked", {authRequired: requirement.required}); if (requirement.required) { if (this.defaultAuthRequest) { @@ -875,32 +914,84 @@ export class CodexAcpServer { /** Reads the account, or returns `null` when the read failed without an answer about the login. */ private async readAccountIfAvailable(): Promise { + const response = await this.readAccountOrUnavailable(() => this.codexAcpClient.getAccount()); + return response === "unavailable" ? null : response; + } + + /** + * Runs an account read, or returns `"unavailable"` when it failed without an answer about the login, + * see {@link isAccountReadUnavailableError}. Another error rejects. + */ + private async readAccountOrUnavailable(read: () => Promise): Promise { try { - return await this.runWithProcessCheck(() => this.codexAcpClient.getAccount()); + return await this.runWithProcessCheck(read); } catch (error) { if (!isAccountReadUnavailableError(error)) throw error; logger.log("Account read unavailable, the login state is unknown", {error: String(error)}); - return null; + return "unavailable"; } } /** * Applies the account read of a load that answered before the read ended. - * A read that found no login needed sets the account of the session and pushes the auth status. - * When the read found that the agent needs a login, a new read pushes the `none` status. - * The client then shows a login request, as it does for an `auth_required` error of the load. + * + * The answer applies only while the load is the current open of the session, and while no auth status + * was set after the read started. A close or a new load of the session makes the answer old. + * A newer status, from any source, also makes it old. An old answer changes nothing. + * + * - A read that found no login needed sets the account of the session and pushes the auth status. + * - A read that found that the agent needs a login (`auth_required`), or that failed because the login + * does not work ({@link isAccountReadAuthFailureError}, for example a revoked token), clears the account + * and pushes the `none` status at once, with no second read. The client then shows a login request, + * as it does for an `auth_required` error of the load. + * - A read that failed because another client logged in or out reads the account again and pushes it. + * - An unavailable read changes nothing. + * - Any other failure is logged as an error and pushes nothing. It tells nothing sure about the login, + * so the client keeps the last status. */ - private applyLateAccountRead(sessionState: SessionState, authorization: Promise): void { + private applyLateAccountRead( + sessionState: SessionState, + authorization: Promise, + authStatusVersionAtStart: number, + ): void { + const sessionIsCurrent = () => this.sessions.get(sessionState.sessionId) === sessionState; + const answerIsCurrent = () => { + if (!sessionIsCurrent()) { + logger.log("A late account read ended after the load closed, so it changes nothing", {sessionId: sessionState.sessionId}); + return false; + } + if (this.authStatusVersion !== authStatusVersionAtStart) { + logger.log("A late account read ended after a newer auth status, so it changes nothing", {sessionId: sessionState.sessionId}); + return false; + } + return true; + }; void authorization.then( async knownAccount => { - if (knownAccount === "unavailable") return; + if (knownAccount === "unavailable" || !answerIsCurrent()) return; const authState = await this.getAuthStateForProvider(sessionState.authProvider, knownAccount); - if (this.sessions.get(sessionState.sessionId) !== sessionState) return; + if (!sessionIsCurrent()) return; sessionState.account = authState.account; sessionState.authConfigured = authState.authConfigured; }, - () => this.publishAuthStatusRead(), - ).catch(error => logger.log("Failed to apply a late account read", {error: String(error)})); + async error => { + if (!answerIsCurrent()) return; + if (isAuthRequiredError(error) || isAccountReadAuthFailureError(error)) { + logger.log("A late account read found that the agent needs a login", {error: String(error)}); + this.lastReadAccount = null; + sessionState.account = null; + sessionState.authConfigured = false; + await this.publishAuthStatus(sessionState.authProvider, null); + return; + } + if (isAccountReadAccountChangedError(error)) { + logger.log("The account changed while a late account read ran, so the adapter reads it again"); + await this.publishAuthStatusRead(); + return; + } + logger.error("A late account read failed, so the auth status stays as it was", error); + }, + ).catch(error => logger.error("Failed to apply a late account read", error)); } private authProviderUsesOpenAiAccount(authProvider: string | null): boolean { @@ -1370,12 +1461,22 @@ export class CodexAcpServer { * Never rejects: an unreadable source means "nothing to report", not an * error. The client then keeps showing the last pushed value, or "not * reported" when there was none. + * + * A read that failed because the login does not work, for example a revoked + * token, is an answer: it pushes the `none` status, see + * {@link isAccountReadAuthFailureError}. */ private async publishAuthStatusRead(): Promise { let authStatus: AuthStatus; try { authStatus = await this.readAgentAuthIdentity(); } catch (error) { + if (isAccountReadAuthFailureError(error)) { + logger.log("The account read found that the agent needs a login", {error: String(error)}); + this.lastReadAccount = null; + await this.setAuthStatus(fromAccount(null)); + return; + } logger.log("Cannot determine auth status", {error: String(error)}); return; } @@ -1399,6 +1500,7 @@ export class CodexAcpServer { return gatewayStatus(modelProvider); } const accountResponse = await this.runWithProcessCheck(() => this.codexAcpClient.getAccount()); + this.lastReadAccount = accountResponse.account; return fromAccount(accountResponse.account); } @@ -1445,6 +1547,7 @@ export class CodexAcpServer { * invalidate; only a gateway logout or a provider change does. */ private async applyAccountUpdated(notification: AccountUpdatedNotification): Promise { + this.lastReadAccount = accountFromUpdated(notification, this.lastReadAccount); try { if (this.codexAcpClient.getAuthGatewayProviderName() !== null) { return; @@ -1477,6 +1580,7 @@ export class CodexAcpServer { * so no payload can equal it. */ private async setAuthStatus(next: AuthStatus): Promise { + this.authStatusVersion += 1; if (sameAuthStatus(this.currentAuthStatus, next)) { return; } @@ -2060,6 +2164,8 @@ export class CodexAcpServer { }> { const requestedSessionGeneration = this.beginSessionOpen(request.sessionId); const loadStartedAt = performance.now(); + const graceLeftMs = () => ACCOUNT_READ_LOAD_GRACE_MS - (performance.now() - loadStartedAt); + const authStatusVersionAtStart = this.authStatusVersion; // The account read can wait for a network call of the app-server, so it runs while // `thread/resume` reads the rollout. A login with the default auth request can change // the provider of the resume, so the login keeps the order. @@ -2085,11 +2191,12 @@ export class CodexAcpServer { }) ); } catch (err) { - // The auth error comes first, as it did when the check ran before `thread/resume`. - await authorization; + // An auth error that is known within the grace time comes first, as it did when the check ran + // before `thread/resume`. A read that is still pending does not hold back the resume error. + await settledWithin(authorization, graceLeftMs()); throw err; } - knownAccount = await settledWithin(authorization, ACCOUNT_READ_LOAD_GRACE_MS - (performance.now() - loadStartedAt)); + knownAccount = await settledWithin(authorization, graceLeftMs()); } catch (err) { if (subscribed) { await this.cleanupStaleSessionOpen(request.sessionId, requestedSessionGeneration); @@ -2159,7 +2266,7 @@ export class CodexAcpServer { ); this.installSessionState(sessionState); if (knownAccount === "pending") { - this.applyLateAccountRead(sessionState, authorization); + this.applyLateAccountRead(sessionState, authorization, authStatusVersionAtStart); } subscribed = false; diff --git a/src/CodexThreadErrors.ts b/src/CodexThreadErrors.ts index 46a87a9bc..98ab9750d 100644 --- a/src/CodexThreadErrors.ts +++ b/src/CodexThreadErrors.ts @@ -101,26 +101,71 @@ const INTERNAL_ERROR_CODE = -32603; /** * The `account/read` errors of the app-server workspace routing discovery that say nothing about the login. - * The discovery runs only for a ChatGPT login that the app-server already holds. It makes a network call - * to the ChatGPT backend, so it fails when the backend is down or not reachable. - * `workspace routing discovery unauthorized (401)` is not in the set: the backend refused the token. + * The texts come from `WorkspaceRoutingError` in codex-rs/app-server `account_processor/workspace_routing.rs` + * (rust-v0.159.1). The discovery runs only for a ChatGPT login that the app-server already holds. + * It makes a network call to the ChatGPT backend (`accounts/check`). + * + * - `DiscoveryFailed` covers every failure of `accounts/check` except a 401: no connection, a network policy + * denial, a bad JSON answer, and every other HTTP status. The text has no status, so a 403 of a deactivated + * account or of a removed workspace also matches. The session then opens, and the first turn fails with + * the real error of the backend. + * - `DiscoveryTimeout` is the 15 s limit of the whole read. It also covers the config load, the wait for + * another discovery of the same account, and the token refresh after a 401. The config load is local and + * fast, the other two are network calls. + * + * Two texts of the enum are not in the set. `DiscoveryCancelled` comes only from a closed semaphore, and the + * app-server never closes it. `Shutdown` comes from an app-server that stops, so it is not a network failure. */ const ACCOUNT_READ_UNAVAILABLE_MESSAGES = new Set([ "workspace routing discovery failed", "workspace routing discovery timed out", - "workspace routing discovery cancelled", - "workspace routing discovery cancelled during shutdown", ]); /** - * True when `account/read` failed without an answer about the login, because the ChatGPT backend did not answer. - * Such a failure does not mean "not logged in". The app-server sends it as an internal error with only the text - * of the routing error, so the match needs the code and the whole message. Another phrasing does not match, - * and the error then stays an error, as before. + * The `account/read` errors that say that the login does not work, so the user must log in again. + * - `DiscoveryUnauthorized`: the backend refused the token with 401, also after a token refresh. + * - `MissingWorkspace`: the selected workspace of the login is not in the accounts of the user. + * - `MissingAccountId`: the ChatGPT login has no account id. */ -export function isAccountReadUnavailableError(err: unknown): boolean { +const ACCOUNT_READ_AUTH_FAILURE_MESSAGES = new Set([ + "workspace routing discovery unauthorized (401)", + "selected workspace missing from routing discovery", + "workspace routing requires a ChatGPT account id", +]); + +/** The `account/read` error when another client logged in or out while the read ran (`AccountChanged`). */ +const ACCOUNT_READ_ACCOUNT_CHANGED_MESSAGE = "account changed during workspace routing discovery"; + +/** The text of an app-server internal error, or `null` for another error. */ +function internalErrorText(err: unknown): string | null { if (err === null || typeof err !== "object" || (err as {code?: unknown}).code !== INTERNAL_ERROR_CODE) { - return false; + return null; } - return ACCOUNT_READ_UNAVAILABLE_MESSAGES.has(errorText(err)); + return errorText(err); +} + +/** + * True when `account/read` failed without an answer about the login, because the ChatGPT backend did not answer. + * Such a failure does not mean "not logged in". It also tells that the app-server holds a ChatGPT login, + * because only such a login runs the discovery. The app-server sends the error as an internal error with only + * the text of the routing error, so the match needs the code and the whole message. Another phrasing does not + * match, and the error then stays an error, as before. + */ +export function isAccountReadUnavailableError(err: unknown): boolean { + const text = internalErrorText(err); + return text !== null && ACCOUNT_READ_UNAVAILABLE_MESSAGES.has(text); +} + +/** + * True when `account/read` failed because the login does not work, see {@link ACCOUNT_READ_AUTH_FAILURE_MESSAGES}. + * The agent then needs a new login, as for a missing login. + */ +export function isAccountReadAuthFailureError(err: unknown): boolean { + const text = internalErrorText(err); + return text !== null && ACCOUNT_READ_AUTH_FAILURE_MESSAGES.has(text); +} + +/** True when `account/read` failed because another client logged in or out while the read ran. */ +export function isAccountReadAccountChangedError(err: unknown): boolean { + return internalErrorText(err) === ACCOUNT_READ_ACCOUNT_CHANGED_MESSAGE; } diff --git a/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts b/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts index e49cfb39e..a8693fe1f 100644 --- a/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts +++ b/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts @@ -96,10 +96,9 @@ describe('ACP server test', { timeout: 40_000 }, () => { ); expect(transportMethods).toEqual([ "account/login/start", + // The auth state refresh of authenticate. No session is open, so it only pushes the auth status. "account/read", "account/updated", - // Reads the connection auth identity for the `auth/status_update` push - // when no session is open yet. // The auth check of session/new. The session open reuses this read, so it reads the account once. "account/read", "thread/start", diff --git a/src/__tests__/CodexACPAgent/account-read-unavailable.test.ts b/src/__tests__/CodexACPAgent/account-read-unavailable.test.ts index 59fe60fa8..eb936245b 100644 --- a/src/__tests__/CodexACPAgent/account-read-unavailable.test.ts +++ b/src/__tests__/CodexACPAgent/account-read-unavailable.test.ts @@ -1,4 +1,4 @@ -import {describe, expect, it, vi} from "vitest"; +import {afterEach, beforeEach, describe, expect, it, vi} from "vitest"; import {ResponseError} from "vscode-jsonrpc/node"; import { createCodexMockTestFixture, @@ -16,6 +16,44 @@ const SIGNED_IN: GetAccountResponse = { requiresOpenaiAuth: true, } as GetAccountResponse; +/** The grace time of `session/load`, see `ACCOUNT_READ_LOAD_GRACE_MS`. */ +const ACCOUNT_READ_LOAD_GRACE_MS = 1_000; +const SIGNED_IN_STATUS: AuthStatus = {kind: "account", label: "ChatGPT Plus", account: {email: "user@example.com", plan: "plus"}}; +const NONE_STATUS: AuthStatus = {kind: "none", label: "Not logged in"}; + +/** The error of `account/read` when the backend refused the token, also after a refresh. */ +function unauthorized(): ResponseError { + return new ResponseError(-32603, "workspace routing discovery unauthorized (401)"); +} + +/** An `account/read` answer that the test gives later. */ +function pendingRead() { + let resolve: (response: GetAccountResponse) => void = () => {}; + let reject: (error: unknown) => void = () => {}; + return { + start: () => new Promise((onResolve, onReject) => { + resolve = onResolve; + reject = onReject; + }), + resolve: (response: GetAccountResponse) => resolve(response), + reject: (error: unknown) => reject(error), + }; +} + +/** Loads the session while the grace timer is fake, and checks that the load waits the whole grace time. */ +async function loadAfterGrace(fixture: CodexMockTestFixture): Promise { + let answered = false; + const load = fixture.getCodexAcpAgent() + .loadSession({sessionId: SESSION_ID, cwd: "/test/project", mcpServers: []}) + .then(() => { + answered = true; + }); + await vi.advanceTimersByTimeAsync(ACCOUNT_READ_LOAD_GRACE_MS - 1); + expect(answered).toBe(false); + await vi.advanceTimersByTimeAsync(1); + await load; +} + /** The error of `account/read` when the ChatGPT backend is not reachable. */ function routingFailure(): ResponseError { return new ResponseError(-32603, "workspace routing discovery failed"); @@ -148,47 +186,297 @@ describe("CodexACPAgent - an unavailable account read", () => { await expect(fixture.getCodexAcpAgent().listSessions({cwd: "/test/project"})).resolves.toMatchObject({sessions: []}); }); - it("answers the load after the grace time when the read waits for the backend, and applies the read later", async () => { + describe("a read that ends after the grace time of the load", () => { + beforeEach(() => { + // Only the grace timer and its clock are fake. The JSON-RPC layer and `drainScheduledWork` use `setImmediate`. + vi.useFakeTimers({toFake: ["setTimeout", "clearTimeout", "performance"]}); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("answers the load after the grace time, and applies the read later", async () => { + const fixture = fixtureWithSession(); + const appServer = fixture.getCodexAppServerClient(); + const read = pendingRead(); + appServer.accountRead = vi.fn() + .mockRejectedValueOnce(routingFailure()) + .mockImplementation(read.start); + await initialize(fixture); + + await loadAfterGrace(fixture); + + expect(historyTexts(fixture)).toEqual(["history"]); + expect(authStatusUpdates(fixture)).toEqual([]); + + read.resolve(SIGNED_IN); + await drainScheduledWork(); + expect(authStatusUpdates(fixture)).toEqual([SIGNED_IN_STATUS]); + expect(fixture.getCodexAcpAgent().getSessionState(SESSION_ID).account).toEqual(SIGNED_IN.account); + }); + + it("pushes the none status at once when a late read finds a revoked token (401)", async () => { + const fixture = fixtureWithSession(); + const appServer = fixture.getCodexAppServerClient(); + const read = pendingRead(); + appServer.accountRead = vi.fn() + .mockResolvedValueOnce(SIGNED_IN) + .mockImplementationOnce(read.start) + .mockRejectedValue(unauthorized()); + await initialize(fixture); + const agent = fixture.getCodexAcpAgent(); + + await loadAfterGrace(fixture); + expect(agent.getSessionState(SESSION_ID).account).toEqual(SIGNED_IN.account); + + read.reject(unauthorized()); + await drainScheduledWork(); + + expect(authStatusUpdates(fixture)).toEqual([SIGNED_IN_STATUS, NONE_STATUS]); + expect(agent.getSessionState(SESSION_ID).account).toBeNull(); + // One read for `initialize` and one read for the load: the 401 needs no second read. + expect(appServer.accountRead).toHaveBeenCalledTimes(2); + expect(appServer.accountLogout).not.toHaveBeenCalled(); + }); + + it("pushes the none status when a late read finds no login (a defensive case)", async () => { + // A missing login needs no network call, so its read is fast. The case covers a slow app-server. + const fixture = fixtureWithSession(); + const appServer = fixture.getCodexAppServerClient(); + const read = pendingRead(); + appServer.accountRead = vi.fn() + .mockRejectedValueOnce(routingFailure()) + .mockImplementationOnce(read.start); + await initialize(fixture); + + await loadAfterGrace(fixture); + read.resolve({account: null, requiresOpenaiAuth: true}); + await drainScheduledWork(); + + expect(authStatusUpdates(fixture)).toEqual([NONE_STATUS]); + expect(appServer.accountRead).toHaveBeenCalledTimes(2); + }); + + it("keeps the status and the account when a late read fails with another error", async () => { + const fixture = fixtureWithSession(); + const appServer = fixture.getCodexAppServerClient(); + const read = pendingRead(); + appServer.accountRead = vi.fn() + .mockResolvedValueOnce(SIGNED_IN) + .mockImplementationOnce(read.start); + await initialize(fixture); + const agent = fixture.getCodexAcpAgent(); + + await loadAfterGrace(fixture); + read.reject(new ResponseError(-32603, "duplicate workspace in routing discovery")); + await drainScheduledWork(); + + expect(authStatusUpdates(fixture)).toEqual([SIGNED_IN_STATUS]); + expect(agent.getSessionState(SESSION_ID).account).toEqual(SIGNED_IN.account); + expect(appServer.accountRead).toHaveBeenCalledTimes(2); + }); + + it("reads the account again when another client changed the login while the late read ran", async () => { + const fixture = fixtureWithSession(); + const appServer = fixture.getCodexAppServerClient(); + const read = pendingRead(); + appServer.accountRead = vi.fn() + .mockRejectedValueOnce(routingFailure()) + .mockImplementationOnce(read.start) + .mockResolvedValue(SIGNED_IN); + await initialize(fixture); + + await loadAfterGrace(fixture); + read.reject(new ResponseError(-32603, "account changed during workspace routing discovery")); + await drainScheduledWork(); + + expect(authStatusUpdates(fixture)).toEqual([SIGNED_IN_STATUS]); + expect(appServer.accountRead).toHaveBeenCalledTimes(3); + }); + + it("pushes nothing when the session closes before the late read ends", async () => { + const fixture = fixtureWithSession(); + const appServer = fixture.getCodexAppServerClient(); + const read = pendingRead(); + appServer.accountRead = vi.fn() + .mockRejectedValueOnce(routingFailure()) + .mockImplementationOnce(read.start); + await initialize(fixture); + const agent = fixture.getCodexAcpAgent(); + + await loadAfterGrace(fixture); + await agent.closeSession({sessionId: SESSION_ID}); + read.resolve(SIGNED_IN); + await drainScheduledWork(); + + expect(authStatusUpdates(fixture)).toEqual([]); + }); + + it("applies only the newest answer when the session loads twice", async () => { + const fixture = fixtureWithSession(); + const appServer = fixture.getCodexAppServerClient(); + const firstRead = pendingRead(); + const secondRead = pendingRead(); + appServer.accountRead = vi.fn() + .mockRejectedValueOnce(routingFailure()) + .mockImplementationOnce(firstRead.start) + .mockImplementationOnce(secondRead.start); + await initialize(fixture); + const agent = fixture.getCodexAcpAgent(); + + await loadAfterGrace(fixture); + const firstState = agent.getSessionState(SESSION_ID); + await loadAfterGrace(fixture); + const secondState = agent.getSessionState(SESSION_ID); + expect(secondState).not.toBe(firstState); + + secondRead.resolve(SIGNED_IN); + await drainScheduledWork(); + firstRead.resolve({account: {type: "apiKey"}, requiresOpenaiAuth: true} as GetAccountResponse); + await drainScheduledWork(); + + expect(authStatusUpdates(fixture)).toEqual([SIGNED_IN_STATUS]); + expect(agent.getSessionState(SESSION_ID).account).toEqual(SIGNED_IN.account); + expect(firstState.account).toBeNull(); + }); + + it("drops a late answer when a newer auth status came after the read started", async () => { + const fixture = fixtureWithSession(); + const appServer = fixture.getCodexAppServerClient(); + const read = pendingRead(); + appServer.accountRead = vi.fn() + .mockResolvedValueOnce(SIGNED_IN) + .mockImplementationOnce(read.start); + await initialize(fixture); + const agent = fixture.getCodexAcpAgent(); + + await loadAfterGrace(fixture); + agent.handleAccountUpdated({authMode: "apikey", planType: null}); + await drainScheduledWork(); + read.resolve(SIGNED_IN); + await drainScheduledWork(); + + expect(authStatusUpdates(fixture)).toEqual([SIGNED_IN_STATUS, {kind: "api_key", label: "OpenAI API key"}]); + }); + + it("fails the load with the resume error after the grace time when the read is still pending", async () => { + const fixture = fixtureWithSession(); + const appServer = fixture.getCodexAppServerClient(); + const read = pendingRead(); + appServer.accountRead = vi.fn() + .mockRejectedValueOnce(routingFailure()) + .mockImplementationOnce(read.start); + vi.spyOn(fixture.getCodexAcpClient(), "loadSession").mockRejectedValue(new Error("resume failed")); + await initialize(fixture); + + const load = fixture.getCodexAcpAgent().loadSession({sessionId: SESSION_ID, cwd: "/test/project", mcpServers: []}); + const outcome = load.then(() => "answered", (error: Error) => error.message); + await vi.advanceTimersByTimeAsync(ACCOUNT_READ_LOAD_GRACE_MS - 1); + await expect(Promise.race([outcome, Promise.resolve("pending")])).resolves.toBe("pending"); + await vi.advanceTimersByTimeAsync(1); + + await expect(outcome).resolves.toBe("resume failed"); + read.reject(unauthorized()); + await drainScheduledWork(); + }); + }); + + it("fails the load with the auth error when the resume fails and the read fails fast", async () => { const fixture = fixtureWithSession(); const appServer = fixture.getCodexAppServerClient(); - let answer: (response: GetAccountResponse) => void = () => {}; - appServer.accountRead = vi.fn() - .mockRejectedValueOnce(routingFailure()) - .mockImplementation(() => new Promise(resolve => answer = resolve)); + appServer.accountRead = vi.fn().mockRejectedValue(unauthorized()); + vi.spyOn(fixture.getCodexAcpClient(), "loadSession").mockRejectedValue(new Error("resume failed")); await initialize(fixture); - const started = performance.now(); - await fixture.getCodexAcpAgent().loadSession({sessionId: SESSION_ID, cwd: "/test/project", mcpServers: []}); - const elapsed = performance.now() - started; + await expect(fixture.getCodexAcpAgent().loadSession({sessionId: SESSION_ID, cwd: "/test/project", mcpServers: []})) + .rejects.toThrow("workspace routing discovery unauthorized (401)"); + }); - expect(elapsed).toBeGreaterThanOrEqual(900); - expect(elapsed).toBeLessThan(5_000); - expect(historyTexts(fixture)).toEqual(["history"]); - expect(authStatusUpdates(fixture)).toEqual([]); + it("keeps the sessions and pushes nothing when an auth refresh read is unavailable", async () => { + const fixture = fixtureWithSession(); + const appServer = fixture.getCodexAppServerClient(); + appServer.accountRead = vi.fn().mockResolvedValueOnce(SIGNED_IN).mockResolvedValueOnce(SIGNED_IN); + await initialize(fixture); + const agent = fixture.getCodexAcpAgent(); + await agent.newSession({cwd: "/test/project", mcpServers: []}); + appServer.accountRead = vi.fn().mockRejectedValue(routingFailure()); - answer(SIGNED_IN); - await vi.waitFor(() => expect(authStatusUpdates(fixture)).toHaveLength(1)); - expect(authStatusUpdates(fixture)[0]).toMatchObject({kind: "account"}); - expect(fixture.getCodexAcpAgent().getSessionState(SESSION_ID).account).toEqual(SIGNED_IN.account); + await (agent as unknown as {refreshAuthState(provider: string | null): Promise}).refreshAuthState(null); + + expect(authStatusUpdates(fixture)).toEqual([SIGNED_IN_STATUS]); + expect(agent.getSessionState(SESSION_ID).account).toEqual(SIGNED_IN.account); }); - it("pushes the none status when a late read finds that the agent needs a login", async () => { + it("pushes the none status when the first auth status read finds a revoked token", async () => { + const fixture = fixtureWithSession(); + fixture.getCodexAppServerClient().accountRead = vi.fn().mockRejectedValue(unauthorized()); + + await initialize(fixture); + + expect(authStatusUpdates(fixture)).toEqual([NONE_STATUS]); + }); + + it("opens a session with the account of account/updated when the read is unavailable", async () => { const fixture = fixtureWithSession(); const appServer = fixture.getCodexAppServerClient(); - let answer: (response: GetAccountResponse) => void = () => {}; - appServer.accountRead = vi.fn() - .mockRejectedValueOnce(routingFailure()) - .mockImplementationOnce(() => new Promise(resolve => answer = resolve)) - .mockResolvedValue({account: null, requiresOpenaiAuth: true}); + appServer.accountRead = vi.fn().mockResolvedValueOnce(SIGNED_IN).mockRejectedValue(routingFailure()); await initialize(fixture); + const agent = fixture.getCodexAcpAgent(); - await fixture.getCodexAcpAgent().loadSession({sessionId: SESSION_ID, cwd: "/test/project", mcpServers: []}); - expect(authStatusUpdates(fixture)).toEqual([]); + agent.handleAccountUpdated({authMode: null, planType: null}); + await drainScheduledWork(); + await agent.newSession({cwd: "/test/project", mcpServers: []}); - answer({account: null, requiresOpenaiAuth: true}); - await vi.waitFor(() => expect(authStatusUpdates(fixture)).toHaveLength(1)); - expect(authStatusUpdates(fixture)[0]).toMatchObject({kind: "none"}); - expect(appServer.accountLogout).not.toHaveBeenCalled(); + expect(agent.getSessionState(SESSION_ID).account).toBeNull(); + }); + + describe("authentication/status", () => { + it("answers the last known ChatGPT account when the read is unavailable", async () => { + const fixture = fixtureWithSession(); + const appServer = fixture.getCodexAppServerClient(); + appServer.accountRead = vi.fn().mockResolvedValueOnce(SIGNED_IN).mockRejectedValue(routingFailure()); + appServer.configRead = vi.fn().mockResolvedValue({config: {}}); + await initialize(fixture); + + await expect(fixture.getCodexAcpAgent().extMethod("authentication/status", {})) + .resolves.toEqual({type: "chat-gpt", email: "user@example.com"}); + }); + + it("answers unauthenticated when the read finds a revoked token", async () => { + const fixture = fixtureWithSession(); + const appServer = fixture.getCodexAppServerClient(); + appServer.accountRead = vi.fn().mockRejectedValue(unauthorized()); + appServer.configRead = vi.fn().mockResolvedValue({config: {}}); + await initialize(fixture); + + await expect(fixture.getCodexAcpAgent().extMethod("authentication/status", {})) + .resolves.toEqual({type: "unauthenticated"}); + }); + }); + + describe("authenticate with a stored ChatGPT login", () => { + const deviceCodeRequest = {methodId: "chat-gpt-device-code"}; + + it("keeps the stored login when the read is unavailable", async () => { + const fixture = fixtureWithSession(); + const appServer = fixture.getCodexAppServerClient(); + appServer.accountRead = vi.fn().mockRejectedValue(routingFailure()); + appServer.accountLogin = vi.fn(); + + await expect(fixture.getCodexAcpClient().authenticate(deviceCodeRequest)).resolves.toBe(true); + expect(appServer.accountLogin).not.toHaveBeenCalled(); + }); + + it("starts a new login when the read finds a revoked token", async () => { + const fixture = fixtureWithSession(); + const appServer = fixture.getCodexAppServerClient(); + appServer.accountRead = vi.fn().mockRejectedValue(unauthorized()); + + // Without URL elicitation the device code login stops at its first step, after the read. + await expect(fixture.getCodexAcpClient().authenticate(deviceCodeRequest)) + .rejects.toThrow("Device code authentication requires URL elicitation support"); + }); }); it("fails the load with the error of a refused token, as before", async () => { @@ -199,7 +487,8 @@ describe("CodexACPAgent - an unavailable account read", () => { await expect(fixture.getCodexAcpAgent().loadSession({sessionId: SESSION_ID, cwd: "/test/project", mcpServers: []})) .rejects.toThrow("workspace routing discovery unauthorized (401)"); - expect(authStatusUpdates(fixture)).toEqual([]); + // The read of `initialize` pushes the none status. The load pushes nothing more. + expect(authStatusUpdates(fixture)).toEqual([NONE_STATUS]); expect(appServer.accountLogout).not.toHaveBeenCalled(); }); }); diff --git a/src/__tests__/CodexACPAgent/auth-status.test.ts b/src/__tests__/CodexACPAgent/auth-status.test.ts index d2eb0e5bc..9325ca264 100644 --- a/src/__tests__/CodexACPAgent/auth-status.test.ts +++ b/src/__tests__/CodexACPAgent/auth-status.test.ts @@ -9,6 +9,7 @@ import { type MethodCallEvent, } from "../acp-test-utils"; import { + accountFromUpdated, AUTH_STATUS_META_KEY, AUTH_STATUS_UPDATE_METHOD, type AuthStatus, @@ -430,3 +431,23 @@ describe("authStatus extension", () => { }); }); }); + +describe("accountFromUpdated", () => { + const chatGpt: Account = {type: "chatgpt", email: "user@example.com", planType: "plus"}; + + it("clears the account on a logout", () => { + expect(accountFromUpdated({authMode: null, planType: null}, chatGpt)).toBeNull(); + }); + + it("keeps the email of a ChatGPT account and takes the new plan", () => { + expect(accountFromUpdated({authMode: "chatgpt", planType: "pro"}, chatGpt)) + .toEqual({type: "chatgpt", email: "user@example.com", planType: "pro"}); + expect(accountFromUpdated({authMode: "chatgpt", planType: null}, {type: "apiKey"})) + .toEqual({type: "chatgpt", email: null, planType: "unknown"}); + }); + + it("maps an API key and keeps the account for a mode with no account shape", () => { + expect(accountFromUpdated({authMode: "apikey", planType: null}, chatGpt)).toEqual({type: "apiKey"}); + expect(accountFromUpdated({authMode: "headers", planType: null}, chatGpt)).toBe(chatGpt); + }); +}); diff --git a/src/__tests__/CodexThreadErrors.test.ts b/src/__tests__/CodexThreadErrors.test.ts index 0c5d84719..509d27588 100644 --- a/src/__tests__/CodexThreadErrors.test.ts +++ b/src/__tests__/CodexThreadErrors.test.ts @@ -1,6 +1,8 @@ import {describe, expect, it} from "vitest"; import {ResponseError} from "vscode-jsonrpc/node"; import { + isAccountReadAccountChangedError, + isAccountReadAuthFailureError, isAccountReadUnavailableError, isInvalidThreadIdError, isMissingRolloutError, @@ -68,28 +70,50 @@ describe("CodexThreadErrors", () => { }); }); -describe("isAccountReadUnavailableError", () => { - // The texts of the routing errors in codex-rs/app-server account_processor/workspace_routing.rs (rust-v0.159.1). +describe("account read classifiers", () => { + // The texts of `WorkspaceRoutingError` in codex-rs/app-server account_processor/workspace_routing.rs (rust-v0.159.1). it.each([ "workspace routing discovery failed", "workspace routing discovery timed out", - "workspace routing discovery cancelled", - "workspace routing discovery cancelled during shutdown", - ])("matches the internal error %s", message => { - expect(isAccountReadUnavailableError(new ResponseError(-32603, message))).toBe(true); + ])("treats the internal error %s as unavailable", message => { + const error = new ResponseError(-32603, message); + expect(isAccountReadUnavailableError(error)).toBe(true); + expect(isAccountReadAuthFailureError(error)).toBe(false); + expect(isAccountReadAccountChangedError(error)).toBe(false); }); it.each([ - // The backend refused the token, so this is an auth error. - new ResponseError(-32603, "workspace routing discovery unauthorized (401)"), - new ResponseError(-32603, "selected workspace missing from routing discovery"), + "workspace routing discovery unauthorized (401)", + "selected workspace missing from routing discovery", + "workspace routing requires a ChatGPT account id", + ])("treats the internal error %s as an auth failure", message => { + const error = new ResponseError(-32603, message); + expect(isAccountReadAuthFailureError(error)).toBe(true); + expect(isAccountReadUnavailableError(error)).toBe(false); + }); + + it("treats the account change as its own case", () => { + const error = new ResponseError(-32603, "account changed during workspace routing discovery"); + expect(isAccountReadAccountChangedError(error)).toBe(true); + expect(isAccountReadUnavailableError(error)).toBe(false); + expect(isAccountReadAuthFailureError(error)).toBe(false); + }); + + it.each([ + // The app-server never closes the semaphore of this error, and a shutdown is not a network failure. + new ResponseError(-32603, "workspace routing discovery cancelled"), + new ResponseError(-32603, "workspace routing discovery cancelled during shutdown"), + new ResponseError(-32603, "duplicate workspace in routing discovery"), new ResponseError(-32600, "workspace routing discovery failed"), + new ResponseError(-32600, "workspace routing discovery unauthorized (401)"), new ResponseError(-32603, "account/read: workspace routing discovery failed"), new Error("workspace routing discovery failed"), - "workspace routing discovery failed", + "workspace routing discovery unauthorized (401)", null, undefined, - ])("does not match %s", error => { + ])("does not classify %s", error => { expect(isAccountReadUnavailableError(error)).toBe(false); + expect(isAccountReadAuthFailureError(error)).toBe(false); + expect(isAccountReadAccountChangedError(error)).toBe(false); }); }); From eb1e005f732d83dd999dc8d97d69ca3eab624236 Mon Sep 17 00:00:00 2001 From: Nikita Ashikhmin Date: Fri, 2 Oct 2026 16:59:11 +0400 Subject: [PATCH 17/22] fix: cut a session title to 160 characters for AIR AIR ignores an agent session title that is longer than 160 characters. It does not cut such a title. The limit of 256 characters made AIR drop a long fallback title, a long thread name and a long history title. The adapter now cuts every session title to 160 characters with an ellipsis. --- src/SessionTitle.ts | 7 +++++-- src/__tests__/SessionTitle.test.ts | 2 +- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/SessionTitle.ts b/src/SessionTitle.ts index 33cb53162..62954b295 100644 --- a/src/SessionTitle.ts +++ b/src/SessionTitle.ts @@ -1,5 +1,8 @@ -/** The maximum length of a session title that the adapter publishes, the ellipsis included. */ -export const MAX_SESSION_TITLE_LENGTH = 256; +/** + * The maximum length of a session title that the adapter publishes, the ellipsis included. + * AIR ignores a longer agent title. + */ +export const MAX_SESSION_TITLE_LENGTH = 160; /** * Collapses the whitespace of [title] and cuts it to [MAX_SESSION_TITLE_LENGTH] characters with a trailing ellipsis. diff --git a/src/__tests__/SessionTitle.test.ts b/src/__tests__/SessionTitle.test.ts index 73b15b39a..d531fd0f0 100644 --- a/src/__tests__/SessionTitle.test.ts +++ b/src/__tests__/SessionTitle.test.ts @@ -19,7 +19,7 @@ describe("normalizeSessionTitle", () => { const title = normalizeSessionTitle("a".repeat(25_023)); expect(title).toBe(`${"a".repeat(MAX_SESSION_TITLE_LENGTH - 1)}…`); - expect(title).toHaveLength(MAX_SESSION_TITLE_LENGTH); + expect(title).toHaveLength(160); }); it("does not split a surrogate pair at the cut", () => { From 37d998b55b7e2da3b59211481cfebee886577ac0 Mon Sep 17 00:00:00 2001 From: Nikita Ashikhmin Date: Fri, 2 Oct 2026 17:00:42 +0400 Subject: [PATCH 18/22] fix: count an MCP startup event without a thread id as global The tracker stored the thread id of a startup event as it came. When Codex omits the field, the key was undefined, not null. Such an event matched no thread and was not global, so the startup wait did not end. The tracker now stores a missing thread id as null. --- src/__tests__/McpStartupTracker.test.ts | 11 +++++++++++ src/mcp/McpStartupTracker.ts | 2 +- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/__tests__/McpStartupTracker.test.ts b/src/__tests__/McpStartupTracker.test.ts index 1bbec2841..8e1b39b71 100644 --- a/src/__tests__/McpStartupTracker.test.ts +++ b/src/__tests__/McpStartupTracker.test.ts @@ -68,6 +68,17 @@ describe("McpStartupTracker", () => { expect(stateThreads(tracker)).toEqual({fs: ["open-thread"], global: [null]}); }); + it("counts an event without a thread id as global", async () => { + const tracker = new McpStartupTracker(); + const startup = tracker.await(["fs"], tracker.version(), {threadId: "thread-id"}); + + const {threadId: _, ...withoutThreadId} = startupStatus("fs", null); + tracker.record(withoutThreadId as Parameters[0]); + + await expect(startup).resolves.toEqual({ready: ["fs"], failed: [], cancelled: []}); + expect(stateThreads(tracker)).toEqual({fs: [null]}); + }); + it("removes an aborted wait", async () => { const tracker = new McpStartupTracker(); const abort = new AbortController(); diff --git a/src/mcp/McpStartupTracker.ts b/src/mcp/McpStartupTracker.ts index a2bfd8d74..ee54f059b 100644 --- a/src/mcp/McpStartupTracker.ts +++ b/src/mcp/McpStartupTracker.ts @@ -60,7 +60,7 @@ export class McpStartupTracker { states = new Map(); this.states.set(params.name, states); } - states.set(params.threadId, { + states.set(params.threadId ?? null, { status: params.status, error: params.error, failureReason: params.failureReason ?? null, From 0c45e68c2939c2861e2cfe0f8790cb46f32b431d Mon Sep 17 00:00:00 2001 From: Nikita Ashikhmin Date: Fri, 2 Oct 2026 17:03:54 +0400 Subject: [PATCH 19/22] revert: keep the session title limit at 256 characters This reverts commit eb1e005. The adapter keeps the limit of 256 characters. AIR will accept a longer agent title instead. --- src/SessionTitle.ts | 7 ++----- src/__tests__/SessionTitle.test.ts | 2 +- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/src/SessionTitle.ts b/src/SessionTitle.ts index 62954b295..33cb53162 100644 --- a/src/SessionTitle.ts +++ b/src/SessionTitle.ts @@ -1,8 +1,5 @@ -/** - * The maximum length of a session title that the adapter publishes, the ellipsis included. - * AIR ignores a longer agent title. - */ -export const MAX_SESSION_TITLE_LENGTH = 160; +/** The maximum length of a session title that the adapter publishes, the ellipsis included. */ +export const MAX_SESSION_TITLE_LENGTH = 256; /** * Collapses the whitespace of [title] and cuts it to [MAX_SESSION_TITLE_LENGTH] characters with a trailing ellipsis. diff --git a/src/__tests__/SessionTitle.test.ts b/src/__tests__/SessionTitle.test.ts index d531fd0f0..73b15b39a 100644 --- a/src/__tests__/SessionTitle.test.ts +++ b/src/__tests__/SessionTitle.test.ts @@ -19,7 +19,7 @@ describe("normalizeSessionTitle", () => { const title = normalizeSessionTitle("a".repeat(25_023)); expect(title).toBe(`${"a".repeat(MAX_SESSION_TITLE_LENGTH - 1)}…`); - expect(title).toHaveLength(160); + expect(title).toHaveLength(MAX_SESSION_TITLE_LENGTH); }); it("does not split a surrogate pair at the cut", () => { From 2f2721fe4759ef12ca4f796026d9de98cd54c94a Mon Sep 17 00:00:00 2001 From: Nikita Ashikhmin Date: Fri, 2 Oct 2026 17:07:48 +0400 Subject: [PATCH 20/22] refactor: remove the server argument of /mcp reconnect Codex cannot reconnect one MCP server. config/mcpServer/reload reloads all servers. The adapter accepted a server name only to check it and to warn that the reload applies to all servers. /mcp reconnect now accepts no argument. An argument gets the same answer as an unknown subcommand. The command hint is now "[reconnect]", and the README shows /mcp reconnect. --- README.md | 2 +- .../data/available-commands-build-in.json | 2 +- .../data/available-commands-skills.json | 2 +- .../data/load-session-history.json | 2 +- .../CodexACPAgent/mcp-command.test.ts | 38 +++---------------- .../CodexACPAgent/mcp-session.test.ts | 2 +- src/__tests__/scenarios/baseline.ts | 2 +- .../scenarios/client-profiles.test.ts | 2 +- .../air/agent-message-and-reasoning.jsonl | 2 +- .../data/air/background-terminal.jsonl | 2 +- .../scenarios/data/air/collab-agent.jsonl | 2 +- .../scenarios/data/air/collab-controls.jsonl | 2 +- .../scenarios/data/air/command-approval.jsonl | 2 +- .../scenarios/data/air/command-failed.jsonl | 2 +- .../data/air/command-output-stdin.jsonl | 2 +- .../air/command-without-streamed-output.jsonl | 2 +- .../data/air/context-compaction.jsonl | 2 +- .../scenarios/data/air/dynamic-tool.jsonl | 2 +- .../data/air/file-change-approval.jsonl | 2 +- .../scenarios/data/air/file-changes.jsonl | 2 +- .../data/air/fuzzy-file-search.jsonl | 2 +- .../scenarios/data/air/goal-update.jsonl | 2 +- .../scenarios/data/air/guardian-review.jsonl | 2 +- .../scenarios/data/air/history-replay.jsonl | 2 +- .../scenarios/data/air/image-generation.jsonl | 2 +- .../scenarios/data/air/image-view.jsonl | 2 +- .../data/air/late-subagent-update.jsonl | 2 +- .../scenarios/data/air/mcp-elicitation.jsonl | 2 +- .../data/air/mcp-startup-failure.jsonl | 2 +- .../data/air/mcp-tool-approval.jsonl | 2 +- .../scenarios/data/air/mcp-tool.jsonl | 2 +- .../data/air/mcp-url-elicitation.jsonl | 2 +- .../data/air/native-subagent-session.jsonl | 2 +- .../data/air/nested-subagent-session.jsonl | 2 +- .../air/network-and-sandbox-approval.jsonl | 2 +- .../data/air/plan-deltas-and-turn-plan.jsonl | 2 +- .../data/air/plan-review-permission.jsonl | 2 +- .../scenarios/data/air/plan-stream.jsonl | 2 +- .../scenarios/data/air/read-search-list.jsonl | 2 +- .../data/air/subagent-activity.jsonl | 2 +- .../scenarios/data/air/web-search.jsonl | 2 +- src/mcp/McpCommand.ts | 19 +++------- 42 files changed, 51 insertions(+), 86 deletions(-) diff --git a/README.md b/README.md index 04af1e938..0c4ba6678 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ Use [OpenAI Codex](https://github.com/openai/codex) from [Agent Client Protocol] - All AIR extensions, capabilities, and `_meta` keys: [AIR extensions](docs/air-extensions.md). - A per-turn [agent file-change report](docs/air-extensions.md#agent-file-change-report) after capability negotiation. - Client-provided MCP servers over command-based stdio config and HTTP transport. -- Slash commands: `/status`, `/mcp` (with `/mcp reconnect [server]`), `/skills`, `/goal`, `/review`, `/review-branch`, `/review-commit`, `/compact`, and `/logout`, as well as configured skills. +- Slash commands: `/status`, `/mcp` (with `/mcp reconnect`), `/skills`, `/goal`, `/review`, `/review-branch`, `/review-commit`, `/compact`, and `/logout`, as well as configured skills. ## Installation diff --git a/src/__tests__/CodexACPAgent/data/available-commands-build-in.json b/src/__tests__/CodexACPAgent/data/available-commands-build-in.json index 0ceaecfe6..2988148fb 100644 --- a/src/__tests__/CodexACPAgent/data/available-commands-build-in.json +++ b/src/__tests__/CodexACPAgent/data/available-commands-build-in.json @@ -29,7 +29,7 @@ "name": "mcp", "description": "Show the status of the MCP servers, or reconnect them.", "input": { - "hint": "[reconnect [server]]" + "hint": "[reconnect]" } }, { diff --git a/src/__tests__/CodexACPAgent/data/available-commands-skills.json b/src/__tests__/CodexACPAgent/data/available-commands-skills.json index 36aabf5c6..8c15b6266 100644 --- a/src/__tests__/CodexACPAgent/data/available-commands-skills.json +++ b/src/__tests__/CodexACPAgent/data/available-commands-skills.json @@ -29,7 +29,7 @@ "name": "mcp", "description": "Show the status of the MCP servers, or reconnect them.", "input": { - "hint": "[reconnect [server]]" + "hint": "[reconnect]" } }, { diff --git a/src/__tests__/CodexACPAgent/data/load-session-history.json b/src/__tests__/CodexACPAgent/data/load-session-history.json index 7d663ebd3..6c022266c 100644 --- a/src/__tests__/CodexACPAgent/data/load-session-history.json +++ b/src/__tests__/CodexACPAgent/data/load-session-history.json @@ -27,7 +27,7 @@ "name": "mcp", "description": "Show the status of the MCP servers, or reconnect them.", "input": { - "hint": "[reconnect [server]]" + "hint": "[reconnect]" } }, { diff --git a/src/__tests__/CodexACPAgent/mcp-command.test.ts b/src/__tests__/CodexACPAgent/mcp-command.test.ts index f605e5841..0b450607c 100644 --- a/src/__tests__/CodexACPAgent/mcp-command.test.ts +++ b/src/__tests__/CodexACPAgent/mcp-command.test.ts @@ -176,7 +176,7 @@ describe("/mcp command", () => { await runMcp(mockFixture, "/mcp restart"); expect(list).not.toHaveBeenCalled(); - expect(messageText(mockFixture)).toBe('Command "/mcp" accepts no arguments, or `reconnect [server]`.'); + expect(messageText(mockFixture)).toBe('Command "/mcp" accepts no arguments, or `reconnect`.'); }); it("reloads the MCP configuration, waits for the startup, and shows the new status", async () => { @@ -211,34 +211,6 @@ describe("/mcp command", () => { expect(messageText(mockFixture)).toContain("**Failed**\n- `fs`: spawn ENOENT"); }); - it("explains that a reconnect of one server reloads all MCP servers", async () => { - const {mockFixture} = setupPromptTestSession({sessionId}); - mockStatusPages(mockFixture, {data: [server({name: "fs"}), server({name: "docs"})], nextCursor: null}); - const reload = vi.spyOn(mockFixture.getCodexAppServerClient(), "mcpServerReload").mockImplementation(async () => { - mockFixture.sendServerNotification(startupStatus("fs", "ready")); - mockFixture.sendServerNotification(startupStatus("docs", "ready")); - return {}; - }); - - await runMcp(mockFixture, "/mcp reconnect fs"); - - expect(reload).toHaveBeenCalledTimes(1); - expect(messageText(mockFixture)).toContain( - "Codex cannot reconnect a single server. The reload applies to all MCP servers, not only `fs`.", - ); - }); - - it("rejects an unknown server name and lists the known servers", async () => { - const {mockFixture} = setupPromptTestSession({sessionId}); - mockStatusPages(mockFixture, {data: [server({name: "fs"}), server({name: "docs"})], nextCursor: null}); - const reload = vi.spyOn(mockFixture.getCodexAppServerClient(), "mcpServerReload"); - - await runMcp(mockFixture, "/mcp reconnect github"); - - expect(reload).not.toHaveBeenCalled(); - expect(messageText(mockFixture)).toBe("Unknown MCP server `github`. Known servers: `fs`, `docs`."); - }); - it("does not reload when no MCP servers are configured", async () => { const {mockFixture} = setupPromptTestSession({sessionId}); mockStatusPages(mockFixture, {data: [], nextCursor: null}); @@ -422,16 +394,16 @@ describe("/mcp command", () => { expect(text).toContain("**Needs authentication**\n- `linear`: not signed in\n"); }); - it("rejects a reconnect with more than one server", async () => { + it("rejects a reconnect with an argument", async () => { const {mockFixture} = setupPromptTestSession({sessionId}); - const list = mockStatusPages(mockFixture, {data: [server({name: "a"}), server({name: "b"})], nextCursor: null}); + const list = mockStatusPages(mockFixture, {data: [server({name: "fs"})], nextCursor: null}); const reload = vi.spyOn(mockFixture.getCodexAppServerClient(), "mcpServerReload"); - await runMcp(mockFixture, "/mcp reconnect a b"); + await runMcp(mockFixture, "/mcp reconnect fs"); expect(list).not.toHaveBeenCalled(); expect(reload).not.toHaveBeenCalled(); - expect(messageText(mockFixture)).toBe('Command "/mcp" accepts no arguments, or `reconnect [server]`.'); + expect(messageText(mockFixture)).toBe('Command "/mcp" accepts no arguments, or `reconnect`.'); }); it("accepts the subcommand in any case, as the command name", async () => { diff --git a/src/__tests__/CodexACPAgent/mcp-session.test.ts b/src/__tests__/CodexACPAgent/mcp-session.test.ts index d187bb1ff..894142fc0 100644 --- a/src/__tests__/CodexACPAgent/mcp-session.test.ts +++ b/src/__tests__/CodexACPAgent/mcp-session.test.ts @@ -42,7 +42,7 @@ describe('MCP session configuration', { timeout: 40_000 }, () => { const newSessionResponse = await codexAcpAgent.newSession({cwd: "", mcpServers: [mcpServer]}); fixture.clearAcpConnectionDump(); - await codexAcpAgent.prompt({sessionId: newSessionResponse.sessionId, prompt: [{type: "text", text: "/mcp reconnect test-mcp"}]}); + await codexAcpAgent.prompt({sessionId: newSessionResponse.sessionId, prompt: [{type: "text", text: "/mcp reconnect"}]}); const transportDump = fixture.getAcpConnectionDump([]); expect(transportDump).contain("Reloaded the MCP configuration"); expect(transportDump).not.contain("still starting"); diff --git a/src/__tests__/scenarios/baseline.ts b/src/__tests__/scenarios/baseline.ts index 86bdd93af..a7fe89b96 100644 --- a/src/__tests__/scenarios/baseline.ts +++ b/src/__tests__/scenarios/baseline.ts @@ -130,7 +130,7 @@ const BASELINE_MCP_COMMAND = {name: "mcp", description: "List configured Model C const MCP_COMMAND = { name: "mcp", description: "Show the status of the MCP servers, or reconnect them.", - input: {hint: "[reconnect [server]]"}, + input: {hint: "[reconnect]"}, }; /** diff --git a/src/__tests__/scenarios/client-profiles.test.ts b/src/__tests__/scenarios/client-profiles.test.ts index b6b2b63d8..18e1d383a 100644 --- a/src/__tests__/scenarios/client-profiles.test.ts +++ b/src/__tests__/scenarios/client-profiles.test.ts @@ -124,7 +124,7 @@ describe("clients that are not AIR, compared with the baseline", () => { const mcp = { name: "mcp", description: "Show the status of the MCP servers, or reconnect them.", - input: {hint: "[reconnect [server]]"}, + input: {hint: "[reconnect]"}, }; expect(withFeatureChanges([commands([status, baselineMcp])])).toEqual([commands([status, mcp])]); expect(withFeatureChanges([commands([status, otherMcp])])).toEqual([commands([status, otherMcp])]); diff --git a/src/__tests__/scenarios/data/air/agent-message-and-reasoning.jsonl b/src/__tests__/scenarios/data/air/agent-message-and-reasoning.jsonl index 6ca467d0f..38325a67c 100644 --- a/src/__tests__/scenarios/data/air/agent-message-and-reasoning.jsonl +++ b/src/__tests__/scenarios/data/air/agent-message-and-reasoning.jsonl @@ -1,7 +1,7 @@ {"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} -{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect [server]]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} +{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"content":{"text":"Thinking about it","type":"text"},"messageId":"rs-1","sessionUpdate":"agent_thought_chunk"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"content":{"text":"\n\n","type":"text"},"messageId":"rs-1","sessionUpdate":"agent_thought_chunk"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"content":{"text":"raw reasoning","type":"text"},"messageId":"rs-1","sessionUpdate":"agent_thought_chunk"}}} diff --git a/src/__tests__/scenarios/data/air/background-terminal.jsonl b/src/__tests__/scenarios/data/air/background-terminal.jsonl index 1b5b054b1..11cbdf658 100644 --- a/src/__tests__/scenarios/data/air/background-terminal.jsonl +++ b/src/__tests__/scenarios/data/air/background-terminal.jsonl @@ -1,7 +1,7 @@ {"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} -{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect [server]]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} +{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"_meta":{"terminal_info":{"cwd":"/workspace","terminal_id":"bg-1"}},"content":[{"terminalId":"bg-1","type":"terminal"}],"kind":"execute","name":"exec_command","rawInput":{"command":"npm run dev","cwd":"/workspace"},"sessionUpdate":"tool_call","status":"in_progress","title":"npm run dev","toolCallId":"bg-1"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"_meta":{"terminal_output_delta":{"data":"listening on 3000\n","terminal_id":"bg-1"}},"sessionUpdate":"tool_call_update","toolCallId":"bg-1"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"session_info_update","title":"Go"}}} diff --git a/src/__tests__/scenarios/data/air/collab-agent.jsonl b/src/__tests__/scenarios/data/air/collab-agent.jsonl index ce032fb76..c9e32048a 100644 --- a/src/__tests__/scenarios/data/air/collab-agent.jsonl +++ b/src/__tests__/scenarios/data/air/collab-agent.jsonl @@ -1,6 +1,6 @@ {"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} -{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect [server]]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} +{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"session_info_update","title":"Go"}}} {"direction":"response","method":"session/prompt","params":{"_meta":{"quota":{"model_usage":[],"token_count":null}},"stopReason":"end_turn","usage":null}} diff --git a/src/__tests__/scenarios/data/air/collab-controls.jsonl b/src/__tests__/scenarios/data/air/collab-controls.jsonl index c1b726537..7767e48dc 100644 --- a/src/__tests__/scenarios/data/air/collab-controls.jsonl +++ b/src/__tests__/scenarios/data/air/collab-controls.jsonl @@ -1,7 +1,7 @@ {"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} -{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect [server]]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} +{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"capabilities":{},"name":"Weather","sessionUpdate":"subagent_spawned","subagentSessionId":"child-thread","task":"Find the weather in Paris."}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"kind":"other","rawInput":{"agentsStates":{"child-thread":{"message":null,"status":"running"}},"model":null,"prompt":null,"reasoningEffort":null,"receiverThreadIds":["child-thread"],"senderThreadId":"session-1"},"sessionUpdate":"tool_call","status":"in_progress","title":"wait","toolCallId":"wait-1"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"wait-1"}}} diff --git a/src/__tests__/scenarios/data/air/command-approval.jsonl b/src/__tests__/scenarios/data/air/command-approval.jsonl index e56b4c4f4..8d8c7bc24 100644 --- a/src/__tests__/scenarios/data/air/command-approval.jsonl +++ b/src/__tests__/scenarios/data/air/command-approval.jsonl @@ -1,7 +1,7 @@ {"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} -{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect [server]]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} +{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"_meta":{"terminal_info":{"cwd":"/workspace","terminal_id":"cmd-a"}},"content":[{"terminalId":"cmd-a","type":"terminal"}],"kind":"execute","rawInput":{"command":"npm install","cwd":"/workspace"},"sessionUpdate":"tool_call","status":"in_progress","title":"npm install","toolCallId":"cmd-a"}}} {"direction":"request","method":"session/request_permission","params":{"_meta":{"jetbrains":{"air":{"permission":{"description":"Install the dependencies.","title":"Run command?","version":1},"version":1}}},"options":[{"kind":"allow_once","name":"Yes, proceed","optionId":"allow_once"},{"kind":"allow_always","name":"Yes, and don't ask again for this command in this session","optionId":"allow_for_session"},{"kind":"reject_once","name":"No, continue without running it","optionId":"decline"},{"kind":"reject_once","name":"No, and tell Codex what to do differently","optionId":"cancel"}],"sessionId":"session-1","toolCall":{"rawInput":{"command":"npm install","cwd":"/workspace"},"title":"npm install","toolCallId":"cmd-a"}}} {"direction":"codexResponse","method":"item/commandExecution/requestApproval","params":{"decision":"accept"}} diff --git a/src/__tests__/scenarios/data/air/command-failed.jsonl b/src/__tests__/scenarios/data/air/command-failed.jsonl index 0becb5969..8b79e2c87 100644 --- a/src/__tests__/scenarios/data/air/command-failed.jsonl +++ b/src/__tests__/scenarios/data/air/command-failed.jsonl @@ -1,7 +1,7 @@ {"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} -{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect [server]]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} +{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"_meta":{"terminal_info":{"cwd":"/workspace","terminal_id":"cmd-3"}},"content":[{"terminalId":"cmd-3","type":"terminal"}],"kind":"execute","rawInput":{"command":"cat missing.txt","cwd":"/workspace"},"sessionUpdate":"tool_call","status":"in_progress","title":"cat missing.txt","toolCallId":"cmd-3"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"_meta":{"terminal_output_delta":{"data":"cat: missing.txt: No such file\n","terminal_id":"cmd-3"}},"sessionUpdate":"tool_call_update","toolCallId":"cmd-3"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"_meta":{"terminal_exit":{"exit_code":1,"signal":null,"terminal_id":"cmd-3"}},"sessionUpdate":"tool_call_update","status":"failed","toolCallId":"cmd-3"}}} diff --git a/src/__tests__/scenarios/data/air/command-output-stdin.jsonl b/src/__tests__/scenarios/data/air/command-output-stdin.jsonl index 23d359cb3..ca2716ba3 100644 --- a/src/__tests__/scenarios/data/air/command-output-stdin.jsonl +++ b/src/__tests__/scenarios/data/air/command-output-stdin.jsonl @@ -1,7 +1,7 @@ {"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} -{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect [server]]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} +{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"_meta":{"terminal_info":{"cwd":"/workspace","terminal_id":"cmd-1"}},"content":[{"terminalId":"cmd-1","type":"terminal"}],"kind":"execute","rawInput":{"command":"npm test","cwd":"/workspace"},"sessionUpdate":"tool_call","status":"in_progress","title":"npm test","toolCallId":"cmd-1"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"_meta":{"terminal_output_delta":{"data":"Running tests\n","terminal_id":"cmd-1"}},"sessionUpdate":"tool_call_update","toolCallId":"cmd-1"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"_meta":{"terminal_input":{"data":"y","terminal_id":"cmd-1"}},"sessionUpdate":"tool_call_update","toolCallId":"cmd-1"}}} diff --git a/src/__tests__/scenarios/data/air/command-without-streamed-output.jsonl b/src/__tests__/scenarios/data/air/command-without-streamed-output.jsonl index d59dc4165..662e92e6e 100644 --- a/src/__tests__/scenarios/data/air/command-without-streamed-output.jsonl +++ b/src/__tests__/scenarios/data/air/command-without-streamed-output.jsonl @@ -1,7 +1,7 @@ {"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} -{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect [server]]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} +{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"_meta":{"terminal_info":{"cwd":"/workspace","terminal_id":"cmd-2"}},"content":[{"terminalId":"cmd-2","type":"terminal"}],"kind":"execute","rawInput":{"command":"ls -la","cwd":"/workspace"},"sessionUpdate":"tool_call","status":"in_progress","title":"ls -la","toolCallId":"cmd-2"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"_meta":{"terminal_exit":{"exit_code":0,"signal":null,"terminal_id":"cmd-2"},"terminal_output_delta":{"data":"a.txt\nb.txt\n","terminal_id":"cmd-2"}},"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"cmd-2"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"session_info_update","title":"Go"}}} diff --git a/src/__tests__/scenarios/data/air/context-compaction.jsonl b/src/__tests__/scenarios/data/air/context-compaction.jsonl index b660da180..8f63ae4d9 100644 --- a/src/__tests__/scenarios/data/air/context-compaction.jsonl +++ b/src/__tests__/scenarios/data/air/context-compaction.jsonl @@ -1,7 +1,7 @@ {"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} -{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect [server]]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} +{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"_meta":{"jetbrains":{"air":{"contextCompaction":{"version":1},"version":1}}},"kind":"think","sessionUpdate":"tool_call","status":"in_progress","title":"Compact conversation","toolCallId":"compact-1"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"compact-1"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"session_info_update","title":"Go"}}} diff --git a/src/__tests__/scenarios/data/air/dynamic-tool.jsonl b/src/__tests__/scenarios/data/air/dynamic-tool.jsonl index bed3c60ea..bdbca4719 100644 --- a/src/__tests__/scenarios/data/air/dynamic-tool.jsonl +++ b/src/__tests__/scenarios/data/air/dynamic-tool.jsonl @@ -1,7 +1,7 @@ {"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} -{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect [server]]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} +{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"kind":"execute","name":"list_apps","rawInput":{"arguments":{"filter":"all"}},"sessionUpdate":"tool_call","status":"in_progress","title":"list_apps","toolCallId":"dyn-1"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"content":[{"content":{"text":"Found 2 apps","type":"text"},"type":"content"}],"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"dyn-1"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"session_info_update","title":"Go"}}} diff --git a/src/__tests__/scenarios/data/air/file-change-approval.jsonl b/src/__tests__/scenarios/data/air/file-change-approval.jsonl index 63542bf78..976142e57 100644 --- a/src/__tests__/scenarios/data/air/file-change-approval.jsonl +++ b/src/__tests__/scenarios/data/air/file-change-approval.jsonl @@ -1,7 +1,7 @@ {"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} -{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect [server]]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} +{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"content":[{"_meta":{"jetbrains":{"air":{"diffPatch":{"format":"git_patch","text":"diff --git a/workspace/src/app.ts b/workspace/src/app.ts\n--- a/workspace/src/app.ts\n+++ b/workspace/src/app.ts\n@@ -1,2 +1,2 @@\n-const a = 1;\n+const a = 2;\n export {a};\n","version":1},"version":1}},"kind":"update"},"newText":"","oldText":null,"path":"/workspace/src/app.ts","type":"diff"}],"kind":"edit","sessionUpdate":"tool_call","status":"in_progress","title":"Editing files","toolCallId":"fc-a"}}} {"direction":"request","method":"session/request_permission","params":{"_meta":{"jetbrains":{"air":{"permission":{"description":"Update the constant.","title":"Make edits?","version":1},"version":1}}},"options":[{"kind":"allow_once","name":"Yes, proceed","optionId":"allow_once"},{"kind":"allow_always","name":"Yes, and don't ask again for these files","optionId":"allow_for_session"},{"kind":"reject_once","name":"No, and tell Codex what to do differently","optionId":"cancel"}],"sessionId":"session-1","toolCall":{"locations":[{"path":"/workspace/src/app.ts"}],"title":"Editing files","toolCallId":"fc-a"}}} {"direction":"codexResponse","method":"item/fileChange/requestApproval","params":{"decision":"cancel"}} diff --git a/src/__tests__/scenarios/data/air/file-changes.jsonl b/src/__tests__/scenarios/data/air/file-changes.jsonl index edc087757..721873c90 100644 --- a/src/__tests__/scenarios/data/air/file-changes.jsonl +++ b/src/__tests__/scenarios/data/air/file-changes.jsonl @@ -1,7 +1,7 @@ {"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} -{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect [server]]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} +{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"content":[{"_meta":{"jetbrains":{"air":{"diffPatch":{"format":"git_patch","text":"diff --git a/workspace/new.txt b/workspace/new.txt\nnew file mode 100644\n--- /dev/null\n+++ b/workspace/new.txt\n@@ -0,0 +1,2 @@\n+hello\n+world\n","version":1},"version":1}},"kind":"add"},"newText":"","oldText":null,"path":"/workspace/new.txt","type":"diff"}],"kind":"edit","sessionUpdate":"tool_call","status":"in_progress","title":"Editing files","toolCallId":"fc-add"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"fc-add"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"content":[{"_meta":{"jetbrains":{"air":{"diffPatch":{"format":"git_patch","text":"diff --git a/workspace/src/app.ts b/workspace/src/app.ts\n--- a/workspace/src/app.ts\n+++ b/workspace/src/app.ts\n@@ -1,2 +1,2 @@\n-const a = 1;\n+const a = 2;\n export {a};\n","version":1},"version":1}},"kind":"update"},"newText":"","oldText":null,"path":"/workspace/src/app.ts","type":"diff"}],"kind":"edit","sessionUpdate":"tool_call","status":"in_progress","title":"Editing files","toolCallId":"fc-update"}}} diff --git a/src/__tests__/scenarios/data/air/fuzzy-file-search.jsonl b/src/__tests__/scenarios/data/air/fuzzy-file-search.jsonl index 6945c4358..10e2c474b 100644 --- a/src/__tests__/scenarios/data/air/fuzzy-file-search.jsonl +++ b/src/__tests__/scenarios/data/air/fuzzy-file-search.jsonl @@ -1,7 +1,7 @@ {"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} -{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect [server]]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} +{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"kind":"search","locations":[{"path":"/workspace/src/Handler.ts"}],"rawInput":{"query":"handler"},"sessionUpdate":"tool_call","status":"in_progress","title":"Search for 'handler'","toolCallId":"fuzzyFileSearch.search-1"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"locations":[{"path":"/workspace/src/OtherHandler.ts"}],"sessionUpdate":"tool_call_update","toolCallId":"fuzzyFileSearch.search-1"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"locations":[],"sessionUpdate":"tool_call_update","title":"Search for 'handlr'","toolCallId":"fuzzyFileSearch.search-1"}}} diff --git a/src/__tests__/scenarios/data/air/goal-update.jsonl b/src/__tests__/scenarios/data/air/goal-update.jsonl index 7c237898b..f5b9c98b0 100644 --- a/src/__tests__/scenarios/data/air/goal-update.jsonl +++ b/src/__tests__/scenarios/data/air/goal-update.jsonl @@ -1,7 +1,7 @@ {"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} -{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect [server]]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} +{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"_meta":{"jetbrains":{"air":{"goal":{"controlMethod":"_session/goal","createdAt":1710000000000,"objective":"Ship it","status":"active","timeUsedSeconds":1,"tokenBudget":null,"tokensUsed":5,"updatedAt":1710000001000},"version":1}}},"sessionUpdate":"session_info_update"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"_meta":{"jetbrains":{"air":{"goal":null,"version":1}}},"sessionUpdate":"session_info_update"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"session_info_update","title":"Go"}}} diff --git a/src/__tests__/scenarios/data/air/guardian-review.jsonl b/src/__tests__/scenarios/data/air/guardian-review.jsonl index 9b9f18cbd..c8dcd1b42 100644 --- a/src/__tests__/scenarios/data/air/guardian-review.jsonl +++ b/src/__tests__/scenarios/data/air/guardian-review.jsonl @@ -1,7 +1,7 @@ {"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} -{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect [server]]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} +{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"content":[{"content":{"text":"Status: In progress\nRisk: medium\nAuthorization: unknown\nRationale: Checking.","type":"text"},"type":"content"}],"kind":"think","rawInput":{"action":{"command":"rm -rf build","cwd":"/workspace","source":"shell","type":"command"}},"sessionUpdate":"tool_call","status":"in_progress","title":"Guardian Review","toolCallId":"guardian_assessment:review-1"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"content":[{"content":{"text":"Status: Denied\nRisk: high\nAuthorization: low\nRationale: Deletes files.","type":"text"},"type":"content"}],"sessionUpdate":"tool_call_update","status":"failed","toolCallId":"guardian_assessment:review-1"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"session_info_update","title":"Go"}}} diff --git a/src/__tests__/scenarios/data/air/history-replay.jsonl b/src/__tests__/scenarios/data/air/history-replay.jsonl index 528da37e6..cf94d19c5 100644 --- a/src/__tests__/scenarios/data/air/history-replay.jsonl +++ b/src/__tests__/scenarios/data/air/history-replay.jsonl @@ -1,7 +1,7 @@ {"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/load","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"}}} -{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect [server]]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} +{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"_meta":{"jetbrains":{"air":{"goal":null,"version":1}}},"sessionUpdate":"session_info_update"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"session_info_update","title":"Run the tests"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"content":{"text":"Run the tests","type":"text"},"messageId":"u-1","sessionUpdate":"user_message_chunk"}}} diff --git a/src/__tests__/scenarios/data/air/image-generation.jsonl b/src/__tests__/scenarios/data/air/image-generation.jsonl index 7000e0a15..61a0afeab 100644 --- a/src/__tests__/scenarios/data/air/image-generation.jsonl +++ b/src/__tests__/scenarios/data/air/image-generation.jsonl @@ -1,7 +1,7 @@ {"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} -{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect [server]]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} +{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"kind":"other","sessionUpdate":"tool_call","status":"in_progress","title":"Image generation","toolCallId":"gen-1"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"content":[{"content":{"text":"Revised prompt: A red square","type":"text"},"type":"content"},{"content":{"data":"iVBORw0KGgo=","mimeType":"image/png","type":"image","uri":"/workspace/square.png"},"type":"content"}],"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"gen-1"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"session_info_update","title":"Go"}}} diff --git a/src/__tests__/scenarios/data/air/image-view.jsonl b/src/__tests__/scenarios/data/air/image-view.jsonl index 194f4dfb9..e11635803 100644 --- a/src/__tests__/scenarios/data/air/image-view.jsonl +++ b/src/__tests__/scenarios/data/air/image-view.jsonl @@ -1,7 +1,7 @@ {"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} -{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect [server]]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} +{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"content":[{"content":{"name":"/workspace/screen.png","type":"resource_link","uri":"/workspace/screen.png"},"type":"content"}],"kind":"read","locations":[{"path":"/workspace/screen.png"}],"name":"view_image","rawInput":{"path":"/workspace/screen.png"},"sessionUpdate":"tool_call","status":"completed","title":"View Image /workspace/screen.png","toolCallId":"img-1"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"session_info_update","title":"Go"}}} {"direction":"response","method":"session/prompt","params":{"_meta":{"quota":{"model_usage":[],"token_count":null}},"stopReason":"end_turn","usage":null}} diff --git a/src/__tests__/scenarios/data/air/late-subagent-update.jsonl b/src/__tests__/scenarios/data/air/late-subagent-update.jsonl index a38415d6c..ac9c05af5 100644 --- a/src/__tests__/scenarios/data/air/late-subagent-update.jsonl +++ b/src/__tests__/scenarios/data/air/late-subagent-update.jsonl @@ -1,7 +1,7 @@ {"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} -{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect [server]]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} +{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"capabilities":{},"name":"Weather","sessionUpdate":"subagent_spawned","subagentSessionId":"child-thread","task":"Find the weather in Paris."}}} {"direction":"notify","method":"session/update","params":{"sessionId":"child-thread","update":{"_meta":{"terminal_info":{"cwd":"/workspace","terminal_id":"child-cmd"}},"content":[{"terminalId":"child-cmd","type":"terminal"}],"kind":"execute","rawInput":{"command":"curl wttr.in","cwd":"/workspace"},"sessionUpdate":"tool_call","status":"in_progress","title":"curl wttr.in","toolCallId":"child-cmd"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"subagent_state_update","state":"completed","subagentSessionId":"child-thread"}}} diff --git a/src/__tests__/scenarios/data/air/mcp-elicitation.jsonl b/src/__tests__/scenarios/data/air/mcp-elicitation.jsonl index 53f39e978..3f96046cd 100644 --- a/src/__tests__/scenarios/data/air/mcp-elicitation.jsonl +++ b/src/__tests__/scenarios/data/air/mcp-elicitation.jsonl @@ -1,7 +1,7 @@ {"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} -{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect [server]]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} +{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} {"direction":"request","method":"elicitation/create","params":{"_meta":null,"message":"Pick a color","mode":"form","requestedSchema":{"properties":{"color":{"enum":["red","blue"],"type":"string"}},"required":["color"],"type":"object"},"sessionId":"session-1"}} {"direction":"codexResponse","method":"mcpServer/elicitation/request","params":{"_meta":null,"action":"accept","content":{"color":"red"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"session_info_update","title":"Go"}}} diff --git a/src/__tests__/scenarios/data/air/mcp-startup-failure.jsonl b/src/__tests__/scenarios/data/air/mcp-startup-failure.jsonl index 9cb027d6f..19afbfa97 100644 --- a/src/__tests__/scenarios/data/air/mcp-startup-failure.jsonl +++ b/src/__tests__/scenarios/data/air/mcp-startup-failure.jsonl @@ -2,5 +2,5 @@ {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"content":[{"content":{"text":"[codex-acp forwarded startup error] MCP server `db` failed to start: boom","type":"text"},"type":"content"}],"kind":"other","sessionUpdate":"tool_call","status":"failed","title":"mcp__db__startup","toolCallId":"mcp_startup.db."}}} -{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect [server]]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} +{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"content":[{"content":{"text":"[codex-acp forwarded startup error] MCP server `slow` startup was cancelled.","type":"text"},"type":"content"}],"kind":"other","sessionUpdate":"tool_call","status":"failed","title":"mcp__slow__startup","toolCallId":"mcp_startup.slow."}}} diff --git a/src/__tests__/scenarios/data/air/mcp-tool-approval.jsonl b/src/__tests__/scenarios/data/air/mcp-tool-approval.jsonl index 195ccddd9..8e213bf1d 100644 --- a/src/__tests__/scenarios/data/air/mcp-tool-approval.jsonl +++ b/src/__tests__/scenarios/data/air/mcp-tool-approval.jsonl @@ -1,7 +1,7 @@ {"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} -{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect [server]]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} +{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"_meta":{"is_mcp_tool_call":true},"kind":"execute","rawInput":{"arguments":{"query":"acp"},"server":"docs","tool":"write"},"sessionUpdate":"tool_call","status":"in_progress","title":"mcp.docs.write","toolCallId":"mcp-3"}}} {"direction":"request","method":"session/request_permission","params":{"_meta":{"is_mcp_tool_approval":true},"options":[{"_meta":{"jetbrains":{"air":{"permission":{"description":"Run the tool and continue.","version":1},"version":1}}},"kind":"allow_once","name":"Allow","optionId":"allow_once"},{"_meta":{"jetbrains":{"air":{"permission":{"description":"Run the tool and remember this choice for this session.","version":1},"version":1}}},"kind":"allow_always","name":"Allow for this session","optionId":"allow_session"},{"_meta":{"jetbrains":{"air":{"permission":{"description":"Cancel this tool call","version":1},"version":1}}},"kind":"reject_once","name":"Cancel","optionId":"cancel"}],"sessionId":"session-1","toolCall":{"kind":"execute","status":"pending","toolCallId":"mcp-3"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","status":"in_progress","toolCallId":"mcp-3"}}} diff --git a/src/__tests__/scenarios/data/air/mcp-tool.jsonl b/src/__tests__/scenarios/data/air/mcp-tool.jsonl index b59e82444..2c6e4f7a5 100644 --- a/src/__tests__/scenarios/data/air/mcp-tool.jsonl +++ b/src/__tests__/scenarios/data/air/mcp-tool.jsonl @@ -1,7 +1,7 @@ {"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} -{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect [server]]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} +{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"_meta":{"is_mcp_tool_call":true},"kind":"execute","rawInput":{"arguments":{"query":"acp"},"server":"docs","tool":"search"},"sessionUpdate":"tool_call","status":"in_progress","title":"mcp.docs.search","toolCallId":"mcp-1"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"rawOutput":{"error":null,"result":{"_meta":null,"content":[{"text":"3 hits","type":"text"}],"structuredContent":{"hits":3}}},"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"mcp-1"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"_meta":{"is_mcp_tool_call":true},"kind":"execute","rawInput":{"arguments":{"query":"acp"},"server":"docs","tool":"fail"},"sessionUpdate":"tool_call","status":"in_progress","title":"mcp.docs.fail","toolCallId":"mcp-2"}}} diff --git a/src/__tests__/scenarios/data/air/mcp-url-elicitation.jsonl b/src/__tests__/scenarios/data/air/mcp-url-elicitation.jsonl index 2bd9f6893..5cc009be7 100644 --- a/src/__tests__/scenarios/data/air/mcp-url-elicitation.jsonl +++ b/src/__tests__/scenarios/data/air/mcp-url-elicitation.jsonl @@ -1,7 +1,7 @@ {"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} -{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect [server]]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} +{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} {"direction":"request","method":"elicitation/create","params":{"_meta":null,"elicitationId":"el-1","message":"Sign in to the docs server","mode":"url","sessionId":"session-1","url":"https://example.com/login"}} {"direction":"codexResponse","method":"mcpServer/elicitation/request","params":{"_meta":null,"action":"accept","content":null}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"session_info_update","title":"Go"}}} diff --git a/src/__tests__/scenarios/data/air/native-subagent-session.jsonl b/src/__tests__/scenarios/data/air/native-subagent-session.jsonl index bd5c7dc75..dc6966a65 100644 --- a/src/__tests__/scenarios/data/air/native-subagent-session.jsonl +++ b/src/__tests__/scenarios/data/air/native-subagent-session.jsonl @@ -1,7 +1,7 @@ {"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} -{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect [server]]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} +{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"capabilities":{},"name":"Weather","sessionUpdate":"subagent_spawned","subagentSessionId":"child-thread","task":"Find the weather in Paris."}}} {"direction":"notify","method":"session/update","params":{"sessionId":"child-thread","update":{"_meta":{"terminal_info":{"cwd":"/workspace","terminal_id":"child-cmd"}},"content":[{"terminalId":"child-cmd","type":"terminal"}],"kind":"execute","rawInput":{"command":"curl wttr.in","cwd":"/workspace"},"sessionUpdate":"tool_call","status":"in_progress","title":"curl wttr.in","toolCallId":"child-cmd"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"child-thread","update":{"_meta":{"terminal_output_delta":{"data":"Sunny\n","terminal_id":"child-cmd"}},"sessionUpdate":"tool_call_update","toolCallId":"child-cmd"}}} diff --git a/src/__tests__/scenarios/data/air/nested-subagent-session.jsonl b/src/__tests__/scenarios/data/air/nested-subagent-session.jsonl index a1113c2fd..4854dfa1f 100644 --- a/src/__tests__/scenarios/data/air/nested-subagent-session.jsonl +++ b/src/__tests__/scenarios/data/air/nested-subagent-session.jsonl @@ -1,7 +1,7 @@ {"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} -{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect [server]]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} +{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"capabilities":{},"name":"Weather","sessionUpdate":"subagent_spawned","subagentSessionId":"child-thread","task":"Find the weather in Paris."}}} {"direction":"notify","method":"session/update","params":{"sessionId":"child-thread","update":{"capabilities":{},"name":"Lyon","sessionUpdate":"subagent_spawned","subagentSessionId":"grandchild-thread","task":"Check Lyon."}}} {"direction":"notify","method":"session/update","params":{"sessionId":"grandchild-thread","update":{"_meta":{"terminal_info":{"cwd":"/workspace","terminal_id":"grandchild-cmd"}},"content":[{"terminalId":"grandchild-cmd","type":"terminal"}],"kind":"execute","rawInput":{"command":"curl wttr.in/Lyon","cwd":"/workspace"},"sessionUpdate":"tool_call","status":"in_progress","title":"curl wttr.in/Lyon","toolCallId":"grandchild-cmd"}}} diff --git a/src/__tests__/scenarios/data/air/network-and-sandbox-approval.jsonl b/src/__tests__/scenarios/data/air/network-and-sandbox-approval.jsonl index 6d2584d8b..4ae362e4c 100644 --- a/src/__tests__/scenarios/data/air/network-and-sandbox-approval.jsonl +++ b/src/__tests__/scenarios/data/air/network-and-sandbox-approval.jsonl @@ -1,7 +1,7 @@ {"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} -{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect [server]]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} +{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} {"direction":"request","method":"session/request_permission","params":{"_meta":{"jetbrains":{"air":{"permission":{"title":"Allow network access?","version":1},"version":1}}},"options":[{"kind":"allow_once","name":"Yes, just this once","optionId":"allow_once"},{"kind":"reject_once","name":"No, continue without running it","optionId":"decline"},{"kind":"reject_once","name":"No, and tell Codex what to do differently","optionId":"cancel"}],"sessionId":"session-1","toolCall":{"content":[{"content":{"text":"https access to example.com","type":"text"},"type":"content"}],"kind":"execute","locations":[{"path":"/workspace/a.txt"}],"rawInput":{"command":"curl https://example.com","cwd":"/workspace","url":"https://example.com"},"status":"pending","title":"https network access to example.com","toolCallId":"cmd-net"}}} {"direction":"codexResponse","method":"item/commandExecution/requestApproval","params":{"decision":"decline"}} {"direction":"request","method":"session/request_permission","params":{"_meta":{"jetbrains":{"air":{"permission":{"description":"Needs the cache.","title":"Grant permissions?","version":1},"version":1}}},"options":[{"kind":"allow_once","name":"Yes, grant these permissions for this turn","optionId":"allow_permissions_turn"},{"kind":"allow_once","name":"Yes, grant for this turn with strict auto review","optionId":"allow_permissions_turn_strict_auto_review"},{"kind":"allow_always","name":"Yes, grant these permissions for this session","optionId":"allow_permissions_session"},{"kind":"reject_once","name":"No, continue without permissions","optionId":"reject_permissions"}],"sessionId":"session-1","toolCall":{"content":[{"content":{"text":"Enable network access","type":"text"},"type":"content"}],"kind":"other","locations":[{"path":"/workspace/cache"}],"name":"request_permissions","rawInput":{"cwd":"/workspace","environmentId":null,"permissions":{"fileSystem":{"read":["/workspace/cache"],"write":null},"network":{"enabled":true}}},"status":"pending","title":"Additional sandbox permissions","toolCallId":"perm-1"}}} diff --git a/src/__tests__/scenarios/data/air/plan-deltas-and-turn-plan.jsonl b/src/__tests__/scenarios/data/air/plan-deltas-and-turn-plan.jsonl index 18b76288d..caddbdd1b 100644 --- a/src/__tests__/scenarios/data/air/plan-deltas-and-turn-plan.jsonl +++ b/src/__tests__/scenarios/data/air/plan-deltas-and-turn-plan.jsonl @@ -1,7 +1,7 @@ {"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} -{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect [server]]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} +{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"plan":{"content":"# Plan\n\n1. Do the thing.","planId":"plan-1","type":"markdown"},"sessionUpdate":"plan_update"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"entries":[{"content":"Read code","priority":"medium","status":"completed"},{"content":"Change code","priority":"medium","status":"in_progress"}],"sessionUpdate":"plan"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"session_info_update","title":"Go"}}} diff --git a/src/__tests__/scenarios/data/air/plan-review-permission.jsonl b/src/__tests__/scenarios/data/air/plan-review-permission.jsonl index 834de0749..6768f3389 100644 --- a/src/__tests__/scenarios/data/air/plan-review-permission.jsonl +++ b/src/__tests__/scenarios/data/air/plan-review-permission.jsonl @@ -1,7 +1,7 @@ {"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"plan","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} -{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect [server]]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} +{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"plan":{"content":"# Plan\n\n1. Make the change.","planId":"plan-2","type":"markdown"},"sessionUpdate":"plan_update"}}} {"direction":"request","method":"session/request_permission","params":{"options":[{"kind":"allow_once","name":"Yes, implement this plan","optionId":"implement_plan"},{"kind":"reject_once","name":"No, and tell Codex what to do differently","optionId":"revise_plan"}],"sessionId":"session-1","toolCall":{"kind":"switch_mode","rawInput":{"plan":"# Plan\n\n1. Make the change."},"status":"pending","title":"Implement this plan?","toolCallId":"plan-review:plan-2"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"rawOutput":"User kept the session in plan mode.","sessionUpdate":"tool_call_update","status":"completed","toolCallId":"plan-review:plan-2"}}} diff --git a/src/__tests__/scenarios/data/air/plan-stream.jsonl b/src/__tests__/scenarios/data/air/plan-stream.jsonl index 33dfe5498..e5f837f40 100644 --- a/src/__tests__/scenarios/data/air/plan-stream.jsonl +++ b/src/__tests__/scenarios/data/air/plan-stream.jsonl @@ -1,7 +1,7 @@ {"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} -{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect [server]]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} +{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"plan":{"content":"# Plan\n\n","planId":"plan-3","type":"markdown"},"sessionUpdate":"plan_update"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"_meta":{"jetbrains":{"air":{"contentDelta":"1. Read.","version":1}}},"plan":{"content":"","planId":"plan-3","type":"markdown"},"sessionUpdate":"plan_update"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"_meta":{"jetbrains":{"air":{"contentDelta":"\n2. Write.","version":1}}},"plan":{"content":"","planId":"plan-3","type":"markdown"},"sessionUpdate":"plan_update"}}} diff --git a/src/__tests__/scenarios/data/air/read-search-list.jsonl b/src/__tests__/scenarios/data/air/read-search-list.jsonl index 31d594f41..276c65d7d 100644 --- a/src/__tests__/scenarios/data/air/read-search-list.jsonl +++ b/src/__tests__/scenarios/data/air/read-search-list.jsonl @@ -1,7 +1,7 @@ {"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} -{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect [server]]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} +{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"kind":"read","locations":[{"path":"/workspace/src/app.ts"}],"sessionUpdate":"tool_call","status":"in_progress","title":"Read file '/workspace/src/app.ts'","toolCallId":"read-1"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"read-1"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"kind":"search","sessionUpdate":"tool_call","status":"in_progress","title":"Search for 'TODO' in src","toolCallId":"search-1"}}} diff --git a/src/__tests__/scenarios/data/air/subagent-activity.jsonl b/src/__tests__/scenarios/data/air/subagent-activity.jsonl index 66186780f..95dc1f0c7 100644 --- a/src/__tests__/scenarios/data/air/subagent-activity.jsonl +++ b/src/__tests__/scenarios/data/air/subagent-activity.jsonl @@ -1,7 +1,7 @@ {"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} -{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect [server]]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} +{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"capabilities":{},"name":"Weather","sessionUpdate":"subagent_spawned","subagentSessionId":"child-thread","task":"Delegated task for Weather"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"child-thread","update":{"content":{"text":"Sunny","type":"text"},"messageId":"child-msg","sessionUpdate":"agent_message_chunk"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"sessionUpdate":"subagent_state_update","state":"completed","subagentSessionId":"child-thread"}}} diff --git a/src/__tests__/scenarios/data/air/web-search.jsonl b/src/__tests__/scenarios/data/air/web-search.jsonl index 43b746b78..b393a7f3f 100644 --- a/src/__tests__/scenarios/data/air/web-search.jsonl +++ b/src/__tests__/scenarios/data/air/web-search.jsonl @@ -1,7 +1,7 @@ {"direction":"response","method":"initialize","params":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","diffPatch","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","rawInputRendering","planContentDelta"],"goal":{"actions":["set","pause","resume","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"acp":false,"http":true,"sse":false},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/codex-acp","title":"Codex","version":""},"authMethods":[{"_meta":{"api-key":{"provider":"openai"}},"description":"Use an API key to authenticate","id":"api-key","name":"API Key"},{"description":"Use ChatGPT to authenticate","id":"chat-gpt","name":"ChatGPT"},{"description":"Sign in to ChatGPT by opening a verification page and entering a one-time code","id":"chat-gpt-device-code","name":"ChatGPT (device code)"}],"protocolVersion":1}} {"direction":"notify","method":"_auth/status_update","params":{"authStatus":{"kind":"none","label":"Not logged in"}}} {"direction":"response","method":"session/new","params":{"configOptions":[{"category":"mode","currentValue":"agent","description":"Approval and sandboxing preset for the session","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","name":"Read-only","value":"read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","name":"Workspace access","value":"workspace-write"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","name":"Auto review","value":"agent"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","name":"Full access","value":"agent-full-access"}],"type":"select"},{"category":"collaboration_mode","currentValue":"default","description":"How Codex collaborates for subsequent turns","id":"collaboration_mode","name":"Collaboration mode","options":[{"name":"Default","value":"default"},{"description":"Plan before making changes","name":"Plan","value":"plan"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"model-id","version":1}}},"category":"model","currentValue":"model-id","description":"Model Codex uses for the session","id":"model","name":"Model","options":[{"description":"model-id model","name":"model-id","value":"model-id"}],"type":"select"},{"_meta":{"jetbrains":{"air":{"recommendedValue":"medium","version":1}}},"category":"thought_level","currentValue":"medium","description":"How much reasoning effort the model should use","id":"reasoning_effort","name":"Reasoning effort","options":[{"description":"Balanced","name":"Medium","value":"medium"}],"type":"select"}],"models":{"availableModels":[{"description":"model-id model Balanced","modelId":"model-id[medium]","name":"model-id (medium)"}],"currentModelId":"model-id[medium]"},"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Requires approval to edit files and access the internet.","id":"read-only","name":"Read-only"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Edit workspace files; ask before writing outside the workspace or accessing the network.","id":"workspace-write","name":"Workspace access"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Only ask for actions detected as potentially unsafe","id":"agent","name":"Auto review"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Unrestricted access to the internet and any file on your computer","id":"agent-full-access","name":"Full access"}],"currentModeId":"agent"},"sessionId":"session-1"}} -{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect [server]]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} +{"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"availableCommands":[{"_meta":{"jetbrains":{"air":{"commandAction":{"configId":"collaboration_mode","kind":"setConfigOption","presentation":"state","resetValue":"default","value":"plan"},"version":1}}},"description":"Turn plan mode on.","input":null,"name":"plan"},{"description":"Show the status of the MCP servers, or reconnect them.","input":{"hint":"[reconnect]"},"name":"mcp"},{"description":"List available skills.","input":null,"name":"skills"},{"description":"Display session configuration and token usage.","input":null,"name":"status"},{"description":"Review uncommitted changes, or review with custom instructions.","input":{"hint":"optional review instructions"},"name":"review"},{"description":"Review changes relative to a base branch.","input":{"hint":"branch name"},"name":"review-branch"},{"description":"Review a specific commit.","input":{"hint":"commit sha"},"name":"review-commit"},{"description":"Summarize conversation to avoid hitting the context limit.","input":null,"name":"compact"},{"_meta":{"jetbrains":{"air":{"commandAction":{"kind":"prefixPrompt","presentation":"state"},"version":1}}},"description":"Set a goal to keep pursuing.","input":{"hint":"[|clear|pause|resume]"},"name":"goal"},{"description":"Rename the current session.","input":{"hint":"new name"},"name":"rename"},{"description":"Sign out of Codex. This option is available when you are logged in via ChatGPT.","input":null,"name":"logout"}],"sessionUpdate":"available_commands_update"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"kind":"search","rawInput":{"action":null,"query":""},"sessionUpdate":"tool_call","status":"in_progress","title":"Web search","toolCallId":"web-1"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"rawInput":{"action":{"queries":null,"query":"acp protocol","type":"search"},"query":"acp protocol"},"sessionUpdate":"tool_call_update","status":"completed","title":"Web search: acp protocol","toolCallId":"web-1"}}} {"direction":"notify","method":"session/update","params":{"sessionId":"session-1","update":{"kind":"search","rawInput":{"action":null,"query":""},"sessionUpdate":"tool_call","status":"in_progress","title":"Web search","toolCallId":"web-2"}}} diff --git a/src/mcp/McpCommand.ts b/src/mcp/McpCommand.ts index 38dda0543..9a5659cec 100644 --- a/src/mcp/McpCommand.ts +++ b/src/mcp/McpCommand.ts @@ -18,7 +18,7 @@ import { /** The maximum time that `/mcp reconnect` waits for the MCP servers to start. */ export const MCP_RECONNECT_STARTUP_TIMEOUT_MS = 30_000; -export const MCP_COMMAND_INPUT_HINT = "[reconnect [server]]"; +export const MCP_COMMAND_INPUT_HINT = "[reconnect]"; /** The MCP servers of a session. `live` is false when the list has no live status of the session thread. */ type McpServerList = { @@ -33,7 +33,7 @@ type ReloadResult = { list: McpServerList; }; -/** Runs `/mcp` and `/mcp reconnect [server]`, and returns the markdown answer. */ +/** Runs `/mcp` and `/mcp reconnect`, and returns the markdown answer. */ export class McpCommand { private readonly codexAcpClient: CodexAcpClient; private readonly runWithProcessCheck: (operation: () => Promise) => Promise; @@ -56,26 +56,19 @@ export class McpCommand { const list = await this.listServers(sessionState); return signal?.aborted ? null : formatStatus(list.servers, new Map()); } - if (args[0]!.toLowerCase() === "reconnect" && args.length <= 2) { - return await this.reconnect(sessionState, args[1] ?? null, signal); + if (args.length === 1 && args[0]!.toLowerCase() === "reconnect") { + return await this.reconnect(sessionState, signal); } - return `Command "/mcp" accepts no arguments, or \`reconnect [server]\`.`; + return `Command "/mcp" accepts no arguments, or \`reconnect\`.`; } - private async reconnect(sessionState: SessionState, serverName: string | null, signal?: AbortSignal): Promise { + private async reconnect(sessionState: SessionState, signal?: AbortSignal): Promise { const before = await this.listServers(sessionState); if (before.servers.length === 0) { return NO_SERVERS_MESSAGE; } - if (serverName !== null && !before.servers.some(server => server.name === serverName)) { - const knownNames = before.servers.map(server => inlineCode(server.name)).join(", "); - return `Unknown MCP server ${inlineCode(serverName)}. Known servers: ${knownNames}.`; - } const notes: string[] = []; - if (serverName !== null) { - notes.push(`Codex cannot reconnect a single server. The reload applies to all MCP servers, not only ${inlineCode(serverName)}.`); - } let reload = await this.reloadAndWait(sessionState, signal); if (reload.error !== null) { From 32eb679292c66117977083f3322a7938a1c20d4e Mon Sep 17 00:00:00 2001 From: Nikita Ashikhmin Date: Fri, 2 Oct 2026 17:11:59 +0400 Subject: [PATCH 21/22] refactor: simplify the MCP error text cleanup The cleanup now collapses the whitespace, removes the wrapper segments and the Rust type paths, keeps two sentences, and cuts the text to 160 characters. One regular expression finds the wrapper segments, and one finds a type path with three or more parts. The cleanup no longer parses the generic arguments, removes repeated segments, or skips the sentence ends after e.g., i.e., etc. and list numbers. The ijproxy error, the startup errors and the HTTP status text do not change. Some texts change: - The Glean transport error gives "Send message error Transport Auth: not logged in". Before, it gave "Send message error: not logged in". - "JSON-RPC error: 401: Unauthorized" gives "401: Unauthorized". Before, it gave "Unauthorized". - A repeated segment stays, such as "timeout: timeout after 30s". - A two-part type path in brackets stays, such as "[rmcp::Transport]". - A sentence can end after e.g. or etc. The cleanup still reads at most 2000 characters of the raw text. --- src/__tests__/McpStatusMarkdown.test.ts | 85 ++++------------ src/mcp/McpStatusMarkdown.ts | 124 +++--------------------- 2 files changed, 34 insertions(+), 175 deletions(-) diff --git a/src/__tests__/McpStatusMarkdown.test.ts b/src/__tests__/McpStatusMarkdown.test.ts index 3d2dc8804..eb0a56cef 100644 --- a/src/__tests__/McpStatusMarkdown.test.ts +++ b/src/__tests__/McpStatusMarkdown.test.ts @@ -27,73 +27,43 @@ describe("cleanErrorMessage", () => { }); it("removes the wrappers and the Rust type paths of the Glean error", () => { - expect(cleanErrorMessage(GLEAN_ERROR)).toBe("Send message error: not logged in"); + expect(cleanErrorMessage(GLEAN_ERROR)).toBe("Send message error Transport Auth: not logged in"); + expect(cleanErrorMessage("expected std::vec::Vec here, got a string")).toBe("expected here, got a string"); }); it("keeps the cause after a message that is not a wrapper", () => { expect(cleanErrorMessage("failed to refresh MCP servers: invalid config.toml")).toBe("failed to refresh MCP servers: invalid config.toml"); - }); - - it("removes the MCP client wrapper of a startup error", () => { expect(cleanErrorMessage("MCP client for `fs` failed to start: spawn ENOENT")).toBe("spawn ENOENT"); }); - it("puts the text on one line and shortens it with an ellipsis", () => { - expect(cleanErrorMessage(`first\nsecond ${"x".repeat(300)}`)).toBe(`first second ${"x".repeat(160 - "first second ".length)}…`); - }); - - it("returns the shortened raw text when only wrappers remain", () => { - expect(cleanErrorMessage("JSON-RPC error: -32603")).toBe("JSON-RPC error: -32603"); - }); - - it("keeps a lone less-than sign", () => { - expect(cleanErrorMessage("expected value < 10, got 12")).toBe("expected value < 10, got 12"); - }); - - it("keeps a generic type without a type path", () => { - expect(cleanErrorMessage("invalid header: Header value contains Vec bytes")) - .toBe("invalid header: Header value contains Vec bytes"); - }); - - it("keeps an IPv6 URL", () => { - expect(cleanErrorMessage("failed to connect to http://[::1]:8080/mcp: Connection refused")) - .toBe("failed to connect to http://[::1]:8080/mcp: Connection refused"); - expect(cleanErrorMessage("failed to connect to http://[fe80::1]:8080/mcp")).toBe("failed to connect to http://[fe80::1]:8080/mcp"); + it("keeps an HTTP status and removes a JSON-RPC code", () => { + expect(cleanErrorMessage("server returned status: 401")).toBe("server returned status: 401"); + expect(cleanErrorMessage("request failed: -32601: Method not found")).toBe("request failed: Method not found"); }); - it("removes a closed type path and keeps the text after it", () => { - expect(cleanErrorMessage("expected std::vec::Vec here, got a string")).toBe("expected here, got a string"); - expect(cleanErrorMessage("send failed Transport [rmcp::Transport>] now")).toBe("send failed now"); + it("keeps the text that is not a Rust type path", () => { + for (const text of [ + "expected value < 10, got 12", + "invalid header: Header value contains Vec bytes", + "failed to connect to http://[::1]:8080/mcp: Connection refused", + "connect to fe80::abcd failed", + "Can't locate Foo::Bar in @INC", + ]) { + expect(cleanErrorMessage(text)).toBe(text); + } }); - it("returns an empty text for an empty error", () => { - expect(cleanErrorMessage("")).toBe(""); + it("returns the raw text when only wrappers remain, and an empty text for an empty error", () => { + expect(cleanErrorMessage("JSON-RPC error: -32603")).toBe("JSON-RPC error: -32603"); expect(cleanErrorMessage(" \n ")).toBe(""); }); - it("shortens by code points and never splits a surrogate pair", () => { - const text = `${"x".repeat(159)}😀😀`; - - expect(cleanErrorMessage(text)).toBe(`${"x".repeat(159)}😀…`); - }); - - it("drops an earlier segment that the next segment repeats and extends", () => { - expect(cleanErrorMessage("timeout: timeout after 30s")).toBe("timeout after 30s"); - expect(cleanErrorMessage("time: timeout after 30s")).toBe("time: timeout after 30s"); - }); - - it("does not end a sentence at e.g. or i.e.", () => { - expect(cleanErrorMessage("Bad flag, e.g. --foo is unknown. Use i.e. the default. Third sentence.")) - .toBe("Bad flag, e.g. --foo is unknown. Use i.e. the default."); - }); - - it("does not end a sentence at a list number or at etc.", () => { - expect(cleanErrorMessage("Step 1. Open it! Then ()")).toBe("Step 1. Open it! Then ()"); - expect(cleanErrorMessage("Bad flags a, b, etc. are unknown. Use the defaults. Third sentence.")) - .toBe("Bad flags a, b, etc. are unknown. Use the defaults."); + it("keeps two sentences on one line and shortens the text by code points", () => { + expect(cleanErrorMessage("First one.\nSecond one! Third one.")).toBe("First one. Second one!"); + expect(cleanErrorMessage(`${"x".repeat(159)}😀😀`)).toBe(`${"x".repeat(159)}😀…`); }); - it("cleans a long error with many segments fast", () => { + it("cleans a long error fast", () => { const text = Array.from({length: 20000}, (_, index) => `part ${index} x`).join(": "); const start = performance.now(); @@ -103,19 +73,6 @@ describe("cleanErrorMessage", () => { expect(cleaned.startsWith("part 0 x: part 1 x")).toBe(true); expect(cleaned.endsWith("…")).toBe(true); }); - - it("keeps the text with :: that is not a Rust type path", () => { - expect(cleanErrorMessage("connect to fe80::abcd failed")).toBe("connect to fe80::abcd failed"); - expect(cleanErrorMessage("bad value 'a::b' in config")).toBe("bad value 'a::b' in config"); - expect(cleanErrorMessage("Can't locate Foo::Bar in @INC")).toBe("Can't locate Foo::Bar in @INC"); - }); - - it("keeps an HTTP status and removes a JSON-RPC code", () => { - expect(cleanErrorMessage("server returned status: 401")).toBe("server returned status: 401"); - expect(cleanErrorMessage("HTTP error: 503")).toBe("HTTP error: 503"); - expect(cleanErrorMessage("JSON-RPC error: 401: Unauthorized")).toBe("Unauthorized"); - expect(cleanErrorMessage("request failed: -32601: Method not found")).toBe("request failed: Method not found"); - }); }); describe("formatStatus", () => { diff --git a/src/mcp/McpStatusMarkdown.ts b/src/mcp/McpStatusMarkdown.ts index fdb2735d4..949423de6 100644 --- a/src/mcp/McpStatusMarkdown.ts +++ b/src/mcp/McpStatusMarkdown.ts @@ -140,132 +140,34 @@ const MAX_ERROR_LENGTH = 160; const MAX_ERROR_SENTENCES = 2; -/** - * The maximum length of the raw error text that the cleanup reads. - * The cleanup compares each segment with the earlier ones, so a long text without a limit blocks the event loop. - */ +/** The maximum length of the raw error text that the cleanup reads. */ const MAX_INPUT_LENGTH = 2000; -const JSON_RPC_WRAPPER = /^json-rpc error$/i; - -const NUMERIC_CODE = /^-?\d+$/; - -/** The error chain segments that only wrap the real message. */ -const WRAPPER_SEGMENTS = [ - /^tool discovery failed$/i, - /^mcp startup failed$/i, - /^handshaking with mcp server failed$/i, - JSON_RPC_WRAPPER, - /^startup error$/i, - /^error$/i, - /^mcp client for .+ failed to start$/i, - // A JSON-RPC error code. An HTTP status, such as `401`, stays. - /^-32\d{3}$/, -]; - -const IDENTIFIER = String.raw`[A-Za-z_]\w*`; - -/** - * Finds a Rust type path, such as `crate::module::Type`. - * A path in a `Transport [` prefix can have two parts. Other paths need three parts or generic arguments. - * Thus a short path of another language, such as the Perl `Foo::Bar`, and an IPv6 address, such as `fe80::abcd`, stay. - * The lookbehind skips an IPv6 address in a URL, such as `[fe80::1]`, and a quoted value, such as `'a::b'`. - */ -const TYPE_PATH = new RegExp( - String.raw`(?:\b[A-Z]\w*\s*\[\s*${IDENTIFIER}(?:::${IDENTIFIER})+` - + String.raw`|(?]`. + * A short path, such as the Perl `Foo::Bar`, and an IPv6 address, such as `fe80::abcd`, stay. */ -function removeTypePaths(segment: string): string { - let result = ""; - let position = 0; - TYPE_PATH.lastIndex = 0; - for (let match = TYPE_PATH.exec(segment); match !== null; match = TYPE_PATH.exec(segment)) { - result += segment.slice(position, match.index); - let end = match.index + match[0].length; - if (segment[end] === "<") { - end = genericsEnd(segment, end); - if (end < 0) { - return result.trim(); - } - } - if (match[0].includes("[")) { - const bracket = /^\s*]/.exec(segment.slice(end)); - end += bracket === null ? 0 : bracket[0].length; - } - position = end; - TYPE_PATH.lastIndex = end; - } - return (result + segment.slice(position)).replace(/\s+/g, " ").trim(); -} - -/** Returns the index after the `>` that closes the `<` at `start`, or -1 when the text has no such `>`. */ -function genericsEnd(text: string, start: number): number { - let depth = 0; - for (let index = start; index < text.length; index++) { - if (text[index] === "<") { - depth++; - } else if (text[index] === ">") { - depth--; - if (depth === 0) { - return index + 1; - } - } - } - return -1; -} +const TYPE_PATH = /\S*\w::\w+::\S*/g; /** * Makes an MCP error chain short and readable, on one line. - * It removes the wrapper segments, the numeric codes, the Rust type paths, and the repeated messages. + * It removes the wrapper segments and the Rust type paths. * It keeps at most two sentences and about 160 characters. * When nothing remains, it returns the shortened raw text. */ export function cleanErrorMessage(text: string): string { const oneLine = text.slice(0, MAX_INPUT_LENGTH).replace(/\s+/g, " ").trim(); - const kept: string[] = []; - let afterJsonRpcWrapper = false; - for (const rawSegment of oneLine.split(": ")) { - const segment = removeTypePaths(rawSegment.trim()); - // The code after a `JSON-RPC error` wrapper is a JSON-RPC code, also outside the reserved range. - const isJsonRpcCode = afterJsonRpcWrapper && NUMERIC_CODE.test(segment); - afterJsonRpcWrapper = JSON_RPC_WRAPPER.test(segment); - if (segment.length === 0 || isJsonRpcCode || WRAPPER_SEGMENTS.some(pattern => pattern.test(segment))) { - continue; - } - const withoutEllipsis = withoutTrailingEllipsis(segment); - if (kept.some(previous => previous.startsWith(withoutEllipsis))) { - continue; - } - // A segment that repeats an earlier one and adds more text replaces it, such as `timeout: timeout after 30s`. - const repeated = kept.findIndex(previous => startsWithWords(segment, withoutTrailingEllipsis(previous))); - if (repeated >= 0) { - kept.splice(repeated, 1); - } - kept.push(segment); - } - if (kept.length === 0) { + const message = oneLine.split(": ") + .map(segment => segment.replace(TYPE_PATH, "").replace(/\s+/g, " ").trim()) + .filter(segment => segment.length > 0 && !WRAPPER_SEGMENT.test(segment)) + .join(": "); + if (message.length === 0) { return shorten(oneLine); } - const sentences = kept.join(": ").split(SENTENCE_END); - return shorten(sentences.slice(0, MAX_ERROR_SENTENCES).join(" ")); -} - -function withoutTrailingEllipsis(text: string): string { - return text.replace(/(?:\.\.\.|…)$/, "").trim(); -} - -/** True when `text` starts with `prefix` and the prefix ends at a word end. */ -function startsWithWords(text: string, prefix: string): boolean { - return prefix.length > 0 && text.startsWith(prefix) && (text.length === prefix.length || /\W/.test(text[prefix.length]!)); + return shorten(message.split(/(?<=[.!?])\s+/).slice(0, MAX_ERROR_SENTENCES).join(" ")); } /** Shortens the text by code points, so that the cut never splits a surrogate pair. */ From 536f6271a79d75ad2688ab9fcb09c494c8436f23 Mon Sep 17 00:00:00 2001 From: Nikita Ashikhmin Date: Fri, 2 Oct 2026 17:17:33 +0400 Subject: [PATCH 22/22] refactor: use one helper to wait for a promise with a timeout raceMcpStartupTimeout and settledWithin had the same behaviour. Both end at the value or at the timeout, and both reject only for a rejection before the timeout. settledWithin moves to StdUtils, and the MCP session startup uses it. The wait of /mcp reconnect stays as it is. It aborts the startup wait at the timeout, so that the tracker releases the waiter. --- src/CodexAcpServer.ts | 14 +------------- src/StdUtils.ts | 16 ++++++++++++++++ src/__tests__/StdUtils.test.ts | 22 ++++++++++++++++++++-- src/mcp/McpSessionStartup.ts | 33 ++------------------------------- 4 files changed, 39 insertions(+), 46 deletions(-) diff --git a/src/CodexAcpServer.ts b/src/CodexAcpServer.ts index 3f71d2e2d..5590c6338 100644 --- a/src/CodexAcpServer.ts +++ b/src/CodexAcpServer.ts @@ -64,6 +64,7 @@ import {CodexCommands, GOAL_CONTINUATION_PROMPT} 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 { @@ -281,19 +282,6 @@ function isAuthRequiredError(error: unknown): boolean { return error instanceof RequestError && error.code === RequestError.authRequired().code; } -/** The value of `promise`, or `"pending"` when it does not settle within `ms`. A rejection rejects. */ -async function settledWithin(promise: Promise, ms: number): Promise { - let timer: NodeJS.Timeout | undefined; - const timeout = new Promise<"pending">(resolve => { - timer = setTimeout(() => resolve("pending"), Math.max(0, ms)); - }); - try { - return await Promise.race([promise, timeout]); - } finally { - clearTimeout(timer); - } -} - interface PendingTurnStart { promise: Promise; resolve: (turnId: string | null) => void; diff --git a/src/StdUtils.ts b/src/StdUtils.ts index fe5e25d10..5b8b88002 100644 --- a/src/StdUtils.ts +++ b/src/StdUtils.ts @@ -150,3 +150,19 @@ class LineSplitter { } } } + +/** + * The value of `promise`, or `"pending"` when it does not settle within `ms`. + * A rejection before the timeout rejects. A later rejection is ignored. + */ +export async function settledWithin(promise: Promise, ms: number): Promise { + let timer: NodeJS.Timeout | undefined; + const timeout = new Promise<"pending">(resolve => { + timer = setTimeout(() => resolve("pending"), Math.max(0, ms)); + }); + try { + return await Promise.race([promise, timeout]); + } finally { + clearTimeout(timer); + } +} diff --git a/src/__tests__/StdUtils.test.ts b/src/__tests__/StdUtils.test.ts index ac42cb0b0..6c566e8cc 100644 --- a/src/__tests__/StdUtils.test.ts +++ b/src/__tests__/StdUtils.test.ts @@ -1,6 +1,6 @@ import {PassThrough, Writable} from "node:stream"; -import {describe, expect, it} from "vitest"; -import {createJsonStream, createJSONRPCReader, createLineWriter} from "../StdUtils"; +import {describe, expect, it, vi} from "vitest"; +import {createJsonStream, createJSONRPCReader, createLineWriter, settledWithin} from "../StdUtils"; function read(chunks: Buffer[]): Promise { const stream = new PassThrough(); @@ -161,3 +161,21 @@ describe("createJsonStream", () => { expect(await settled(writer.write({jsonrpc: "2.0", id: 1, result: {}}))).toBe("rejected: write EPIPE"); }); }); + +describe("settledWithin", () => { + it("returns the value, or pending at the timeout, and rejects only before the timeout", async () => { + vi.useFakeTimers(); + try { + await expect(settledWithin(Promise.resolve("done"), 100)).resolves.toBe("done"); + await expect(settledWithin(Promise.reject(new Error("early")), 100)).rejects.toThrow("early"); + + let rejectLate!: (error: Error) => void; + const late = settledWithin(new Promise((_, reject) => { rejectLate = reject; }), 100); + await vi.advanceTimersByTimeAsync(100); + rejectLate(new Error("late")); + await expect(late).resolves.toBe("pending"); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/src/mcp/McpSessionStartup.ts b/src/mcp/McpSessionStartup.ts index 1804e8d44..4b55bab58 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 {settledWithin} from "../StdUtils"; import {AcpToolCallRenderer} from "../tool-calls/AcpToolCallRenderer"; import type {ClientCapabilities} from "../tool-calls/ClientCapabilities"; import {McpStartupReporter} from "../tool-calls/reporters/McpStartupReporter"; @@ -71,7 +72,7 @@ export class McpSessionStartup { } const awaitTimeoutMs = options.awaitTimeoutMs; if (awaitTimeoutMs !== undefined && awaitTimeoutMs > 0) { - const startupWait = raceMcpStartupTimeout(pendingStartup.startup, awaitTimeoutMs); + const startupWait = settledWithin(pendingStartup.startup, awaitTimeoutMs).then(() => undefined); // These handlers run before the caller resumes, because the caller awaits the same promise after them. void startupWait.then( () => this.publishOrAbort(sessionId, pendingStartup, options.publish), @@ -202,33 +203,3 @@ export function parseMcpStartupAwaitTimeoutMs(meta: Record | nu const value = meta?.[MCP_STARTUP_AWAIT_TIMEOUT_META_KEY]; return typeof value === "number" && Number.isFinite(value) ? value : undefined; } - -// Resolves once `startup` settles, or once `timeoutMs` elapses, whichever comes first. -// A startup rejection is only propagated if it happens before the timeout. -function raceMcpStartupTimeout(startup: Promise, timeoutMs: number): Promise { - return new Promise((resolve, reject) => { - let settled = false; - const timer = setTimeout(() => { - if (!settled) { - settled = true; - resolve(); - } - }, timeoutMs); - startup.then( - () => { - if (!settled) { - settled = true; - clearTimeout(timer); - resolve(); - } - }, - (err) => { - if (!settled) { - settled = true; - clearTimeout(timer); - reject(err); - } - }, - ); - }); -}