diff --git a/README.md b/README.md index 9e514677..0c4ba667 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`), `/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 e716db9f..79f4a593 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. @@ -186,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 | 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 @@ -991,6 +993,9 @@ 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`. + 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/docs/mcp-startup-await-timeout.md b/docs/mcp-startup-await-timeout.md index 06f6e9cf..a20c9130 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/AirExtension.ts b/src/AirExtension.ts index b97255fb..7870a17a 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/AuthStatusMeta.ts b/src/AuthStatusMeta.ts index 639e123d..88ca881b 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 ecdf1b86..0275afc2 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 { @@ -28,12 +28,15 @@ 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 { AccountLoginCompletedNotification, AccountUpdatedNotification, GetAccountRateLimitsResponse, GetAccountResponse, + ListMcpServerStatusParams, ListMcpServerStatusResponse, McpServerOauthLoginCompletedNotification, McpServerOauthLoginParams, @@ -105,6 +108,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; @@ -210,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(); @@ -223,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) { @@ -349,17 +383,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}; } /** @@ -837,12 +872,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 +1181,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 +1199,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 { @@ -1190,7 +1235,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 a43f9ab3..5590c633 100644 --- a/src/CodexAcpServer.ts +++ b/src/CodexAcpServer.ts @@ -7,6 +7,8 @@ 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 {getRequestedMcpServerNames, McpSessionStartup, parseMcpStartupAwaitTimeoutMs} from "./mcp/McpSessionStartup"; import { CodexAcpClient, type JsonObject, @@ -15,14 +17,20 @@ import { type SessionMetadataWithThread, type UrlElicitationRequester } from "./CodexAcpClient"; -import {CodexAppServerClient, type McpStartupResult} from "./CodexAppServerClient"; -import {isNoActiveTurnError} from "./CodexThreadErrors"; +import {CodexAppServerClient} from "./CodexAppServerClient"; +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"; import type { Account, AccountUpdatedNotification, + GetAccountResponse, Model, ReasoningEffortOption, Thread, @@ -32,6 +40,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, @@ -55,7 +64,7 @@ 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 {settledWithin} from "./StdUtils"; import type {ToolCallReports} from "./ToolCallReports"; import {ToolCallReportingConnection} from "./ToolCallReportingConnection"; import { @@ -63,6 +72,7 @@ import { AUTH_STATUS_UPDATE_METHOD, authStatusCapability, type AuthStatus, + type AuthenticationStatusResponse, GOAL_CONTROL_ACTIONS, GOAL_CONTROL_METHOD, GOAL_EXTENSION_VERSION, @@ -88,7 +98,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"; @@ -124,6 +133,7 @@ import { import {CodexSubagentEventRouter} from "./subagents/CodexSubagentEventRouter"; import {nameFromAgentPath} from "./subagents/CodexAgentPath"; import { + accountFromUpdated, fromAccount, fromAccountUpdated, gatewayStatus, @@ -239,6 +249,15 @@ 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 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; + function clientSupportsTypedSessionFailures(capabilities: acp.ClientCapabilities | null): boolean { return clientSupportsAirCapability(capabilities, AIR_SESSION_FAILURE_KEY); } @@ -252,9 +271,15 @@ interface ActiveAuthState { authConfigured: boolean; } -interface PendingMcpStartupSession { - requestedServers: Set; - startup: Promise; +/** + * 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; + +/** 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; } interface PendingTurnStart { @@ -285,6 +310,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; @@ -298,9 +324,19 @@ export class CodexAcpServer { private booleanConfigOptionsSupported: boolean; /** Last `authStatus` pushed to the client; used to suppress duplicates. */ private currentAuthStatus: AuthStatus | null; + /** + * 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 pendingMcpStartupSessions: Map; + private readonly mcpSessionStartup: McpSessionStartup; private readonly pendingTurnStarts: Map; private readonly activePrompts: Map; private readonly steeringQueues: Map; @@ -323,7 +359,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(); @@ -335,6 +370,19 @@ 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.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(); @@ -355,7 +403,8 @@ export class CodexAcpServer { this.connection, client, (operation) => this.runWithProcessCheck(operation), - () => this.refreshAuthState(null) + () => this.refreshAuthState(null), + this.mcpServerSignIn, ); } @@ -449,7 +498,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 {}; @@ -533,10 +582,43 @@ export class CodexAcpServer { } } - async checkAuthorization(){ - const authNeeded = await this.runWithProcessCheck(() => this.codexAcpClient.authRequired()); - logger.log("Auth requirement checked", {authRequired: authNeeded}); - if (authNeeded) { + /** + * 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. + * 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.readAccountOrUnavailable(() => this.codexAcpClient.readAuthRequirement()); + if (requirement === "unavailable") return "unavailable"; + logger.log("Auth requirement checked", {authRequired: requirement.required}); + if (requirement.required) { if (this.defaultAuthRequest) { logger.log("Authenticating with default auth request...", { authRequest: this.defaultAuthRequest @@ -547,7 +629,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]> { @@ -649,7 +733,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() @@ -685,7 +769,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); @@ -747,21 +831,15 @@ export class CodexAcpServer { const canPublishSessionUpdates = operation !== "fork"; if (requestedMcpServers.length > 0 && mcpServerStartupVersion !== null) { - const pendingStartup = this.createPendingMcpStartupSession( - 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); @@ -769,9 +847,6 @@ export class CodexAcpServer { throw err; } } - if (canPublishSessionUpdates) { - this.publishMcpStartupStatusAsync(sessionId); - } } if (canPublishSessionUpdates) { @@ -788,7 +863,18 @@ 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. + * + * 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: KnownAccount = null, + ): Promise { if (!this.authProviderUsesOpenAiAccount(authProvider)) { await this.publishAuthStatus(authProvider, null); return { @@ -796,7 +882,17 @@ export class CodexAcpServer { authConfigured: true, }; } - const accountResponse = 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, @@ -804,6 +900,88 @@ 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(read); + } catch (error) { + if (!isAccountReadUnavailableError(error)) throw error; + logger.log("Account read unavailable, the login state is unknown", {error: String(error)}); + return "unavailable"; + } + } + + /** + * Applies the account read of a load that answered before the read ended. + * + * 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, + 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" || !answerIsCurrent()) return; + const authState = await this.getAuthStateForProvider(sessionState.authProvider, knownAccount); + if (!sessionIsCurrent()) return; + sessionState.account = authState.account; + sessionState.authConfigured = authState.authConfigured; + }, + 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 { return authProvider === null || authProvider === "openai"; } @@ -967,7 +1145,8 @@ export class CodexAcpServer { } finally { if (this.getSessionGeneration(params.sessionId) === closeGeneration) { this.sessions.delete(params.sessionId); - 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); this.steeringQueues.delete(params.sessionId); @@ -1003,7 +1182,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) @@ -1270,12 +1449,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; } @@ -1299,6 +1488,7 @@ export class CodexAcpServer { return gatewayStatus(modelProvider); } const accountResponse = await this.runWithProcessCheck(() => this.codexAcpClient.getAccount()); + this.lastReadAccount = accountResponse.account; return fromAccount(accountResponse.account); } @@ -1345,6 +1535,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; @@ -1377,6 +1568,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; } @@ -1959,7 +2151,17 @@ export class CodexAcpServer { history: AsyncIterable; }> { const requestedSessionGeneration = this.beginSessionOpen(request.sessionId); - await this.checkAuthorization(); + 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. + 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() @@ -1968,12 +2170,21 @@ export class CodexAcpServer { logger.log(`Load existing session: ${request.sessionId}...`); let subscribed = false; let sessionMetadata: SessionMetadataWithThread; + let knownAccount: KnownAccount | "pending"; try { - sessionMetadata = await this.runWithProcessCheck(() => - this.codexAcpClient.loadSession(request, () => { - subscribed = true; - }) - ); + try { + sessionMetadata = await this.runWithProcessCheck(() => + this.codexAcpClient.loadSession(request, () => { + subscribed = true; + }) + ); + } catch (err) { + // 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, graceLeftMs()); } catch (err) { if (subscribed) { await this.cleanupStaleSessionOpen(request.sessionId, requestedSessionGeneration); @@ -1985,7 +2196,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 === "pending" ? "unavailable" : knownAccount); } catch (err) { if (subscribed) { await this.cleanupStaleSessionOpen(request.sessionId, requestedSessionGeneration); @@ -2042,14 +2253,13 @@ export class CodexAcpServer { () => sessionState.sessionTitleSource, ); this.installSessionState(sessionState); + if (knownAccount === "pending") { + this.applyLateAccountRead(sessionState, authorization, authStatusVersionAtStart); + } subscribed = false; if (requestedMcpServers.length > 0 && mcpServerStartupVersion !== null) { - this.pendingMcpStartupSessions.set( - sessionId, - this.createPendingMcpStartupSession(requestedMcpServers, mcpServerStartupVersion), - ); - this.publishMcpStartupStatusAsync(sessionId); + this.mcpSessionStartup.begin(sessionId, requestedMcpServers, mcpServerStartupVersion, {publish: true}); } await this.publishAvailableCommands(sessionState, requestedSessionGeneration); @@ -2226,7 +2436,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"; @@ -2239,14 +2449,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(" ")); @@ -2313,17 +2523,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) { @@ -2512,122 +2717,6 @@ export class CodexAcpServer { return []; } - private publishMcpStartupStatusAsync(sessionId: string): void { - void this.doPublishMcpStartupStatus(sessionId); - } - - private createPendingMcpStartupSession( - mcpServers: Array, - afterVersion: number, - ): PendingMcpStartupSession { - const requestedServers = new Set(getRequestedMcpServerNames(mcpServers)); - return { - requestedServers, - startup: this.runWithProcessCheck(() => - this.codexAcpClient.awaitMcpServerStartup(Array.from(requestedServers), afterVersion) - ), - }; - } - - 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.requestedServers); - } catch (err) { - 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, - 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) { - 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); - failuresAfterOauth.push(failure); - } - } - - 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 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 +3058,7 @@ export class CodexAcpServer { } const commandPromise = this.availableCommands.tryHandleCommand(params.prompt, sessionState, { + signal: activePrompt.signal, onTurnStartPending: () => { sessionState.lastTokenUsage = null; ensurePendingTurnStart(); @@ -3482,47 +3572,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/CodexAppServerClient.ts b/src/CodexAppServerClient.ts index e35f59a4..c0aebb57 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); @@ -549,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); @@ -576,6 +565,7 @@ export class CodexAppServerClient { releaseRoutingCapture(); releaseCompactionCapture(); closed?.dispose(); + disposed?.dispose(); } } @@ -675,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); @@ -763,27 +753,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 +777,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 +1086,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 +1136,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; @@ -1316,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/CodexCommands.ts b/src/CodexCommands.ts index 43d6122f..fb3efb97 100644 --- a/src/CodexCommands.ts +++ b/src/CodexCommands.ts @@ -1,13 +1,15 @@ 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"; 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. */ @@ -111,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} : {}), }); } } @@ -145,8 +155,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 +328,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/CodexEventHandler.ts b/src/CodexEventHandler.ts index 1d01b0d9..c3ce6fd6 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/CodexThreadErrors.ts b/src/CodexThreadErrors.ts index cec2f02c..98ab9750 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 @@ -87,3 +95,77 @@ 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 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", +]); + +/** + * 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. + */ +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 null; + } + 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/SessionTitle.ts b/src/SessionTitle.ts new file mode 100644 index 00000000..33cb5316 --- /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/StdUtils.ts b/src/StdUtils.ts index 96e8d704..5b8b8800 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})); + }; } /** @@ -112,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/ToolCallReports.ts b/src/ToolCallReports.ts index 08717f84..567dba9a 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__/CodexACPAgent/CodexAcpClient.test.ts b/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts index fc673fa6..a8693fe1 100644 --- a/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts +++ b/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts @@ -19,7 +19,8 @@ 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 {ClientCapabilities} from "../../tool-calls/ClientCapabilities"; +import type {McpStartupResult} from "../../mcp/McpStartupTracker"; describe('ACP server test', { timeout: 40_000 }, () => { @@ -95,15 +96,14 @@ 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", "model/list", "thread/started", - "account/read", "skills/list", ]); expect(loginRequest).toEqual({ @@ -352,7 +352,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"}); @@ -720,7 +720,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: []}); @@ -819,7 +819,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, @@ -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({ @@ -993,7 +994,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", @@ -1075,7 +1076,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({ @@ -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); @@ -1118,7 +1119,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({ @@ -1146,7 +1147,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({ @@ -1172,7 +1173,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({ @@ -1204,7 +1205,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({ @@ -1742,7 +1743,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( @@ -1780,7 +1781,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 @@ -1803,6 +1804,47 @@ describe('ACP server test', { timeout: 40_000 }, () => { await expect(mockFixture.getAcpConnectionDump([])).toMatchFileSnapshot("data/available-commands-skills.json"); }); + it('sends the skill path to AIR and not to other clients', 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}); + + // @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 () => { const mockFixture = createCodexMockTestFixture(); const codexAcpAgent = mockFixture.getCodexAcpAgent(); @@ -3521,7 +3563,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: []}); @@ -3541,7 +3583,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({ @@ -3620,7 +3662,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({ @@ -3673,7 +3715,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({ @@ -3712,7 +3754,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", @@ -3737,7 +3779,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: [ { @@ -3782,7 +3824,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/account-read-unavailable.test.ts b/src/__tests__/CodexACPAgent/account-read-unavailable.test.ts new file mode 100644 index 00000000..eb936245 --- /dev/null +++ b/src/__tests__/CodexACPAgent/account-read-unavailable.test.ts @@ -0,0 +1,494 @@ +import {afterEach, beforeEach, 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 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"); +} + +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: []}); + }); + + 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(); + appServer.accountRead = vi.fn().mockRejectedValue(unauthorized()); + vi.spyOn(fixture.getCodexAcpClient(), "loadSession").mockRejectedValue(new Error("resume failed")); + await initialize(fixture); + + await expect(fixture.getCodexAcpAgent().loadSession({sessionId: SESSION_ID, cwd: "/test/project", mcpServers: []})) + .rejects.toThrow("workspace routing discovery unauthorized (401)"); + }); + + 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()); + + 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 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(); + appServer.accountRead = vi.fn().mockResolvedValueOnce(SIGNED_IN).mockRejectedValue(routingFailure()); + await initialize(fixture); + const agent = fixture.getCodexAcpAgent(); + + agent.handleAccountUpdated({authMode: null, planType: null}); + await drainScheduledWork(); + await agent.newSession({cwd: "/test/project", mcpServers: []}); + + 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 () => { + 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)"); + // 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 23811794..9325ca26 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, @@ -66,7 +67,7 @@ async function createPromptableSession(fixture: CodexMockTestFixture): Promise { }); }); }); + +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__/CodexACPAgent/cancel-turn-registration-race.test.ts b/src/__tests__/CodexACPAgent/cancel-turn-registration-race.test.ts index 6f757942..0bcfd0d9 100644 --- a/src/__tests__/CodexACPAgent/cancel-turn-registration-race.test.ts +++ b/src/__tests__/CodexACPAgent/cancel-turn-registration-race.test.ts @@ -76,7 +76,7 @@ async function startPrompt(): Promise<{ const codexAcpClient = fixture.getCodexAcpClient(); const appServer = 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}); vi.spyOn(codexAcpClient, "newSession").mockResolvedValue({ sessionId, diff --git a/src/__tests__/CodexACPAgent/compact-command-lifecycle.test.ts b/src/__tests__/CodexACPAgent/compact-command-lifecycle.test.ts index ee598dd7..6bfba214 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,37 @@ 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-")); + let codex: ReturnType | undefined; + 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); + 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 { + codex?.process.kill(); + 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(); diff --git a/src/__tests__/CodexACPAgent/data/available-commands-build-in.json b/src/__tests__/CodexACPAgent/data/available-commands-build-in.json index d611fd30..2988148f 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]" + } }, { "name": "skills", diff --git a/src/__tests__/CodexACPAgent/data/available-commands-skills.json b/src/__tests__/CodexACPAgent/data/available-commands-skills.json index fbc66b5c..8c15b626 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]" + } }, { "name": "skills", @@ -99,7 +101,15 @@ { "name": "$build", "description": "Build", - "input": null + "input": null, + "_meta": { + "jetbrains": { + "air": { + "version": 1, + "skillPath": "/workspace/.agents/skills/build/SKILL.md" + } + } + } } ] } diff --git a/src/__tests__/CodexACPAgent/data/command-mcp.json b/src/__tests__/CodexACPAgent/data/command-mcp.json index adcc6d25..3c048cca 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 bd9977de..6c022266 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]" + } }, { "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 00000000..bac98c61 --- /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 00000000..4098315e --- /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 00000000..254d326f --- /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 00000000..4e41d17d --- /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 b356978e..6590e3dd 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/fast-mode-config.test.ts b/src/__tests__/CodexACPAgent/fast-mode-config.test.ts index 6007d529..ba32ae5f 100644 --- a/src/__tests__/CodexACPAgent/fast-mode-config.test.ts +++ b/src/__tests__/CodexACPAgent/fast-mode-config.test.ts @@ -37,7 +37,7 @@ describe("Fast mode session config", () => { }); 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 ce569e9c..3aaeb970 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 00000000..abbe455f --- /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 ca517a47..eefdb352 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(); @@ -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 () => { @@ -621,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, @@ -713,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-command.test.ts b/src/__tests__/CodexACPAgent/mcp-command.test.ts new file mode 100644 index 00000000..0b450607 --- /dev/null +++ b/src/__tests__/CodexACPAgent/mcp-command.test.ts @@ -0,0 +1,617 @@ +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`.'); + }); + + 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("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 an argument", async () => { + const {mockFixture} = setupPromptTestSession({sessionId}); + const list = mockStatusPages(mockFixture, {data: [server({name: "fs"})], nextCursor: null}); + const reload = vi.spyOn(mockFixture.getCodexAppServerClient(), "mcpServerReload"); + + 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`.'); + }); + + 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 94a92d71..b5666a08 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", @@ -75,14 +75,14 @@ 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 () => { 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 84865074..894142fc 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"}] }; @@ -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().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"}] + }; + + const newSessionResponse = await codexAcpAgent.newSession({cwd: "", mcpServers: [mcpServer]}); + fixture.clearAcpConnectionDump(); + 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"); + expect(transportDump).contain("**Connected**\\n- `test-mcp`: "); }); }); diff --git a/src/__tests__/CodexACPAgent/model-filtering.test.ts b/src/__tests__/CodexACPAgent/model-filtering.test.ts index 9cdf12cc..df967cde 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 01a7b429..4fbe6713 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 5a724039..c2f79303 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 d2d0fd10..9207f372 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"; @@ -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({ @@ -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, "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({ + 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(); @@ -356,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"); @@ -376,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(); @@ -478,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 3fe56f63..666a1f64 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 eea81839..f66be6f0 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 ba0207ed..2afdc6f1 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 7ce8f3a5..c9780509 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 () => { @@ -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({ @@ -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, "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({ + 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__/CodexACPAgent/session-info-update-events.test.ts b/src/__tests__/CodexACPAgent/session-info-update-events.test.ts index fe12e429..d6324452 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__/CodexACPAgent/session-notices.test.ts b/src/__tests__/CodexACPAgent/session-notices.test.ts index 336263d7..0e8fc8cb 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 098b5a09..cd5ddc42 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/thread-history.test.ts b/src/__tests__/CodexACPAgent/thread-history.test.ts index 71790909..8451d4c8 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(); diff --git a/src/__tests__/CodexACPAgent/unmaterialized-thread.test.ts b/src/__tests__/CodexACPAgent/unmaterialized-thread.test.ts index 6004a60c..089a77c8 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__/CodexThreadErrors.test.ts b/src/__tests__/CodexThreadErrors.test.ts index 4cb4ad26..509d2758 100644 --- a/src/__tests__/CodexThreadErrors.test.ts +++ b/src/__tests__/CodexThreadErrors.test.ts @@ -1,5 +1,9 @@ import {describe, expect, it} from "vitest"; +import {ResponseError} from "vscode-jsonrpc/node"; import { + isAccountReadAccountChangedError, + isAccountReadAuthFailureError, + isAccountReadUnavailableError, isInvalidThreadIdError, isMissingRolloutError, isThreadActiveWriterError, @@ -65,3 +69,51 @@ describe("CodexThreadErrors", () => { expect(isUnknownThreadError(null)).toBe(false); }); }); + +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", + ])("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([ + "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 unauthorized (401)", + null, + undefined, + ])("does not classify %s", error => { + expect(isAccountReadUnavailableError(error)).toBe(false); + expect(isAccountReadAuthFailureError(error)).toBe(false); + expect(isAccountReadAccountChangedError(error)).toBe(false); + }); +}); diff --git a/src/__tests__/McpStartupTracker.test.ts b/src/__tests__/McpStartupTracker.test.ts new file mode 100644 index 00000000..8e1b39b7 --- /dev/null +++ b/src/__tests__/McpStartupTracker.test.ts @@ -0,0 +1,134 @@ +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("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(); + 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 00000000..eb0a56ce --- /dev/null +++ b/src/__tests__/McpStatusMarkdown.test.ts @@ -0,0 +1,222 @@ +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 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"); + expect(cleanErrorMessage("MCP client for `fs` failed to start: spawn ENOENT")).toBe("spawn ENOENT"); + }); + + 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("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 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("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 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); + }); +}); + +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)"); + }); + + 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/__tests__/SessionTitle.test.ts b/src/__tests__/SessionTitle.test.ts new file mode 100644 index 00000000..73b15b39 --- /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)}…`); + }); +}); diff --git a/src/__tests__/StdUtils.test.ts b/src/__tests__/StdUtils.test.ts index bfe0ee46..6c566e8c 100644 --- a/src/__tests__/StdUtils.test.ts +++ b/src/__tests__/StdUtils.test.ts @@ -1,6 +1,6 @@ -import {PassThrough} from "node:stream"; -import {describe, expect, it} from "vitest"; -import {createJSONRPCReader} from "../StdUtils"; +import {PassThrough, Writable} from "node:stream"; +import {describe, expect, it, vi} from "vitest"; +import {createJsonStream, createJSONRPCReader, createLineWriter, settledWithin} from "../StdUtils"; function read(chunks: Buffer[]): Promise { const stream = new PassThrough(); @@ -65,3 +65,117 @@ 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"); + }); +}); + +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/__tests__/ToolCallReports.test.ts b/src/__tests__/ToolCallReports.test.ts index d43d81d7..ce8785c1 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"}); diff --git a/src/__tests__/acp-test-utils.ts b/src/__tests__/acp-test-utils.ts index ca4901da..94b0c08a 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 23750e56..a7fe89b9 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]"}, +}; + +/** + * 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 41cd0f8f..18e1d383 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]"}, + }; + 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 794bc3dc..38325a67 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]"},"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 7fb6a8dc..11cbdf65 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]"},"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 61523b51..c9e32048 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]"},"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 384b87c7..7767e48d 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]"},"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 8e9668a4..8d8c7bc2 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]"},"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 b1682bde..8b79e2c8 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]"},"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 814cef2e..ca2716ba 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]"},"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 dbd5c7f1..662e92e6 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]"},"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 580451d0..8f63ae4d 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]"},"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 4f553e28..bdbca471 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]"},"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 b91a33bf..976142e5 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]"},"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 18bd4dfd..721873c9 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]"},"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 9c434f88..10e2c474 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]"},"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 8892e8ee..f5b9c98b 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]"},"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 f73ba927..c8dcd1b4 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]"},"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 4f1a6b5f..cf94d19c 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]"},"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 feccd351..61a0afea 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]"},"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 e5b1fc7b..e1163580 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]"},"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 f31b025a..ac9c05af 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]"},"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 b5c4cdb0..3f96046c 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]"},"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 c797e5d6..19afbfa9 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]"},"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 3806a3af..8e213bf1 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]"},"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 6a97c326..2c6e4f7a 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]"},"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 c4f69721..5cc009be 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]"},"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 0dd243ef..dc6966a6 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]"},"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 872c4898..4854dfa1 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]"},"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 a32ce7d7..4ae362e4 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]"},"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 188c7868..caddbdd1 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]"},"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 22b8185f..6768f338 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]"},"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 83038dd8..e5f837f4 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]"},"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 346e1ea0..276c65d7 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]"},"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 b78df074..95dc1f0c 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]"},"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 175edd93..b393a7f3 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]"},"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/__tests__/scenarios/scenario-harness.ts b/src/__tests__/scenarios/scenario-harness.ts index d628f77f..7f4b1892 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({ diff --git a/src/mcp/McpCommand.ts b/src/mcp/McpCommand.ts new file mode 100644 index 00000000..9a5659ce --- /dev/null +++ b/src/mcp/McpCommand.ts @@ -0,0 +1,238 @@ +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]"; + +/** 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`, 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.length === 1 && args[0]!.toLowerCase() === "reconnect") { + return await this.reconnect(sessionState, signal); + } + return `Command "/mcp" accepts no arguments, or \`reconnect\`.`; + } + + private async reconnect(sessionState: SessionState, signal?: AbortSignal): Promise { + const before = await this.listServers(sessionState); + if (before.servers.length === 0) { + return NO_SERVERS_MESSAGE; + } + + const notes: string[] = []; + + 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 00000000..b8008d5b --- /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 00000000..348a7389 --- /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 00000000..77da9965 --- /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/McpSessionStartup.ts b/src/mcp/McpSessionStartup.ts new file mode 100644 index 00000000..4b55bab5 --- /dev/null +++ b/src/mcp/McpSessionStartup.ts @@ -0,0 +1,205 @@ +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 {settledWithin} from "../StdUtils"; +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) { + 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), + () => this.forget(sessionId, pendingStartup), + ); + return startupWait; + } + 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 publishOrAbort(sessionId: string, pendingStartup: PendingMcpStartupSession, publish: boolean): void { + if (publish) { + this.publishAsync(sessionId); + } else { + pendingStartup.abort.abort(); + } + } + + 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); + } + + 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 { + this.forget(sessionId, pendingStartup); + } + } + + private async publish( + sessionId: string, + mcpStartup: McpStartupResult, + signal: AbortSignal, + requestedServers: Set, + ): Promise { + 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]; + 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; +} diff --git a/src/mcp/McpStartupTracker.ts b/src/mcp/McpStartupTracker.ts new file mode 100644 index 00000000..ee54f059 --- /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 ?? null, { + 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 00000000..949423de --- /dev/null +++ b/src/mcp/McpStatusMarkdown.ts @@ -0,0 +1,205 @@ +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 maximum length of the raw error text that the cleanup reads. */ +const MAX_INPUT_LENGTH = 2000; + +/** An error chain segment that only wraps the real message. A JSON-RPC error code is also a wrapper. An HTTP status, such as `401`, stays. */ +const WRAPPER_SEGMENT = /^(?:tool discovery failed|mcp startup failed|handshaking with mcp server failed|json-rpc error|startup error|error|mcp client for .+ failed to start|-32\d{3})$/i; + +/** + * A Rust type path with three or more parts, and the rest of its word, such as `[crate::module::Type]`. + * A short path, such as the Perl `Foo::Bar`, and an IPv6 address, such as `fe80::abcd`, stay. + */ +const TYPE_PATH = /\S*\w::\w+::\S*/g; + +/** + * Makes an MCP error chain short and readable, on one line. + * 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 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); + } + 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. */ +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. + * 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(name: string): string { + const text = name.replace(/\s+/g, " "); + 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}`; +}