diff --git a/package-lock.json b/package-lock.json index 1ed03c97..0fc966ac 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,16 @@ { "name": "acp-extension-codex", - "version": "1.2.0", + "version": "1.2.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "acp-extension-codex", - "version": "1.2.0", + "version": "1.2.1", "license": "Apache-2.0", "dependencies": { "@agentclientprotocol/sdk": "^1.2.1", - "@openai/codex": "^0.144.0", + "@openai/codex": "^0.144.4", "diff": "^9.0.0", "open": "^11.0.0", "vscode-jsonrpc": "^9.0.1", @@ -929,9 +929,9 @@ } }, "node_modules/@openai/codex": { - "version": "0.144.0", - "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.144.0.tgz", - "integrity": "sha512-QFh6f+v5QUx/Vg0HjIl9HB94p7aDLBDkZjc4IXX5RXUcXHPVCZNb6Hl2R49Og/fqW7orgZkeDcgWfRANUa1WoQ==", + "version": "0.144.4", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.144.4.tgz", + "integrity": "sha512-DTHzYatlKq9dw55E0/HsbK4tRCEKabuJ10ybbqpsG8gVv/kvwEdg3Z4OI3cvLXKa21xkIa4lkGlZoO/HmqmFFw==", "license": "Apache-2.0", "bin": { "codex": "bin/codex.js" @@ -940,19 +940,19 @@ "node": ">=16" }, "optionalDependencies": { - "@openai/codex-darwin-arm64": "npm:@openai/codex@0.144.0-darwin-arm64", - "@openai/codex-darwin-x64": "npm:@openai/codex@0.144.0-darwin-x64", - "@openai/codex-linux-arm64": "npm:@openai/codex@0.144.0-linux-arm64", - "@openai/codex-linux-x64": "npm:@openai/codex@0.144.0-linux-x64", - "@openai/codex-win32-arm64": "npm:@openai/codex@0.144.0-win32-arm64", - "@openai/codex-win32-x64": "npm:@openai/codex@0.144.0-win32-x64" + "@openai/codex-darwin-arm64": "npm:@openai/codex@0.144.4-darwin-arm64", + "@openai/codex-darwin-x64": "npm:@openai/codex@0.144.4-darwin-x64", + "@openai/codex-linux-arm64": "npm:@openai/codex@0.144.4-linux-arm64", + "@openai/codex-linux-x64": "npm:@openai/codex@0.144.4-linux-x64", + "@openai/codex-win32-arm64": "npm:@openai/codex@0.144.4-win32-arm64", + "@openai/codex-win32-x64": "npm:@openai/codex@0.144.4-win32-x64" } }, "node_modules/@openai/codex-darwin-arm64": { "name": "@openai/codex", - "version": "0.144.0-darwin-arm64", - "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.144.0-darwin-arm64.tgz", - "integrity": "sha512-rqFAJdOa2I0VRgepVsSZeLxs96+Y+LXTjccOOvH6894FyaFAYPZ/o+6hgpB1iGHxxdoY/DsGa8jrJC8Leqn9Kg==", + "version": "0.144.4-darwin-arm64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.144.4-darwin-arm64.tgz", + "integrity": "sha512-6J3g498cM2oA7vYIJhpuGJlnIi/M5JdYmjB5BZ1Of5HQ0ziIlplFSvH801oVy9J5TQFp642ODzOu/ZEokDUXsg==", "cpu": [ "arm64" ], @@ -967,9 +967,9 @@ }, "node_modules/@openai/codex-darwin-x64": { "name": "@openai/codex", - "version": "0.144.0-darwin-x64", - "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.144.0-darwin-x64.tgz", - "integrity": "sha512-4p2jxRbN+Khg5UQzpkzT9upFj+qkEF/abmdvrtflkkWmVKP6Nt+yi8ospdqv9PDqvQ9SotPvX7iXaFaeUTrtmA==", + "version": "0.144.4-darwin-x64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.144.4-darwin-x64.tgz", + "integrity": "sha512-k1HC8gdbAy+VmMbekYkhM+r+QE2Xfgd67n1VSp94tjz7aXVKoalHcDkdKNM/uUQ8o2tvbiwhHSUftJF8Sm9/Lw==", "cpu": [ "x64" ], @@ -984,9 +984,9 @@ }, "node_modules/@openai/codex-linux-arm64": { "name": "@openai/codex", - "version": "0.144.0-linux-arm64", - "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.144.0-linux-arm64.tgz", - "integrity": "sha512-k++xhZrn9P3laO00Q92APG6mdOFDD66nUBo+8ExCa1NXi2pjLEMLC4+UNJTUUtUT1PEflOZ5pDKxPXgzaiFFFg==", + "version": "0.144.4-linux-arm64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.144.4-linux-arm64.tgz", + "integrity": "sha512-OlKx65579OwIzech9Tt3OUH9+hFZfFrCBP1hL2MudnMIoNr1+cFZjB5YIj5MWMRoBD+K5W3wdBIpQSH855b5Sg==", "cpu": [ "arm64" ], @@ -1001,9 +1001,9 @@ }, "node_modules/@openai/codex-linux-x64": { "name": "@openai/codex", - "version": "0.144.0-linux-x64", - "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.144.0-linux-x64.tgz", - "integrity": "sha512-GmKtQeX+cO9lN7mQD1FEVcXYEMLMgMByHwZdvlluH0bj/+c2ind3hwbRtE3eECFDekNhEiB80Ez0FfbkyFQqoA==", + "version": "0.144.4-linux-x64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.144.4-linux-x64.tgz", + "integrity": "sha512-2jxrmV6+/7eBNdg5uhhmOEPFu2o28eYY/ClLzWhSBHH8uo3f2KA1z9JQcVtwlbToW03nEPlEzYNYfCF1UBqsVQ==", "cpu": [ "x64" ], @@ -1018,9 +1018,9 @@ }, "node_modules/@openai/codex-win32-arm64": { "name": "@openai/codex", - "version": "0.144.0-win32-arm64", - "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.144.0-win32-arm64.tgz", - "integrity": "sha512-e2yGSgwdzrT1SoJMoOzWD58WBEsIaAMZpEchuV2VGkE2T955SG7dn7EyVQTQcy7/rdpE8aEDktZ/1eQQfjkdtQ==", + "version": "0.144.4-win32-arm64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.144.4-win32-arm64.tgz", + "integrity": "sha512-CCgfI1smFhHZTIpTuBwDJwBr/AR40RTqaFxbBWVabu0RMeYDteRuPiDfdTlktf3C43Y1q10VZXhVGYtCokDg2w==", "cpu": [ "arm64" ], @@ -1035,9 +1035,9 @@ }, "node_modules/@openai/codex-win32-x64": { "name": "@openai/codex", - "version": "0.144.0-win32-x64", - "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.144.0-win32-x64.tgz", - "integrity": "sha512-QiholLCYqNeYvNM77HOmPtrOFrY0rQc/N9nXt+sQGXO3rEGmcWjpLzujY4Oegl3CLRHoieWqlep3EqEvFBjoIA==", + "version": "0.144.4-win32-x64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.144.4-win32-x64.tgz", + "integrity": "sha512-iL1ky0ERgdQJOKzom/Ms1fhpwkSmpsA9eVrzAqURFlYGS8z7JqwEgm33+nLGCsY7y25d8Xs/LJ91Oiqz3yXcUg==", "cpu": [ "x64" ], diff --git a/package.json b/package.json index 7bde9244..b4ef2f9b 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "publishConfig": { "access": "public" }, - "version": "1.2.0", + "version": "1.2.1", "description": "An ACP-compatible coding agent powered by Codex", "main": "dist/index.js", "bin": { @@ -69,7 +69,7 @@ }, "dependencies": { "@agentclientprotocol/sdk": "^1.2.1", - "@openai/codex": "^0.144.0", + "@openai/codex": "^0.144.4", "diff": "^9.0.0", "open": "^11.0.0", "vscode-jsonrpc": "^9.0.1", diff --git a/src/CodexAcpClient.ts b/src/CodexAcpClient.ts index 850b8375..36516caf 100644 --- a/src/CodexAcpClient.ts +++ b/src/CodexAcpClient.ts @@ -1,4 +1,4 @@ -import {CODEX_API_KEY_ENV_VAR, isCodexAuthRequest, OPENAI_API_KEY_ENV_VAR} from "./CodexAuthMethod"; +import {CODEX_API_KEY_ENV_VAR, GatewayAuthMethod, isCodexAuthRequest, OPENAI_API_KEY_ENV_VAR} from "./CodexAuthMethod"; import type {EmbeddedResourceResource} from "@agentclientprotocol/sdk"; import * as acp from "@agentclientprotocol/sdk"; import {type McpServer, RequestError} from "@agentclientprotocol/sdk"; @@ -44,6 +44,21 @@ import type { import packageJson from "../package.json"; import type {AuthenticationStatusResponse} from "./AcpExtensions"; +/** + * Well-known provider id for the client-configurable custom LLM gateway. + * This is the only provider exposed through the ACP `providers/*` methods and + * the `gateway` auth method; it maps to a Codex `model_providers` entry. + */ +export const CUSTOM_GATEWAY_PROVIDER_ID = "custom-gateway"; + +/** + * ACP `LlmProtocol` values Codex can route through the custom gateway, mapped to + * the Codex `wire_api`. Codex only supports the OpenAI Responses wire API here. + */ +const SUPPORTED_GATEWAY_PROTOCOLS: Record = { + openai: "responses", +}; + /** * API for accessing the Codex App Server using ACP requests. * Converts ACP requests into corresponding app-server operations. @@ -88,7 +103,7 @@ export class CodexAcpClient { if (!isCodexAuthRequest(authRequest)) { throw RequestError.invalidRequest(); } - + this.gatewayConfig = null; switch (authRequest.methodId) { case "api-key": { const apiKey = authRequest._meta?.["api-key"]?.apiKey ?? this.readApiKeyFromEnv(); @@ -97,7 +112,6 @@ export class CodexAcpClient { case "chat-gpt": { const accountResponse = await this.codexClient.accountRead({refreshToken: true}); if (accountResponse.account?.type === "chatgpt") { - this.gatewayConfig = null; return true; } const loginCompletedPromise = this.awaitNextLoginCompleted(); @@ -105,7 +119,6 @@ export class CodexAcpClient { if (loginResponse.type == "chatgpt") { await open(loginResponse.authUrl); } - this.gatewayConfig = null; const result = await loginCompletedPromise; return result.success; } @@ -115,33 +128,15 @@ export class CodexAcpClient { const gatewaySettings = authRequest._meta["gateway"]; if (!gatewaySettings) throw RequestError.invalidRequest(); - const baseUrl = gatewaySettings.baseUrl; - const providerName = typeof gatewaySettings.providerName === "string" && gatewaySettings.providerName.trim().length > 0 - ? gatewaySettings.providerName - : "User-provided gateway"; - const headers: Record = { - "X-Client-Feature-ID": "codex", - ...gatewaySettings.headers - }; - - this.gatewayConfig = { - modelProvider: "custom-gateway", - config: { - name: providerName, - base_url: baseUrl, - http_headers: headers, - wire_api: "responses" - } - }; + this.applyGatewayConfig({ + baseUrl: gatewaySettings.baseUrl, + apiType: GatewayAuthMethod._meta.gateway.protocol, + headers: gatewaySettings.headers, + providerName: gatewaySettings.providerName, + }); - // Early return: model provider information will be sent to Codex later during the session creation return true; - } - - // Reset the gateway config to null if another authentication method was used - this.gatewayConfig = null; - return false; } private async authenticateWithApiKey(apiKey: string): Promise { @@ -150,7 +145,6 @@ export class CodexAcpClient { type: "apiKey", apiKey, }); - this.gatewayConfig = null; const result = await loginCompletedPromise; return result.success; } @@ -229,8 +223,96 @@ export class CodexAcpClient { return response.requiresOpenaiAuth && !response.account; } - hasGatewayAuth(): boolean { - return this.gatewayConfig !== null; + /** + * Validates and stores custom gateway routing. Shared by the `gateway` auth + * method and the ACP `providers/set` method. Throws `invalid_params` for an + * unsupported protocol or a malformed base URL. + */ + private applyGatewayConfig(params: { + baseUrl: string; + headers?: Record | undefined; + providerName?: string | undefined; + apiType: acp.LlmProtocol; + }): void { + const apiType = params.apiType; + const wireApi = SUPPORTED_GATEWAY_PROTOCOLS[apiType]; + if (!wireApi) { + throw RequestError.invalidParams( + {apiType}, + `Unsupported provider apiType "${apiType}"; supported: ${Object.keys(SUPPORTED_GATEWAY_PROTOCOLS).join(", ")}`, + ); + } + if (typeof params.baseUrl !== "string" || params.baseUrl.trim().length === 0) { + throw RequestError.invalidParams(undefined, "baseUrl must be a non-empty string"); + } + const providerName = typeof params.providerName === "string" && params.providerName.trim().length > 0 + ? params.providerName + : "User-provided gateway"; + const headers: Record = { + "X-Client-Feature-ID": "codex", + ...params.headers, + }; + + this.gatewayConfig = { + modelProvider: CUSTOM_GATEWAY_PROVIDER_ID, + config: { + name: providerName, + base_url: params.baseUrl, + http_headers: headers, + wire_api: wireApi, + }, + }; + } + + /** + * `providers/list`: returns the single client-configurable custom gateway + * provider. `current` carries only non-secret routing (never headers), and is + * `null` when the provider is not configured/disabled. + */ + listProviders(): acp.ProviderInfo[] { + const gatewayConfig = this.gatewayConfig; + const current: acp.ProviderCurrentConfig | null = gatewayConfig + ? { + apiType: gatewayApiTypeFromConfig(gatewayConfig), + baseUrl: gatewayConfig.config.base_url, + } + : null; + return [ + { + providerId: CUSTOM_GATEWAY_PROVIDER_ID, + supported: Object.keys(SUPPORTED_GATEWAY_PROTOCOLS), + required: false, + current, + }, + ]; + } + + /** + * `providers/set`: replaces the full configuration for the custom gateway + * provider. Rejects unknown provider ids with `invalid_params`. + */ + setProvider(request: acp.SetProviderRequest): void { + if (request.providerId !== CUSTOM_GATEWAY_PROVIDER_ID) { + throw RequestError.invalidParams( + {providerId: request.providerId}, + `Unknown providerId "${request.providerId}"; only "${CUSTOM_GATEWAY_PROVIDER_ID}" is configurable`, + ); + } + this.applyGatewayConfig({ + apiType: request.apiType, + baseUrl: request.baseUrl, + headers: request.headers, + }); + } + + /** + * `providers/disable`: disables the custom gateway provider. Disabling an + * unknown provider id is idempotent success (RFD behavior ยง7). + */ + disableProvider(request: acp.DisableProviderRequest): void { + if (request.providerId === CUSTOM_GATEWAY_PROVIDER_ID) { + this.gatewayConfig = null; + } } async getAccount(): Promise { @@ -544,6 +626,10 @@ export class CodexAcpClient { await this.waitForSessionNotifications(sessionId); return await elicitationHandler.handleElicitation(params); }, + handleUserInput: async (params) => { + await this.waitForSessionNotifications(sessionId); + return await elicitationHandler.handleUserInput(params); + }, }); } @@ -761,7 +847,7 @@ export class CodexAcpClient { const [allProviders, archivedAllProviders, customGateway] = await Promise.all([ this.codexClient.threadList({}), this.codexClient.threadList({archived: true}), - this.codexClient.threadList({modelProviders: ["custom-gateway"]}), + this.codexClient.threadList({modelProviders: [CUSTOM_GATEWAY_PROVIDER_ID]}), ]); return { @@ -866,13 +952,15 @@ function shouldDeduplicateMcpConflicts(): boolean { return !disabledByEnv; } +type WireApi = "responses"; + interface GatewayConfig { modelProvider: string; config: { name: string, base_url: string, http_headers: Record, - wire_api: "responses" + wire_api: WireApi } } @@ -965,6 +1053,12 @@ function isJsonObject(value: JsonValue | undefined): value is JsonObject { return value !== null && typeof value === "object" && !Array.isArray(value); } +function gatewayApiTypeFromConfig(gatewayConfig: GatewayConfig): acp.LlmProtocol { + const wireApi = gatewayConfig.config.wire_api; + const match = Object.entries(SUPPORTED_GATEWAY_PROTOCOLS).find(([, wire]) => wire === wireApi); + return match?.[0] ?? "openai"; +} + function mergeGatewayConfig(config: JsonObject, gatewayConfig: GatewayConfig | null): JsonObject { if (gatewayConfig !== null) { const newConfig = {...config}; diff --git a/src/CodexAcpServer.ts b/src/CodexAcpServer.ts index 99a64fef..269eda80 100644 --- a/src/CodexAcpServer.ts +++ b/src/CodexAcpServer.ts @@ -16,7 +16,6 @@ import type { ThreadGoalStatus, Thread, ThreadItem, - TurnCompletedNotification, UserInput } from "./app-server/v2"; import type {RateLimitsMap} from "./RateLimitsMap"; @@ -51,6 +50,7 @@ import { } from "./AcpExtensions"; import { createCollabAgentToolCallUpdate, + createCompletedContextCompactionUpdate, createCommandExecutionCompleteUpdate, createCommandExecutionUpdate, createDynamicToolCallUpdate, @@ -116,6 +116,8 @@ export interface SessionState { sessionMcpServers?: Array; terminalOutputMode: TerminalOutputMode; currentGoal?: ThreadGoalSnapshot | null; + sessionTitle: string | null; + sessionTitleSource: "unset" | "fallback" | "explicit" | "unknown"; } interface ActiveAuthState { @@ -229,6 +231,7 @@ export class CodexAcpServer { auth: { logout: {}, }, + providers: {}, loadSession: true, promptCapabilities: { embeddedContext: true, @@ -444,6 +447,8 @@ export class CodexAcpServer { planModeExplicitlySet: false, sessionMcpServers: sessionMcpServers, terminalOutputMode: this.terminalOutputMode, + sessionTitle: null, + sessionTitleSource: "sessionId" in request ? "unknown" : "unset", }; this.sessions.set(sessionId, sessionState); this.publishRateLimitsAsync(sessionState); @@ -695,6 +700,20 @@ export class CodexAcpServer { logger.log("Logout request completed"); } + listProviders(_params: acp.ListProvidersRequest): acp.ListProvidersResponse { + return { providers: this.codexAcpClient.listProviders() }; + } + + setProvider(params: acp.SetProviderRequest): acp.SetProviderResponse { + this.codexAcpClient.setProvider(params); + return { }; + } + + disableProvider(params: acp.DisableProviderRequest): acp.DisableProviderResponse { + this.codexAcpClient.disableProvider(params); + return { }; + } + private async refreshSessionsAuthState(authProvider: string | null): Promise { if (this.sessions.size === 0) return; @@ -1020,6 +1039,8 @@ export class CodexAcpServer { planModeExplicitlySet: false, sessionMcpServers: sessionMcpServers, terminalOutputMode: this.terminalOutputMode, + sessionTitle: null, + sessionTitleSource: "unset", }; this.sessions.set(sessionId, sessionState); this.publishRateLimitsAsync(sessionState); @@ -1048,6 +1069,7 @@ export class CodexAcpServer { private async streamThreadHistory(sessionId: string, thread: Thread): Promise { const session = new ACPSessionConnection(this.connection, sessionId); const sessionState = this.getSessionState(sessionId); + await this.publishThreadHistoryTitle(session, sessionState, thread); const responseItemFallbackUpdates = await createResponseItemHistoryFallbackUpdates( thread, sessionState.terminalOutputMode, @@ -1069,6 +1091,67 @@ export class CodexAcpServer { } } + private async publishThreadHistoryTitle( + session: ACPSessionConnection, + sessionState: SessionState, + thread: Thread, + ): Promise { + const explicitTitle = this.normalizeSessionTitle(thread.name); + if (explicitTitle) { + sessionState.sessionTitle = explicitTitle; + sessionState.sessionTitleSource = "explicit"; + await session.update({ + sessionUpdate: "session_info_update", + title: explicitTitle, + }); + return; + } + + const historyTitle = this.findFirstUserMessageTitle(thread) + ?? this.normalizeSessionTitle(thread.preview); + await this.publishFallbackSessionTitle(sessionState, historyTitle); + } + + private findFirstUserMessageTitle(thread: Thread): string | null { + for (const turn of thread.turns) { + for (const item of turn.items) { + if (item.type !== "userMessage") continue; + const title = this.normalizeSessionTitle(item.content + .filter((input): input is Extract => input.type === "text") + .map(input => input.text) + .join(" ")); + if (title) return title; + } + } + return null; + } + + private async publishFallbackSessionTitle( + sessionState: SessionState, + title: string | null, + ): Promise { + if (sessionState.sessionTitleSource !== "unset" || !title) return; + sessionState.sessionTitle = title; + sessionState.sessionTitleSource = "fallback"; + const session = new ACPSessionConnection(this.connection, sessionState.sessionId); + await session.update({ + sessionUpdate: "session_info_update", + title, + }); + } + + private createPromptFallbackTitle(prompt: acp.ContentBlock[]): string | null { + return this.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 { switch (item.type) { case "userMessage": @@ -1115,7 +1198,7 @@ export class CodexAcpServer { case "exitedReviewMode": return [this.createReviewModeUpdate(item, false)]; case "contextCompaction": - return [this.createContextCompactionUpdate()]; + return [createCompletedContextCompactionUpdate(item)]; case "plan": return [this.createPlanUpdate(item)]; } @@ -1168,16 +1251,6 @@ export class CodexAcpServer { }; } - private createContextCompactionUpdate(): UpdateSessionEvent { - return { - sessionUpdate: "agent_message_chunk", - content: { - type: "text", - text: "Context compacted.", - }, - }; - } - private createPlanUpdate( item: ThreadItem & { type: "plan" } ): UpdateSessionEvent { @@ -1758,6 +1831,11 @@ export class CodexAcpServer { throw error; } + await this.publishFallbackSessionTitle( + sessionState, + this.createPromptFallbackTitle(params.prompt), + ); + return { stopReason: "end_turn", usage: this.buildPromptUsage(sessionState.lastTokenUsage), diff --git a/src/CodexAppServerClient.ts b/src/CodexAppServerClient.ts index fc9e8b6b..38a40c13 100644 --- a/src/CodexAppServerClient.ts +++ b/src/CodexAppServerClient.ts @@ -52,6 +52,8 @@ import type { ThreadStartResponse, ThreadUnsubscribeParams, ThreadUnsubscribeResponse, + ToolRequestUserInputParams, + ToolRequestUserInputResponse, TurnCompletedNotification, TurnInterruptParams, TurnInterruptResponse, @@ -76,6 +78,7 @@ export interface ApprovalHandler { export interface ElicitationHandler { handleElicitation(params: McpServerElicitationRequestParams): Promise; + handleUserInput(params: ToolRequestUserInputParams): Promise; } export type McpStartupFailure = { @@ -113,6 +116,12 @@ const McpServerElicitationRequest = new RequestType< void >('mcpServer/elicitation/request'); +const ToolRequestUserInputRequest = new RequestType< + ToolRequestUserInputParams, + ToolRequestUserInputResponse, + void +>('item/tool/requestUserInput'); + const GOAL_RUNTIME_EFFECTS_GRACE_MS = 1_000; /** @@ -220,6 +229,17 @@ export class CodexAppServerClient { } return await handler.handleElicitation(params); }); + + this.connection.onRequest(ToolRequestUserInputRequest, async (params) => { + if (this.isStaleTurn(params.threadId, params.turnId)) { + return { answers: {} }; + } + const handler = this.elicitationHandlers.get(params.threadId); + if (!handler) { + return { answers: {} }; + } + return await handler.handleUserInput(params); + }); } onApprovalRequest(threadId: string, handler: ApprovalHandler): void { diff --git a/src/CodexAuthMethod.ts b/src/CodexAuthMethod.ts index 94c1833a..9f550583 100644 --- a/src/CodexAuthMethod.ts +++ b/src/CodexAuthMethod.ts @@ -34,7 +34,7 @@ export interface ChatGPTAuthRequest extends AuthenticateRequest { methodId: "chat-gpt"; } -const GatewayAuthMethod: AuthMethod = { +export const GatewayAuthMethod = { id: "gateway", name: "Custom model gateway", description: "Use a custom gateway to authenticate and access models", diff --git a/src/CodexCommands.ts b/src/CodexCommands.ts index d7057b8e..b8f41c42 100644 --- a/src/CodexCommands.ts +++ b/src/CodexCommands.ts @@ -257,8 +257,8 @@ export class CodexCommands { return { handled: true }; } default: - await this.sendUnknownCommandMessage(commandName, sessionId); - return { handled: true }; + // Let Codex resolve unrecognized commands as raw prompts. + return { handled: false }; } } @@ -364,18 +364,6 @@ export class CodexCommands { await session.update(createAgentTextMessageChunk(text)); } - private async sendUnknownCommandMessage(name: string, sessionId: string): Promise { - const lines = this.getBuiltinCommands().map(command => `- /${command.name}: ${command.description}`); - const text = [ - `Unknown command "/${name}".`, - "Available commands:" - ]; - if (lines.length > 0) { - text.push(...lines); - } - await this.sendCommandMessage(text.join("\n"), sessionId); - } - private buildStatusMessage(sessionState: SessionState): string { const agentMode = sessionState.agentMode; const accountText = this.formatAccountInfo(sessionState.account); diff --git a/src/CodexElicitationHandler.ts b/src/CodexElicitationHandler.ts index b99bb51b..b0ca0eab 100644 --- a/src/CodexElicitationHandler.ts +++ b/src/CodexElicitationHandler.ts @@ -8,6 +8,8 @@ import type { ItemStartedNotification, McpServerElicitationRequestParams, McpServerElicitationRequestResponse, + ToolRequestUserInputParams, + ToolRequestUserInputResponse, } from "./app-server/v2"; import { logger } from "./Logger"; import { McpApprovalOptionId } from "./McpApprovalOptionId"; @@ -36,6 +38,8 @@ type AcpBackedMcpElicitationParams = Extract< { mode: "form" } | { mode: "url" } >; +const USER_INPUT_OTHER_FIELD_SUFFIX = "__other"; + /** * Parses the `persist` field from the elicitation request `_meta`. * Codex advertises which persistence options the client should show. @@ -219,6 +223,33 @@ function elicitationResponseMeta( return Object.keys(meta).length === 0 ? null : meta; } +function userInputOtherFieldId(questionId: string, questionIds: Set): string { + const base = `${questionId}${USER_INPUT_OTHER_FIELD_SUFFIX}`; + if (!questionIds.has(base)) { + return base; + } + + let index = 1; + while (questionIds.has(`${base}${index}`)) { + index += 1; + } + return `${base}${index}`; +} + +function userInputResponseValue( + content: Record, + fieldId: string +): acp.ElicitationContentValue | undefined { + const value = content[fieldId]; + if (typeof value === "string" && value.trim() === "") { + return undefined; + } + if (Array.isArray(value) && value.length === 0) { + return undefined; + } + return value; +} + /** * Builds the ACP permission options for an MCP tool call approval elicitation. * Always includes "Allow Once"; adds session/always persist options when advertised. @@ -332,8 +363,76 @@ export class CodexElicitationHandler implements ElicitationHandler { } } - private requestOptions(): acp.SendRequestOptions | undefined { - return this.cancellationSignal ? {cancellationSignal: this.cancellationSignal} : undefined; + async handleUserInput(params: ToolRequestUserInputParams): Promise { + if (!clientSupportsFormElicitation(this.clientCapabilities)) { + return { answers: {} }; + } + + try { + const response = await this.requestUserInputElicitation(params); + if (response === null) { + return { answers: {} }; + } + return this.convertUserInputResponse(response, params); + } catch (error) { + logger.error("Error handling Codex user input request", error); + return { answers: {} }; + } + } + + private requestOptions( + cancellationSignal: AbortSignal | undefined = this.cancellationSignal + ): acp.SendRequestOptions | undefined { + return cancellationSignal ? {cancellationSignal} : undefined; + } + + private async requestUserInputElicitation( + params: ToolRequestUserInputParams + ): Promise { + const request = this.buildUserInputRequest(params); + if (params.autoResolutionMs === null) { + return await this.connection.request( + acp.methods.client.elicitation.create, + request, + this.requestOptions(), + ); + } + + const abortController = new AbortController(); + let timeout: ReturnType | undefined; + let removeAbortListener: (() => void) | undefined; + const timeoutPromise = new Promise((resolve) => { + const resolveWithoutInput = () => { + abortController.abort(); + resolve(null); + }; + timeout = setTimeout(resolveWithoutInput, Math.max(0, params.autoResolutionMs ?? 0)); + if (this.cancellationSignal?.aborted) { + resolveWithoutInput(); + return; + } + if (this.cancellationSignal) { + this.cancellationSignal.addEventListener("abort", resolveWithoutInput, { once: true }); + removeAbortListener = () => { + this.cancellationSignal?.removeEventListener("abort", resolveWithoutInput); + }; + } + }); + const requestPromise = Promise.resolve(this.connection.request( + acp.methods.client.elicitation.create, + request, + this.requestOptions(abortController.signal), + )); + void requestPromise.catch(() => {}); + + try { + return await Promise.race([requestPromise, timeoutPromise]); + } finally { + if (timeout) { + clearTimeout(timeout); + } + removeAbortListener?.(); + } } private createMcpElicitationContext(params: McpServerElicitationRequestParams): McpElicitationContext { @@ -393,6 +492,79 @@ export class CodexElicitationHandler implements ElicitationHandler { } } + private buildUserInputRequest(params: ToolRequestUserInputParams): acp.CreateElicitationRequest { + const properties: Record = {}; + const required: string[] = []; + const questionIds = new Set(params.questions.map(question => question.id)); + + for (const question of params.questions) { + const options = question.options ?? []; + const hasOptions = options.length > 0; + const hasOtherAnswer = question.isOther && hasOptions; + const base = { + title: question.header || question.id, + description: question.question, + _meta: { + codex: { + isOther: question.isOther, + isSecret: question.isSecret, + }, + }, + }; + if (!hasOtherAnswer) { + required.push(question.id); + } + properties[question.id] = hasOptions + ? { + ...base, + type: "string", + oneOf: options.map(option => ({ + const: option.label, + title: option.label, + description: option.description, + })), + } + : { + ...base, + type: "string", + }; + if (hasOtherAnswer) { + properties[userInputOtherFieldId(question.id, questionIds)] = { + type: "string", + title: "Other", + description: "Type your own answer instead of choosing an option above.", + _meta: { + codex: { + questionId: question.id, + isOtherAnswer: true, + isSecret: question.isSecret, + }, + }, + }; + } + } + + const firstQuestion = params.questions[0]; + return { + sessionId: this.sessionState.sessionId, + toolCallId: params.itemId, + mode: "form", + message: params.questions.length === 1 && firstQuestion + ? firstQuestion.question + : "Input requested", + requestedSchema: { + type: "object", + properties, + required, + }, + _meta: { + codex: { + autoResolutionMs: params.autoResolutionMs, + }, + }, + }; + } + private buildPermissionRequest( params: McpServerElicitationRequestParams, context: McpElicitationContext @@ -514,6 +686,34 @@ export class CodexElicitationHandler implements ElicitationHandler { return { action: "cancel", content: null, _meta: null }; } + private convertUserInputResponse( + response: acp.CreateElicitationResponse, + params: ToolRequestUserInputParams + ): ToolRequestUserInputResponse { + if (!acp.CreateElicitationResponse.isAccept(response)) { + return { answers: {} }; + } + + const answers: ToolRequestUserInputResponse["answers"] = {}; + const content = contentRecord(response.content); + const questionIds = new Set(params.questions.map(question => question.id)); + for (const question of params.questions) { + const value = question.isOther && question.options != null && question.options.length > 0 + ? userInputResponseValue(content, userInputOtherFieldId(question.id, questionIds)) + ?? userInputResponseValue(content, question.id) + : userInputResponseValue(content, question.id); + if (value === undefined) { + continue; + } + answers[question.id] = { + answers: Array.isArray(value) + ? value.map(String) + : [String(value)], + }; + } + return { answers }; + } + private async publishAcceptedMcpToolApproval( context: McpElicitationContext, accepted: boolean diff --git a/src/CodexEventHandler.ts b/src/CodexEventHandler.ts index 846aa2fe..7fd5c6e5 100644 --- a/src/CodexEventHandler.ts +++ b/src/CodexEventHandler.ts @@ -46,6 +46,8 @@ import { createCollabAgentToolCallCompleteUpdate, createCollabAgentToolCallUpdate, createCommandExecutionUpdate, + createContextCompactionCompleteUpdate, + createContextCompactionStartUpdate, createDynamicToolCallUpdate, createFileChangeUpdate, createGuardianApprovalReviewToolCall, @@ -141,6 +143,10 @@ 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.sessionTitleSource = notification.params.threadName == null + ? "unset" + : "explicit"; return { sessionUpdate: "session_info_update", title: notification.params.threadName ?? null, @@ -478,6 +484,8 @@ export class CodexEventHandler { case "agentMessage": this.rememberAgentMessagePhase(event.item); return null; + case "contextCompaction": + return createContextCompactionStartUpdate(event.item); case "subAgentActivity": case "sleep": case "userMessage": @@ -485,7 +493,6 @@ export class CodexEventHandler { case "reasoning": case "enteredReviewMode": case "exitedReviewMode": - case "contextCompaction": case "plan": return null; } @@ -538,7 +545,7 @@ export class CodexEventHandler { case "exitedReviewMode": return this.createExitedReviewModeEvent(event.item); case "contextCompaction": - return this.createContextCompactedEvent(); + return createContextCompactionCompleteUpdate(event.item); //ignored types case "subAgentActivity": case "sleep": @@ -706,7 +713,15 @@ export class CodexEventHandler { private async createErrorEvent(params: ErrorNotification): Promise { const error = params.error.codexErrorInfo; - if (error === "usageLimitExceeded") { + if (params.willRetry) { + return this.createCodexSessionInfoUpdate({ + error: { + ...params.error, + turnId: params.turnId, + willRetry: true, + }, + }); + } else if (error === "usageLimitExceeded") { this.failure = RequestError.internalError( this.createTurnErrorData(params.error), ); diff --git a/src/CodexToolCallMapper.ts b/src/CodexToolCallMapper.ts index 3e2fd4e7..20465fc2 100644 --- a/src/CodexToolCallMapper.ts +++ b/src/CodexToolCallMapper.ts @@ -41,8 +41,11 @@ type GuardianApprovalReviewNotification = type WebSearchItem = ThreadItem & { type: "webSearch" }; type CollabAgentToolCallItem = ThreadItem & { type: "collabAgentToolCall" }; type CommandExecutionItem = ThreadItem & { type: "commandExecution" }; +type ContextCompactionItem = ThreadItem & { type: "contextCompaction" }; type AcpToolCallEvent = Extract; +const CONTEXT_COMPACTION_META = { contextCompaction: true }; + function toAcpStatus(status: CodexItemStatus): AcpToolCallStatus { switch (status) { case "inProgress": @@ -220,6 +223,44 @@ export function createImageGenerationUpdate( }; } +export function createContextCompactionStartUpdate( + item: ContextCompactionItem, +): UpdateSessionEvent { + return { + sessionUpdate: "tool_call", + toolCallId: item.id, + kind: "other", + title: "Context compacting", + status: "in_progress", + _meta: CONTEXT_COMPACTION_META, + }; +} + +export function createContextCompactionCompleteUpdate( + item: ContextCompactionItem, +): UpdateSessionEvent { + return { + sessionUpdate: "tool_call_update", + toolCallId: item.id, + title: "Context compacted", + status: "completed", + _meta: CONTEXT_COMPACTION_META, + }; +} + +export function createCompletedContextCompactionUpdate( + item: ContextCompactionItem, +): UpdateSessionEvent { + return { + sessionUpdate: "tool_call", + toolCallId: item.id, + kind: "other", + title: "Context compacted", + status: "completed", + _meta: CONTEXT_COMPACTION_META, + }; +} + export async function createExecuteToolCallUpdate( item: ThreadItem & ({ type: "mcpToolCall" } | { type: "dynamicToolCall" }), title: string, diff --git a/src/ElicitationCapabilities.ts b/src/ElicitationCapabilities.ts index 51030b0d..db2af583 100644 --- a/src/ElicitationCapabilities.ts +++ b/src/ElicitationCapabilities.ts @@ -1,4 +1,5 @@ import type * as acp from "@agentclientprotocol/sdk"; +import type {InitializeCapabilities} from "./app-server"; export function clientSupportsFormElicitation( clientCapabilities?: acp.ClientCapabilities | null diff --git a/src/ModelConfigOption.ts b/src/ModelConfigOption.ts index 035b59fb..fbdb4e9e 100644 --- a/src/ModelConfigOption.ts +++ b/src/ModelConfigOption.ts @@ -5,6 +5,10 @@ import type {Model, ReasoningEffortOption} from "./app-server/v2"; export const MODEL_CONFIG_ID = "model"; export const REASONING_EFFORT_CONFIG_ID = "reasoning_effort"; +function capitalize(value: string): string { + return value.charAt(0).toUpperCase() + value.slice(1); +} + export function findSupportedEffort( options: ReadonlyArray, effort: string | undefined, @@ -51,7 +55,7 @@ export function createReasoningEffortConfigOption( currentValue: currentEffort, options: supportedReasoningEfforts.map(option => ({ value: option.reasoningEffort, - name: option.reasoningEffort, + name: capitalize(option.reasoningEffort), description: option.description, })), }; diff --git a/src/ResponseItemHistoryFallback.ts b/src/ResponseItemHistoryFallback.ts index 9cbb7de9..c9826a8a 100644 --- a/src/ResponseItemHistoryFallback.ts +++ b/src/ResponseItemHistoryFallback.ts @@ -176,6 +176,7 @@ function toolCallIdFromThreadItem(item: ThreadItem): string | null { case "webSearch": case "imageView": case "imageGeneration": + case "contextCompaction": return item.id; case "userMessage": case "hookPrompt": @@ -185,7 +186,6 @@ function toolCallIdFromThreadItem(item: ThreadItem): string | null { case "subAgentActivity": case "enteredReviewMode": case "exitedReviewMode": - case "contextCompaction": case "sleep": return null; } diff --git a/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts b/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts index 7e024e28..258be031 100644 --- a/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts +++ b/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts @@ -3245,7 +3245,7 @@ describe('ACP server test', { timeout: 40_000 }, () => { await expect(mockFixture.getAcpConnectionDump([])).toMatchFileSnapshot("data/thread-compacted.json"); }); - it ('should surface contextCompaction item as user-visible message', async () => { + it ('should surface contextCompaction item lifecycle as a tool call', async () => { const sessionId = "test-session-id"; const { mockFixture } = setupPromptFixture({ sessionId }); @@ -3256,6 +3256,15 @@ describe('ACP server test', { timeout: 40_000 }, () => { mockFixture.clearAcpConnectionDump(); + mockFixture.sendServerNotification({ + method: "item/started", + params: { + threadId: sessionId, + turnId: "turn-id", + startedAtMs: 0, + item: { type: "contextCompaction", id: "context-compaction-id" }, + }, + }); mockFixture.sendServerNotification({ method: "item/completed", params: { @@ -3267,11 +3276,11 @@ describe('ACP server test', { timeout: 40_000 }, () => { }); await vi.waitFor(() => { - const dump = mockFixture.getAcpConnectionDump([]); - expect(dump.length).toBeGreaterThan(0); + const events = mockFixture.getAcpConnectionEvents([]); + expect(events).toHaveLength(2); }); - await expect(mockFixture.getAcpConnectionDump([])).toMatchFileSnapshot("data/thread-compacted.json"); + await expect(mockFixture.getAcpConnectionDump([])).toMatchFileSnapshot("data/context-compaction-lifecycle.json"); }); it ('should surface exitedReviewMode item as user-visible review output', async () => { diff --git a/src/__tests__/CodexACPAgent/auth-error-events.test.ts b/src/__tests__/CodexACPAgent/auth-error-events.test.ts index f4917991..dae65dca 100644 --- a/src/__tests__/CodexACPAgent/auth-error-events.test.ts +++ b/src/__tests__/CodexACPAgent/auth-error-events.test.ts @@ -64,8 +64,45 @@ const configuredAuthFailureCases: Array<{ ]; describe("CodexEventHandler - auth error events", () => { + it("keeps the prompt alive for a retryable HTTP 401", async () => { + const {result: response, updates} = await runPromptWithError(createTestSessionState({ + sessionId: "retrying-session", + account: { type: "apiKey" }, + }), { + message: "Reconnecting after provider returned 401", + codexErrorInfo: { + responseStreamDisconnected: { + httpStatusCode: 401, + }, + }, + additionalDetails: "HTTP status 401", + }, true); + + expect(response).toMatchObject({ + stopReason: "end_turn", + }); + expect(updates).toEqual([{ + sessionUpdate: "session_info_update", + _meta: { + codex: { + error: { + message: "Reconnecting after provider returned 401", + codexErrorInfo: { + responseStreamDisconnected: { + httpStatusCode: 401, + }, + }, + additionalDetails: "HTTP status 401", + turnId: "turn-id", + willRetry: true, + }, + }, + }, + }]); + }); + it("returns AuthRequired for auth errors when no auth is configured", async () => { - const error = await runPromptWithError(createTestSessionState({ + const {result: error} = await runPromptWithError(createTestSessionState({ sessionId: "unauthenticated-session", account: null, authConfigured: false, @@ -88,7 +125,7 @@ describe("CodexEventHandler - auth error events", () => { it.each(configuredAuthFailureCases)( "returns InternalError with details for $name when auth is configured", async ({turnError, sessionOverrides, expectedData}) => { - const error = await runPromptWithError(createTestSessionState({ + const {result: error} = await runPromptWithError(createTestSessionState({ sessionId: "authenticated-session", account: { type: "apiKey" }, ...sessionOverrides, @@ -109,7 +146,8 @@ describe("CodexEventHandler - auth error events", () => { async function runPromptWithError( sessionState: SessionState, turnError: ErrorNotification["error"], -): Promise { + willRetry = false, +): Promise<{result: unknown; updates: unknown[]}> { const mockFixture = createCodexMockTestFixture(); const codexAcpAgent = mockFixture.getCodexAcpAgent(); const codexAppServerClient = mockFixture.getCodexAppServerClient(); @@ -134,7 +172,7 @@ async function runPromptWithError( params: { threadId: sessionState.sessionId, turnId: "turn-id", - willRetry: false, + willRetry, error: turnError, }, }); @@ -144,14 +182,16 @@ async function runPromptWithError( turn: createTurn("completed"), }); - let caughtError: unknown; + let result: unknown; try { - await promptPromise; + result = await promptPromise; } catch (error) { - caughtError = error; + result = error; } - expect(caughtError).toBeDefined(); - return caughtError; + return { + result, + updates: mockFixture.getAcpConnectionEvents([]).map(event => event.args[0].update), + }; } function createTurn(status: "inProgress" | "completed") { diff --git a/src/__tests__/CodexACPAgent/data/context-compaction-lifecycle.json b/src/__tests__/CodexACPAgent/data/context-compaction-lifecycle.json new file mode 100644 index 00000000..bbc4a32d --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/context-compaction-lifecycle.json @@ -0,0 +1,35 @@ +{ + "method": "sessionUpdate", + "args": [ + { + "sessionId": "test-session-id", + "update": { + "sessionUpdate": "tool_call", + "toolCallId": "context-compaction-id", + "kind": "other", + "title": "Context compacting", + "status": "in_progress", + "_meta": { + "contextCompaction": true + } + } + } + ] +} +{ + "method": "sessionUpdate", + "args": [ + { + "sessionId": "test-session-id", + "update": { + "sessionUpdate": "tool_call_update", + "toolCallId": "context-compaction-id", + "title": "Context compacted", + "status": "completed", + "_meta": { + "contextCompaction": true + } + } + } + ] +} \ No newline at end of file diff --git a/src/__tests__/CodexACPAgent/data/load-session-history.json b/src/__tests__/CodexACPAgent/data/load-session-history.json index 454bcf7d..bc5b3f96 100644 --- a/src/__tests__/CodexACPAgent/data/load-session-history.json +++ b/src/__tests__/CodexACPAgent/data/load-session-history.json @@ -64,6 +64,18 @@ } ] } +{ + "method": "sessionUpdate", + "args": [ + { + "sessionId": "session-1", + "update": { + "sessionUpdate": "session_info_update", + "title": "Saved title" + } + } + ] +} { "method": "sessionUpdate", "args": [ @@ -327,4 +339,22 @@ } } ] +} +{ + "method": "sessionUpdate", + "args": [ + { + "sessionId": "session-1", + "update": { + "sessionUpdate": "tool_call", + "toolCallId": "item-context-compaction-1", + "kind": "other", + "title": "Context compacted", + "status": "completed", + "_meta": { + "contextCompaction": true + } + } + } + ] } \ No newline at end of file diff --git a/src/__tests__/CodexACPAgent/data/load-session-response-item-history-fallback.json b/src/__tests__/CodexACPAgent/data/load-session-response-item-history-fallback.json index 3fd4f914..e960c459 100644 --- a/src/__tests__/CodexACPAgent/data/load-session-response-item-history-fallback.json +++ b/src/__tests__/CodexACPAgent/data/load-session-response-item-history-fallback.json @@ -64,6 +64,18 @@ } ] } +{ + "method": "sessionUpdate", + "args": [ + { + "sessionId": "session-legacy", + "update": { + "sessionUpdate": "session_info_update", + "title": "List the files" + } + } + ] +} { "method": "sessionUpdate", "args": [ diff --git a/src/__tests__/CodexACPAgent/data/session-info-update-fallback-title.json b/src/__tests__/CodexACPAgent/data/session-info-update-fallback-title.json new file mode 100644 index 00000000..26871a0f --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/session-info-update-fallback-title.json @@ -0,0 +1,12 @@ +{ + "method": "sessionUpdate", + "args": [ + { + "sessionId": "test-session-id", + "update": { + "sessionUpdate": "session_info_update", + "title": "Fix the flaky test in CI" + } + } + ] +} diff --git a/src/__tests__/CodexACPAgent/elicitation-events.test.ts b/src/__tests__/CodexACPAgent/elicitation-events.test.ts index 8bd45e54..57513621 100644 --- a/src/__tests__/CodexACPAgent/elicitation-events.test.ts +++ b/src/__tests__/CodexACPAgent/elicitation-events.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import * as acp from "@agentclientprotocol/sdk"; -import type { McpServerElicitationRequestParams } from '../../app-server/v2'; +import type { McpServerElicitationRequestParams, ToolRequestUserInputParams } from '../../app-server/v2'; import { createCodexMockTestFixture, createTestSessionState, type CodexMockTestFixture } from '../acp-test-utils'; import type { SessionState } from '../../CodexAcpServer'; import { AgentMode } from "../../AgentMode"; @@ -677,4 +677,191 @@ describe('Elicitation Events', () => { await promptPromise; }); }); + + describe('Codex request_user_input', () => { + it('should use ACP form elicitation for request_user_input when supported', async () => { + const { promptPromise, completeTurn } = await setupSessionWithPendingPromptAndCapabilities({ + elicitation: { form: {} }, + }); + fixture.setElicitationResponse({ + action: 'accept', + content: { + next_step: 'Run tests', + notes: 'Focus auth', + }, + }); + + const params: ToolRequestUserInputParams = { + threadId: sessionId, + turnId: 'turn-1', + itemId: 'request-user-input-1', + autoResolutionMs: 60000, + questions: [ + { + id: 'next_step', + header: 'Next step', + question: 'What should I do next?', + isOther: true, + isSecret: false, + options: [ + { label: 'Run tests', description: 'Run the focused test suite.' }, + { label: 'Stop', description: 'Stop and report current status.' }, + ], + }, + { + id: 'notes', + header: 'Notes', + question: 'Any extra instructions?', + isOther: false, + isSecret: false, + options: null, + }, + ], + }; + + const response = await fixture.sendServerRequest('item/tool/requestUserInput', params); + expect(response).toEqual({ + answers: { + next_step: { answers: ['Run tests'] }, + notes: { answers: ['Focus auth'] }, + }, + }); + + const [elicitationEvent] = fixture.getAcpConnectionEvents(['_meta']); + expect(elicitationEvent).toMatchObject({ + method: 'createElicitation', + args: [{ + sessionId, + toolCallId: 'request-user-input-1', + mode: 'form', + message: 'Input requested', + requestedSchema: { + type: 'object', + required: ['notes'], + }, + }], + }); + expect(elicitationEvent!.args[0].requestedSchema.properties.next_step.oneOf).toEqual([ + { const: 'Run tests', title: 'Run tests', description: 'Run the focused test suite.' }, + { const: 'Stop', title: 'Stop', description: 'Stop and report current status.' }, + ]); + expect(elicitationEvent!.args[0].requestedSchema.properties.next_step__other).toMatchObject({ + type: 'string', + title: 'Other', + }); + expect(elicitationEvent!.args[0].requestedSchema.properties.notes).toMatchObject({ + type: 'string', + title: 'Notes', + description: 'Any extra instructions?', + }); + + completeTurn(); + await promptPromise; + }); + + it('should prefer free-form Other answers over fixed choices', async () => { + const { promptPromise, completeTurn } = await setupSessionWithPendingPromptAndCapabilities({ + elicitation: { form: {} }, + }); + fixture.setElicitationResponse({ + action: 'accept', + content: { + next_step: 'Run tests', + next_step__other: 'Inspect flaky logs', + }, + }); + + const params: ToolRequestUserInputParams = { + threadId: sessionId, + turnId: 'turn-1', + itemId: 'request-user-input-1', + autoResolutionMs: null, + questions: [{ + id: 'next_step', + header: 'Next step', + question: 'What should I do next?', + isOther: true, + isSecret: false, + options: [ + { label: 'Run tests', description: 'Run the focused test suite.' }, + { label: 'Stop', description: 'Stop and report current status.' }, + ], + }], + }; + + const response = await fixture.sendServerRequest('item/tool/requestUserInput', params); + expect(response).toEqual({ + answers: { + next_step: { answers: ['Inspect flaky logs'] }, + }, + }); + + completeTurn(); + await promptPromise; + }); + + it('should auto-resolve request_user_input when the client does not answer in time', async () => { + const { promptPromise, completeTurn } = await setupSessionWithPendingPromptAndCapabilities({ + elicitation: { form: {} }, + }); + fixture.setElicitationResponse(new Promise(() => {})); + + const params: ToolRequestUserInputParams = { + threadId: sessionId, + turnId: 'turn-1', + itemId: 'request-user-input-1', + autoResolutionMs: 1, + questions: [{ + id: 'next_step', + header: 'Next step', + question: 'What should I do next?', + isOther: false, + isSecret: false, + options: null, + }], + }; + + const response = await fixture.sendServerRequest('item/tool/requestUserInput', params); + expect(response).toEqual({ answers: {} }); + + const [elicitationEvent] = fixture.getAcpConnectionEvents(['_meta']); + expect(elicitationEvent).toMatchObject({ + method: 'createElicitation', + args: [{ + sessionId, + toolCallId: 'request-user-input-1', + mode: 'form', + }], + }); + + completeTurn(); + await promptPromise; + }); + + it('should not call ACP elicitation for request_user_input without form support', async () => { + const { promptPromise, completeTurn } = setupSessionWithPendingPrompt(); + + const params: ToolRequestUserInputParams = { + threadId: sessionId, + turnId: 'turn-1', + itemId: 'request-user-input-1', + autoResolutionMs: null, + questions: [{ + id: 'next_step', + header: 'Next step', + question: 'What should I do next?', + isOther: false, + isSecret: false, + options: null, + }], + }; + + const response = await fixture.sendServerRequest('item/tool/requestUserInput', params); + expect(response).toEqual({ answers: {} }); + expect(fixture.getAcpConnectionEvents(['_meta'])).toEqual([]); + + completeTurn(); + await promptPromise; + }); + }); }); diff --git a/src/__tests__/CodexACPAgent/initialize.test.ts b/src/__tests__/CodexACPAgent/initialize.test.ts index 85ec1caa..76a53176 100644 --- a/src/__tests__/CodexACPAgent/initialize.test.ts +++ b/src/__tests__/CodexACPAgent/initialize.test.ts @@ -41,6 +41,7 @@ describe('CodexACPAgent - initialize', () => { auth: { logout: {}, }, + providers: {}, loadSession: true, promptCapabilities: { embeddedContext: true, diff --git a/src/__tests__/CodexACPAgent/load-session.test.ts b/src/__tests__/CodexACPAgent/load-session.test.ts index 8fb5dfee..f8aea520 100644 --- a/src/__tests__/CodexACPAgent/load-session.test.ts +++ b/src/__tests__/CodexACPAgent/load-session.test.ts @@ -66,7 +66,7 @@ describe("CodexACPAgent - loadSession", () => { agentNickname: null, agentRole: null, gitInfo: null, - name: null, + name: "Saved title", turns: [ { id: "turn-1", @@ -161,6 +161,10 @@ describe("CodexACPAgent - loadSession", () => { result: "iVBORw0KGgo=", savedPath: "/test/project/generated-blue-square.png", }, + { + type: "contextCompaction", + id: "item-context-compaction-1", + }, ], }, ], diff --git a/src/__tests__/CodexACPAgent/providers.test.ts b/src/__tests__/CodexACPAgent/providers.test.ts new file mode 100644 index 00000000..e7addda8 --- /dev/null +++ b/src/__tests__/CodexACPAgent/providers.test.ts @@ -0,0 +1,168 @@ +import {describe, expect, it, vi} from "vitest"; +import * as acp from "@agentclientprotocol/sdk"; +import {createCodexMockTestFixture} from "../acp-test-utils"; +import {CUSTOM_GATEWAY_PROVIDER_ID} from "../../CodexAcpClient"; + +function expectInvalidParams(fn: () => unknown): void { + let caught: unknown; + try { + fn(); + } catch (err) { + caught = err; + } + expect(caught).toBeInstanceOf(acp.RequestError); + expect((caught as acp.RequestError).code).toBe(-32602); +} + +describe("Configurable LLM providers (providers/*)", () => { + it("advertises the providers capability in initialize", async () => { + const fixture = createCodexMockTestFixture(); + const result = await fixture.getCodexAcpAgent().initialize({ + protocolVersion: acp.PROTOCOL_VERSION, + }); + expect(result.agentCapabilities?.providers).toEqual({}); + }); + + it("lists the custom gateway provider as unconfigured before any set", () => { + const fixture = createCodexMockTestFixture(); + const response = fixture.getCodexAcpAgent().listProviders({}); + expect(response).toEqual({ + providers: [ + { + providerId: CUSTOM_GATEWAY_PROVIDER_ID, + supported: ["openai"], + required: false, + current: null, + }, + ], + }); + }); + + it("reflects set routing in list without echoing headers", () => { + const fixture = createCodexMockTestFixture(); + const agent = fixture.getCodexAcpAgent(); + agent.setProvider({ + providerId: CUSTOM_GATEWAY_PROVIDER_ID, + apiType: "openai", + baseUrl: "https://llm-gateway.corp.example.com/openai/v1", + headers: {Authorization: "Bearer super-secret"}, + }); + + const provider = agent.listProviders({}).providers[0]!; + expect(provider.current).toEqual({ + apiType: "openai", + baseUrl: "https://llm-gateway.corp.example.com/openai/v1", + }); + // The secret headers must never be echoed back through providers/list. + expect(JSON.stringify(provider)).not.toContain("super-secret"); + }); + + it("rejects an unsupported apiType with invalid_params", () => { + const fixture = createCodexMockTestFixture(); + const agent = fixture.getCodexAcpAgent(); + expectInvalidParams(() => agent.setProvider({ + providerId: CUSTOM_GATEWAY_PROVIDER_ID, + apiType: "anthropic", + baseUrl: "https://example.com", + })); + }); + + it("rejects an unknown providerId with invalid_params", () => { + const fixture = createCodexMockTestFixture(); + const agent = fixture.getCodexAcpAgent(); + expectInvalidParams(() => agent.setProvider({ + providerId: "does-not-exist", + apiType: "openai", + baseUrl: "https://example.com", + })); + }); + + it("rejects a malformed baseUrl with invalid_params", () => { + const fixture = createCodexMockTestFixture(); + const agent = fixture.getCodexAcpAgent(); + expectInvalidParams(() => agent.setProvider({ + providerId: CUSTOM_GATEWAY_PROVIDER_ID, + apiType: "openai", + baseUrl: " ", + })); + }); + + it("disables the custom gateway provider and encodes it as current: null", () => { + const fixture = createCodexMockTestFixture(); + const agent = fixture.getCodexAcpAgent(); + agent.setProvider({ + providerId: CUSTOM_GATEWAY_PROVIDER_ID, + apiType: "openai", + baseUrl: "https://example.com", + }); + expect(agent.listProviders({}).providers[0]!.current).not.toBeNull(); + + agent.disableProvider({providerId: CUSTOM_GATEWAY_PROVIDER_ID}); + expect(agent.listProviders({}).providers[0]!.current).toBeNull(); + }); + + it("treats disabling an unknown providerId as idempotent success", () => { + const fixture = createCodexMockTestFixture(); + const agent = fixture.getCodexAcpAgent(); + expect(() => agent.disableProvider({providerId: "not-a-real-provider"})).not.toThrow(); + // The known provider remains discoverable. + expect(agent.listProviders({}).providers[0]!.providerId).toBe(CUSTOM_GATEWAY_PROVIDER_ID); + }); + + it("applies the configured gateway to Codex config on session creation", async () => { + const fixture = createCodexMockTestFixture(); + const agent = fixture.getCodexAcpAgent(); + const codexAcpClient = fixture.getCodexAcpClient(); + const codexAppServerClient = fixture.getCodexAppServerClient(); + + vi.spyOn(codexAcpClient, "authRequired").mockResolvedValue(false); + const threadStartSpy = vi.spyOn(codexAppServerClient, "threadStart") + .mockRejectedValue(new Error("stop after capturing config")); + + agent.setProvider({ + providerId: CUSTOM_GATEWAY_PROVIDER_ID, + apiType: "openai", + baseUrl: "https://llm-gateway.corp.example.com/openai/v1", + headers: {Authorization: "Bearer super-secret"}, + }); + + await expect(agent.newSession({cwd: "/workspace", mcpServers: []})).rejects.toThrow(); + + expect(threadStartSpy).toHaveBeenCalledWith(expect.objectContaining({ + modelProvider: CUSTOM_GATEWAY_PROVIDER_ID, + config: expect.objectContaining({ + model_providers: expect.objectContaining({ + [CUSTOM_GATEWAY_PROVIDER_ID]: expect.objectContaining({ + base_url: "https://llm-gateway.corp.example.com/openai/v1", + wire_api: "responses", + http_headers: expect.objectContaining({ + "Authorization": "Bearer super-secret", + "X-Client-Feature-ID": "codex", + }), + }), + }), + }), + })); + }); + + it("shares state with the legacy gateway auth method", async () => { + const fixture = createCodexMockTestFixture(); + const codexAcpClient = fixture.getCodexAcpClient(); + + await codexAcpClient.authenticate({ + methodId: "gateway", + _meta: { + gateway: { + baseUrl: "https://gateway.internal/openai", + headers: {Authorization: "Bearer via-auth"}, + providerName: "Corp gateway", + }, + }, + } as acp.AuthenticateRequest); + + expect(codexAcpClient.listProviders()[0]!.current).toEqual({ + apiType: "openai", + baseUrl: "https://gateway.internal/openai", + }); + }); +}); diff --git a/src/__tests__/CodexACPAgent/session-config-options.test.ts b/src/__tests__/CodexACPAgent/session-config-options.test.ts index a5cfd49e..4bd139fd 100644 --- a/src/__tests__/CodexACPAgent/session-config-options.test.ts +++ b/src/__tests__/CodexACPAgent/session-config-options.test.ts @@ -75,9 +75,9 @@ describe("Session config options", () => { currentValue: "medium", type: "select", options: [ - {value: "low", name: "low"}, - {value: "medium", name: "medium"}, - {value: "high", name: "high"}, + {value: "low", name: "Low"}, + {value: "medium", name: "Medium"}, + {value: "high", name: "High"}, ], }); diff --git a/src/__tests__/CodexACPAgent/session-info-update-events.test.ts b/src/__tests__/CodexACPAgent/session-info-update-events.test.ts index fdf771e7..fe12e429 100644 --- a/src/__tests__/CodexACPAgent/session-info-update-events.test.ts +++ b/src/__tests__/CodexACPAgent/session-info-update-events.test.ts @@ -5,6 +5,40 @@ import { setupPromptTestSession } from "../acp-test-utils"; describe("CodexEventHandler - session info updates", () => { const sessionId = "test-session-id"; + it("uses the first user prompt as a fallback session title", async () => { + const { mockFixture } = setupPromptTestSession({ + sessionId, + sessionTitleSource: "unset", + }); + + await mockFixture.getCodexAcpAgent().prompt({ + sessionId, + prompt: [ + { type: "text", text: " Fix the flaky\n test " }, + { type: "text", text: "in CI" }, + ], + }); + + await expect(`${mockFixture.getAcpConnectionDump([])}\n`).toMatchFileSnapshot( + "data/session-info-update-fallback-title.json" + ); + }); + + it("does not replace an explicit session title with the prompt fallback", async () => { + const { mockFixture } = setupPromptTestSession({ + sessionId, + sessionTitle: "Explicit title", + sessionTitleSource: "explicit", + }); + + await mockFixture.getCodexAcpAgent().prompt({ + sessionId, + prompt: [{ type: "text", text: "Fallback title" }], + }); + + expect(mockFixture.getAcpConnectionEvents([])).toEqual([]); + }); + it("maps thread name updates to ACP session info updates", async () => { const { mockFixture } = setupPromptTestSession({ sessionId }); diff --git a/src/__tests__/acp-test-utils.ts b/src/__tests__/acp-test-utils.ts index aaaee80d..b7a3b01a 100644 --- a/src/__tests__/acp-test-utils.ts +++ b/src/__tests__/acp-test-utils.ts @@ -388,6 +388,8 @@ export function createTestSessionState(overrides?: Partial): Sessi planModeEnabled: false, planModeExplicitlySet: false, terminalOutputMode: "terminal_output_delta", + sessionTitle: null, + sessionTitleSource: "unknown", ...overrides, }; } diff --git a/src/index.ts b/src/index.ts index 5f8a83dd..ca0b3d37 100644 --- a/src/index.ts +++ b/src/index.ts @@ -124,6 +124,9 @@ function startAcpServer() { .onRequest(acp.methods.agent.session.setConfigOption, (ctx) => getAgent().setSessionConfigOption(ctx.params)) .onRequest(acp.methods.agent.authenticate, (ctx) => getAgent().authenticate(ctx.params)) .onRequest(acp.methods.agent.logout, (ctx) => getAgent().logout(ctx.params)) + .onRequest(acp.methods.agent.providers.list, (ctx) => getAgent().listProviders(ctx.params)) + .onRequest(acp.methods.agent.providers.set, (ctx) => getAgent().setProvider(ctx.params)) + .onRequest(acp.methods.agent.providers.disable, (ctx) => getAgent().disableProvider(ctx.params)) .onRequest(acp.methods.agent.session.prompt, (ctx) => getAgent().prompt(ctx.params, ctx.signal)) .onNotification(acp.methods.agent.session.cancel, (ctx) => getAgent().cancel(ctx.params)) .onRequest("authentication/status", emptyExtensionParamsParser, (ctx) => getAgent().extMethod("authentication/status", ctx.params))