diff --git a/README.md b/README.md index fe401b19..b4ea48d5 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ CODEX_PATH=/path/to/codex npx -y @agentclientprotocol/codex-acp The adapter advertises ACP auth methods during initialization. Clients can authenticate with: -- ChatGPT login. Set `NO_BROWSER=1` to hide this method in remote or browserless environments. +- ChatGPT login. Set `NO_BROWSER=1` to use device code auth for remote or browserless environments. - API key via `CODEX_API_KEY` or `OPENAI_API_KEY`. - A custom OpenAI-compatible gateway, when the client opts in to the gateway auth capability. @@ -53,7 +53,7 @@ The adapter advertises ACP auth methods during initialization. Clients can authe - `MODEL_PROVIDER` - model provider to pass to Codex for new sessions. - `DEFAULT_AUTH_REQUEST` - ACP auth request JSON used when Codex requires authentication. - `INITIAL_AGENT_MODE` - initial mode id: `read-only`, `agent`, or `agent-full-access`. -- `NO_BROWSER` - hide browser-based ChatGPT auth when set. +- `NO_BROWSER` - use device code auth instead of browser-based ChatGPT auth when set. - `APP_SERVER_LOGS` - directory for adapter logs. ## Development diff --git a/package-lock.json b/package-lock.json index 069e62a5..5d30983f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "1.0.2", "license": "Apache-2.0", "dependencies": { - "@agentclientprotocol/sdk": "^1.0.0", + "@agentclientprotocol/sdk": "^1.1.0", "@openai/codex": "^0.142.4", "diff": "^8.0.3", "open": "^11.0.0", @@ -29,9 +29,9 @@ } }, "node_modules/@agentclientprotocol/sdk": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-1.0.0.tgz", - "integrity": "sha512-5FHgLbLZhSAq9F+9SU7AOCXTDzaw/IYx/AvMjrZDs/YFZ9ZPxkdn9ti3fSkny0huHfj2hrqF6pqdns9gN5/Fig==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-1.1.0.tgz", + "integrity": "sha512-NT2KqphUJ3w6EksUL51ZhJgIYgq/ZLGcBPkyMKgRSO5PMVwe9DnKKX+Htnvk6KHh6dUuh34UHK4gKp+4te1Mdg==", "license": "Apache-2.0", "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" diff --git a/package.json b/package.json index 29e94719..316d1c68 100644 --- a/package.json +++ b/package.json @@ -61,7 +61,7 @@ "vitest": "^4.0.10" }, "dependencies": { - "@agentclientprotocol/sdk": "^1.0.0", + "@agentclientprotocol/sdk": "^1.1.0", "@openai/codex": "^0.142.4", "diff": "^8.0.3", "open": "^11.0.0", diff --git a/readme-dev.md b/readme-dev.md index bc147807..27305e48 100644 --- a/readme-dev.md +++ b/readme-dev.md @@ -10,7 +10,7 @@ Set `CODEX_PATH` to run a different Codex binary; versions other than the one sp - `MODEL_PROVIDER` - model provider to pass to Codex for new sessions. - `DEFAULT_AUTH_REQUEST` - ACP auth request JSON used when Codex requires authentication. - `INITIAL_AGENT_MODE` - initial mode id: `read-only`, `agent`, or `agent-full-access`. -- `NO_BROWSER` - hide browser-based ChatGPT auth when set. +- `NO_BROWSER` - use device code auth instead of browser-based ChatGPT auth when set. - `APP_SERVER_LOGS` - directory for adapter logs. ### Quick start diff --git a/src/CodexAcpClient.ts b/src/CodexAcpClient.ts index 2d5dbd64..dee0ddd9 100644 --- a/src/CodexAcpClient.ts +++ b/src/CodexAcpClient.ts @@ -40,6 +40,7 @@ import type { } from "./app-server/v2"; import packageJson from "../package.json"; import type {AuthenticationStatusResponse} from "./AcpExtensions"; +import type { AcpClientConnection } from "./ACPSessionConnection"; /** * API for accessing the Codex App Server using ACP requests. @@ -78,7 +79,12 @@ export class CodexAcpClient { }); } - async authenticate(authRequest: acp.AuthenticateRequest): Promise { + async authenticate( + connection: AcpClientConnection, + authRequest: acp.AuthenticateRequest, + requestId: acp.JsonRpcId, + env: NodeJS.ProcessEnv = process.env, + ): Promise { if (!isCodexAuthRequest(authRequest)) { throw RequestError.invalidRequest(); } @@ -94,14 +100,11 @@ export class CodexAcpClient { this.gatewayConfig = null; return true; } - const loginCompletedPromise = this.awaitNextLoginCompleted(); - const loginResponse = await this.codexClient.accountLogin({type: "chatgpt"}); - if (loginResponse.type == "chatgpt") { - await open(loginResponse.authUrl); + if (env["NO_BROWSER"]) { + return await this.authenticateWithChatGptDeviceCode(connection, requestId); + } else { + return await this.authenticateWithChatGptBrowser(); } - this.gatewayConfig = null; - const result = await loginCompletedPromise; - return result.success; } case "gateway": if (!authRequest._meta) throw RequestError.invalidRequest(); @@ -138,6 +141,59 @@ export class CodexAcpClient { return false; } + private async authenticateWithChatGptBrowser(): Promise { + const loginCompletedPromise = this.awaitNextLoginCompleted(); + const loginResponse = await this.codexClient.accountLogin({type: "chatgpt"}); + if (loginResponse.type == "chatgpt") { + await open(loginResponse.authUrl); + } + this.gatewayConfig = null; + const result = await loginCompletedPromise; + return result.success; + } + + private async authenticateWithChatGptDeviceCode(connection: AcpClientConnection, requestId: acp.JsonRpcId): Promise { + const loginCompletedPromise = this.awaitNextLoginCompleted(); + const loginResponse = await this.codexClient.accountLogin({type: "chatgptDeviceCode"}); + if (loginResponse.type == "chatgptDeviceCode") { + const url = loginResponse.verificationUrl; + const userCode = loginResponse.userCode; + + await this.requestDeviceCodeAuth(connection, { + requestId, + elicitationId: `chatgpt-login-${crypto.randomUUID()}`, + url, + message: `Follow these steps to sign in with ChatGPT using device code authorization:\n\ + \n1. Open this link in your browser and sign in to your account\n ${url}\n\ + \n2. Enter this one-time code (expires in 15 minutes)\n ${userCode}\n\ + \nDevice codes are a common phishing target. Never share this code.\n`, + }); + } + this.gatewayConfig = null; + const result = await loginCompletedPromise; + return result.success; + } + + private async requestDeviceCodeAuth( + client: AcpClientConnection, + request: { requestId: acp.JsonRpcId, elicitationId: string; url: string; message: string }, + ): Promise { + const response = await client.request( + acp.methods.client.elicitation.create, + { + mode: "url", + requestId: request.requestId, + elicitationId: request.elicitationId, + url: request.url, + message: request.message, + }, + ); + + if (response.action !== "accept") { + throw RequestError.authRequired(); + } + } + private async authenticateWithApiKey(apiKey: string): Promise { const loginCompletedPromise = this.awaitNextLoginCompleted(); await this.codexClient.accountLogin({ diff --git a/src/CodexAcpServer.ts b/src/CodexAcpServer.ts index 80486fe6..38e71112 100644 --- a/src/CodexAcpServer.ts +++ b/src/CodexAcpServer.ts @@ -233,7 +233,7 @@ export class CodexAcpServer { } } - async checkAuthorization(){ + async checkAuthorization(requestId: acp.JsonRpcId){ const authNeeded = await this.runWithProcessCheck(() => this.codexAcpClient.authRequired()); logger.log("Auth requirement checked", {authRequired: authNeeded}); if (authNeeded) { @@ -241,7 +241,7 @@ export class CodexAcpServer { logger.log("Authenticating with default auth request...", { authRequest: this.defaultAuthRequest }); - await this.authenticate(this.defaultAuthRequest) + await this.authenticate(this.defaultAuthRequest, requestId); logger.log("Authentication completed"); } else { logger.log("Authentication required but no default auth request provided, return to IDE"); @@ -250,9 +250,12 @@ export class CodexAcpServer { } } - async getOrCreateSession(request: acp.NewSessionRequest | acp.ResumeSessionRequest): Promise<[SessionId, LegacySessionModelState, SessionModeState]> { + async getOrCreateSession( + request: acp.NewSessionRequest | acp.ResumeSessionRequest, + requestId: acp.JsonRpcId, + ): Promise<[SessionId, LegacySessionModelState, SessionModeState]> { try { - return await this.tryCreateSession(request); + return await this.tryCreateSession(request, requestId); } catch (e) { const error = e instanceof Error ? e : new Error(String(e)); await this.handleError(error); @@ -331,11 +334,14 @@ export class CodexAcpServer { return generation; } - async tryCreateSession(request: acp.NewSessionRequest | acp.ResumeSessionRequest): Promise<[SessionId, LegacySessionModelState, SessionModeState]> { + async tryCreateSession( + request: acp.NewSessionRequest | acp.ResumeSessionRequest, + requestId: acp.JsonRpcId, + ): Promise<[SessionId, LegacySessionModelState, SessionModeState]> { const requestedSessionGeneration = "sessionId" in request ? this.beginSessionOpen(request.sessionId) : null; - await this.checkAuthorization(); + await this.checkAuthorization(requestId); const requestedMcpServers = request.mcpServers ?? []; const mcpServerStartupVersion = requestedMcpServers.length > 0 ? this.codexAcpClient.getMcpServerStartupVersion() @@ -453,14 +459,17 @@ export class CodexAcpServer { return null; } - async loadSession(params: acp.LoadSessionRequest): Promise { + async loadSession( + params: acp.LoadSessionRequest, + requestId: acp.JsonRpcId, + ): Promise { logger.log("Loading session...", {sessionId: params.sessionId}); const { sessionId, modelState, modeState, thread, - } = await this.getOrCreateSessionWithHistory(params); + } = await this.getOrCreateSessionWithHistory(params, requestId); await this.streamThreadHistory(sessionId, thread); @@ -476,9 +485,12 @@ export class CodexAcpServer { }; } - async resumeSession(params: acp.ResumeSessionRequest): Promise { + async resumeSession( + params: acp.ResumeSessionRequest, + requestId: acp.JsonRpcId, + ): Promise { logger.log("Resuming session...", {sessionId: params.sessionId}); - const [sessionId, modelState, modeState] = await this.getOrCreateSession(params); + const [sessionId, modelState, modeState] = await this.getOrCreateSession(params, requestId); logger.log("Session resumed", { sessionId: sessionId, @@ -492,9 +504,9 @@ export class CodexAcpServer { }; } - async listSessions(params: acp.ListSessionsRequest): Promise { + async listSessions(params: acp.ListSessionsRequest, requestId: acp.JsonRpcId): Promise { logger.log("Listing sessions...", {cwd: params.cwd, cursor: params.cursor}); - await this.checkAuthorization(); + await this.checkAuthorization(requestId); const response = await this.runWithProcessCheck(() => this.codexAcpClient.listSessions(params)); return { ...response, @@ -582,9 +594,10 @@ export class CodexAcpServer { async newSession( params: acp.NewSessionRequest, + requestId: acp.JsonRpcId, ): Promise { logger.log("Starting new session..."); - const [sessionId, modelState, modeState] = await this.getOrCreateSession(params); + const [sessionId, modelState, modeState] = await this.getOrCreateSession(params, requestId); logger.log("New session created", { sessionId: sessionId, @@ -602,9 +615,10 @@ export class CodexAcpServer { async authenticate( _params: acp.AuthenticateRequest, + requestId: acp.JsonRpcId, ): Promise { logger.log("Authenticate request received"); - const isAuthenticated = await this.runWithProcessCheck(() => this.codexAcpClient.authenticate(_params)); + const isAuthenticated = await this.runWithProcessCheck(() => this.codexAcpClient.authenticate(this.connection, _params, requestId)); if (!isAuthenticated) { logger.log("Authenticate request failed"); throw RequestError.invalidParams(); @@ -838,7 +852,8 @@ export class CodexAcpServer { } private async getOrCreateSessionWithHistory( - request: acp.LoadSessionRequest + request: acp.LoadSessionRequest, + requestId: acp.JsonRpcId, ): Promise<{ sessionId: SessionId; modelState: LegacySessionModelState; @@ -846,7 +861,7 @@ export class CodexAcpServer { thread: Thread; }> { const requestedSessionGeneration = this.beginSessionOpen(request.sessionId); - await this.checkAuthorization(); + await this.checkAuthorization(requestId); const requestedMcpServers = request.mcpServers ?? []; const mcpServerStartupVersion = requestedMcpServers.length > 0 ? this.codexAcpClient.getMcpServerStartupVersion() diff --git a/src/CodexAuthMethod.ts b/src/CodexAuthMethod.ts index 94c1833a..7f2c5cdf 100644 --- a/src/CodexAuthMethod.ts +++ b/src/CodexAuthMethod.ts @@ -59,7 +59,8 @@ export interface GatewayAuthRequest extends AuthenticateRequest { export function getCodexAuthMethods(clientCapabilities?: ClientCapabilities | null, env: NodeJS.ProcessEnv = process.env): AuthMethod[] { const authMethods: AuthMethod[] = [ApiKeyAuthMethod]; - if (!env["NO_BROWSER"]) { + // ChatGPT login requires a browser or URL elicitation support for device code auth + if (!env["NO_BROWSER"] || clientCapabilities?.elicitation?.url) { authMethods.push(ChatGptAuthMethod); } const supportsGatewayAuth = clientCapabilities?.auth?._meta?.["gateway"] === true; diff --git a/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts b/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts index 77077e5e..49651257 100644 --- a/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts +++ b/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts @@ -40,7 +40,7 @@ describe('ACP server test', { timeout: 40_000 }, () => { authFixture.clearCodexConnectionDump(); await expect( - codexAcpAgent.newSession({cwd: "", mcpServers: []}) + codexAcpAgent.newSession({cwd: "", mcpServers: []}, 0) ).rejects.toThrow("Authentication required"); const transportDump = authFixture.getCodexConnectionDump(ignoredFields); @@ -61,8 +61,8 @@ describe('ACP server test', { timeout: 40_000 }, () => { keyFixture.clearCodexConnectionDump(); const authRequest: CodexAuthRequest = { methodId: "api-key", _meta: { "api-key": { apiKey: "TOKEN" }}}; - await codexAcpAgent.authenticate(authRequest); - const newSessionResponse = await codexAcpAgent.newSession({cwd: "", mcpServers: []}); + await codexAcpAgent.authenticate(authRequest, 0); + const newSessionResponse = await codexAcpAgent.newSession({cwd: "", mcpServers: []}, 1); expect(newSessionResponse.sessionId).toBeDefined(); const transportEvents = keyFixture.getCodexConnectionEvents([...ignoredFields, "upgrade"]); @@ -128,7 +128,7 @@ describe('ACP server test', { timeout: 40_000 }, () => { await envFixture.getCodexAcpClient().logout(); envFixture.clearCodexConnectionDump(); - await codexAcpAgent.authenticate({methodId: "api-key"}); + await codexAcpAgent.authenticate({methodId: "api-key"}, 0); const transportEvents = envFixture.getCodexConnectionEvents([]); const loginRequest = transportEvents.find(event => @@ -157,7 +157,7 @@ describe('ACP server test', { timeout: 40_000 }, () => { await envFixture.getCodexAcpClient().logout(); envFixture.clearCodexConnectionDump(); - await codexAcpAgent.authenticate({methodId: "api-key"}); + await codexAcpAgent.authenticate({methodId: "api-key"}, 0); const transportEvents = envFixture.getCodexConnectionEvents([]); const loginRequest = transportEvents.find(event => @@ -182,7 +182,7 @@ describe('ACP server test', { timeout: 40_000 }, () => { vi.stubEnv(CODEX_API_KEY_ENV_VAR, ""); vi.stubEnv(OPENAI_API_KEY_ENV_VAR, ""); - await expect(codexAcpAgent.authenticate({methodId: "api-key"})) + await expect(codexAcpAgent.authenticate({methodId: "api-key"}, 0)) .rejects.toThrow(`${CODEX_API_KEY_ENV_VAR} or ${OPENAI_API_KEY_ENV_VAR} is not set`); }); @@ -195,13 +195,77 @@ describe('ACP server test', { timeout: 40_000 }, () => { }); const accountLoginSpy = vi.spyOn(codexAppServerClient, "accountLogin"); - await expect(chatGptFixture.getCodexAcpAgent().authenticate({methodId: "chat-gpt"})) + await expect(chatGptFixture.getCodexAcpAgent().authenticate({methodId: "chat-gpt"}, 0)) .resolves.toEqual({}); expect(accountReadSpy).toHaveBeenCalledWith({refreshToken: true}); expect(accountLoginSpy).not.toHaveBeenCalled(); }); + + it('should request ACP URL elicitation for device code auth', async () => { + const deviceCodeFixture = createCodexMockTestFixture(); + vi.spyOn(deviceCodeFixture.getCodexAppServerClient(), "accountRead").mockResolvedValue({ + account: null, + requiresOpenaiAuth: true, + }); + deviceCodeFixture.setElicitationResponse({action: "accept"}); + deviceCodeFixture.setAccountLoginResponse({ + type: "chatgptDeviceCode", + loginId: "login-id", + verificationUrl: "https://openai.example/device", + userCode: "ABCD-EFGH", + }); + vi.stubEnv("NO_BROWSER", "1"); + + const authRequestId = 0; + const authPromise = deviceCodeFixture + .getCodexAcpAgent() + .authenticate({ methodId: "chat-gpt" }, authRequestId); + + await vi.waitFor(() => { + expect(deviceCodeFixture.getCodexAppServerClient().accountLogin) + .toHaveBeenCalledWith({type: "chatgptDeviceCode"}); + }); + + await vi.waitFor(() => { + const elicitationEvent = deviceCodeFixture.getAcpConnectionEvents([]) + .find(event => event.method === "elicitationCreate"); + expect(elicitationEvent?.args[0]).toEqual(expect.objectContaining({ + mode: "url", + requestId: authRequestId, + elicitationId: expect.stringMatching(/^chatgpt-login-/), + url: "https://openai.example/device", + message: expect.stringContaining("ABCD-EFGH"), + })); + }); + + deviceCodeFixture.sendAccountLoginCompleted({loginId: "login-id", success: true, error: null}); + + await expect(authPromise).resolves.toEqual({}); + }); + + it('should fail device code auth when ACP URL elicitation is not accepted', async () => { + const deviceCodeFixture = createCodexMockTestFixture(); + vi.spyOn(deviceCodeFixture.getCodexAppServerClient(), "accountRead").mockResolvedValue({ + account: null, + requiresOpenaiAuth: true, + }); + deviceCodeFixture.setElicitationResponse({action: "cancel"}); + deviceCodeFixture.setAccountLoginResponse({ + type: "chatgptDeviceCode", + loginId: "login-id", + verificationUrl: "https://openai.example/device", + userCode: "ABCD-EFGH", + }); + vi.stubEnv("NO_BROWSER", "1"); + + await expect(deviceCodeFixture.getCodexAcpAgent().authenticate({methodId: "chat-gpt"}, 0)) + .rejects.toThrow("Authentication required"); + expect(deviceCodeFixture.getCodexAppServerClient().accountLogin) + .toHaveBeenCalledWith({type: "chatgptDeviceCode"}); + }); + it('should authenticate with a gateway', async () => { const gatewayFixture = createTestFixture(); const codexAcpAgent = gatewayFixture.getCodexAcpAgent(); @@ -230,13 +294,13 @@ describe('ACP server test', { timeout: 40_000 }, () => { } }; - await codexAcpAgent.authenticate(authRequest); + await codexAcpAgent.authenticate(authRequest, 0); expect(await gatewayFixture.getCodexAcpClient().authRequired()).toBe(false); const authenticatedResponse = await gatewayFixture.getCodexAcpAgent().extMethod("authentication/status", {}); expect(authenticatedResponse).toEqual({type: "gateway", name: "custom-gateway"}); - const newSessionResponse = await codexAcpAgent.newSession({cwd: "", mcpServers: []}); + const newSessionResponse = await codexAcpAgent.newSession({cwd: "", mcpServers: []}, 1); expect(newSessionResponse.sessionId).toBeDefined(); }); @@ -256,11 +320,11 @@ describe('ACP server test', { timeout: 40_000 }, () => { }); await authFixture.getCodexAcpClient().logout(); - await codexAcpAgent.authenticate({ - methodId: "api-key", - _meta: { "api-key": { apiKey: "TOKEN" } } - }); - const apiKeySession = await codexAcpAgent.newSession({cwd: "", mcpServers: []}); + await codexAcpAgent.authenticate( + {methodId: "api-key", _meta: {"api-key": {apiKey: "TOKEN"}}}, + 0, + ); + const apiKeySession = await codexAcpAgent.newSession({cwd: "", mcpServers: []}, 0); authFixture.clearAcpConnectionDump(); await codexAcpAgent.prompt({ @@ -271,18 +335,21 @@ describe('ACP server test', { timeout: 40_000 }, () => { const apiKeyStatusDump = authFixture.getAcpConnectionDump([]); expect(apiKeyStatusDump).toContain("**Account:** API key configured"); - await codexAcpAgent.authenticate({ - methodId: "gateway", - _meta: { - "gateway": { - baseUrl: "https://www.example.com", - headers: { - "Custom-Auth-Header": "TOKEN" + await codexAcpAgent.authenticate( + { + methodId: "gateway", + _meta: { + "gateway": { + baseUrl: "https://www.example.com", + headers: { + "Custom-Auth-Header": "TOKEN" + } } } - } - }); - const gatewaySession = await codexAcpAgent.newSession({cwd: "", mcpServers: []}); + }, + 0, + ); + const gatewaySession = await codexAcpAgent.newSession({cwd: "", mcpServers: []}, 0); authFixture.clearAcpConnectionDump(); await codexAcpAgent.prompt({ @@ -526,15 +593,14 @@ describe('ACP server test', { timeout: 40_000 }, () => { nextCursor: null, }); - await codexAcpAgent.resumeSession({ - sessionId: "resume-id", - cwd: "/workspace", - }); - await codexAcpAgent.loadSession({ - sessionId: "load-id", - cwd: "/workspace", - mcpServers: [], - }); + await codexAcpAgent.resumeSession( + {sessionId: "resume-id", cwd: "/workspace"}, + 0, + ); + await codexAcpAgent.loadSession( + {sessionId: "load-id", cwd: "/workspace", mcpServers: []}, + 1, + ); expect(threadResumeSpy.mock.calls[0]![0].modelProvider).toBe("azure"); expect(threadResumeSpy.mock.calls[1]![0].modelProvider).toBe("azure"); @@ -700,10 +766,10 @@ describe('ACP server test', { timeout: 40_000 }, () => { env: [], } as unknown as acp.McpServerStdio; - const session = await codexAcpAgent.newSession({ - cwd: "/workspace", - mcpServers: [mcpServer] - }); + const session = await codexAcpAgent.newSession( + {cwd: "/workspace", mcpServers: [mcpServer]}, + 0, + ); mockFixture.sendServerNotification({ method: "mcpServer/startupStatus/updated", @@ -1239,7 +1305,7 @@ describe('ACP server test', { timeout: 40_000 }, () => { fixture.clearCodexConnectionDump(); await expect( - fixture.getCodexAcpAgent().resumeSession({cwd: "", sessionId: sessionId}) + fixture.getCodexAcpAgent().resumeSession({cwd: "", sessionId: sessionId}, 0) ).rejects.toThrow("invalid session id"); }); @@ -2624,7 +2690,7 @@ describe('ACP server test', { timeout: 40_000 }, () => { fixture.getCodexAcpClient().authRequired = vi.fn().mockResolvedValue(false); - const newSessionResponse = await codexAcpAgent.newSession({cwd: "", mcpServers: []}); + const newSessionResponse = await codexAcpAgent.newSession({cwd: "", mcpServers: []}, 0); fixture.clearAcpConnectionDump(); const prompt: acp.ContentBlock[] = [{ type: "text", text: "/logout " }]; @@ -2674,8 +2740,8 @@ describe('ACP server test', { timeout: 40_000 }, () => { const logoutSpy = vi.spyOn(codexAcpClient, "logout").mockResolvedValue(); const authenticateSpy = vi.spyOn(codexAcpClient, "authenticate").mockResolvedValue(true); - const session1 = await codexAcpAgent.newSession({cwd: "/workspace", mcpServers: []}); - const session2 = await codexAcpAgent.newSession({cwd: "/workspace", mcpServers: []}); + const session1 = await codexAcpAgent.newSession({cwd: "/workspace", mcpServers: []}, 0); + const session2 = await codexAcpAgent.newSession({cwd: "/workspace", mcpServers: []}, 0); expect(codexAcpAgent.getSessionState(session1.sessionId).authConfigured).toBe(true); expect(codexAcpAgent.getSessionState(session2.sessionId).authConfigured).toBe(true); @@ -2695,9 +2761,10 @@ describe('ACP server test', { timeout: 40_000 }, () => { authConfigured: false, }); - await codexAcpAgent.authenticate({methodId: "api-key"}); + const authRequestId = 0; + await codexAcpAgent.authenticate({methodId: "api-key"}, authRequestId); - expect(authenticateSpy).toHaveBeenCalledWith({methodId: "api-key"}); + expect(authenticateSpy).toHaveBeenCalledWith(expect.anything(), {methodId: "api-key"}, authRequestId); expect(getAccountSpy).toHaveBeenCalledTimes(4); expect(codexAcpAgent.getSessionState(session1.sessionId)).toMatchObject({ account: { type: "apiKey" }, @@ -2732,7 +2799,7 @@ describe('ACP server test', { timeout: 40_000 }, () => { }); const authenticateSpy = vi.spyOn(codexAcpClient, "authenticate").mockResolvedValue(true); - const session = await codexAcpAgent.newSession({cwd: "/workspace", mcpServers: []}); + const session = await codexAcpAgent.newSession({cwd: "/workspace", mcpServers: []}, 0); expect(codexAcpAgent.getSessionState(session.sessionId)).toMatchObject({ account: { type: "apiKey" }, authConfigured: true, @@ -2750,9 +2817,10 @@ describe('ACP server test', { timeout: 40_000 }, () => { }, }, }; - await codexAcpAgent.authenticate(gatewayAuthRequest); + const gatewayAuthRequestId = 0; + await codexAcpAgent.authenticate(gatewayAuthRequest, gatewayAuthRequestId); - expect(authenticateSpy).toHaveBeenCalledWith(gatewayAuthRequest); + expect(authenticateSpy).toHaveBeenCalledWith(expect.anything(), gatewayAuthRequest, gatewayAuthRequestId); expect(getAccountSpy).toHaveBeenCalledTimes(1); expect(codexAcpAgent.getSessionState(session.sessionId)).toMatchObject({ account: { type: "apiKey" }, @@ -2783,7 +2851,7 @@ describe('ACP server test', { timeout: 40_000 }, () => { }); const logoutSpy = vi.spyOn(codexAcpClient, "logout").mockResolvedValue(); - const session = await codexAcpAgent.newSession({cwd: "/workspace", mcpServers: []}); + const session = await codexAcpAgent.newSession({cwd: "/workspace", mcpServers: []}, 0); expect(codexAcpAgent.getSessionState(session.sessionId)).toMatchObject({ account: null, authConfigured: true, @@ -2818,7 +2886,7 @@ describe('ACP server test', { timeout: 40_000 }, () => { }] }); - const newSessionResponse = await codexAcpAgent.newSession({cwd: "", mcpServers: []}); + const newSessionResponse = await codexAcpAgent.newSession({cwd: "", mcpServers: []}, 0); fixture.clearAcpConnectionDump(); const prompt: acp.ContentBlock[] = [{ type: "text", text: "/skills " }]; @@ -2854,7 +2922,7 @@ describe('ACP server test', { timeout: 40_000 }, () => { nextCursor: null }); - const newSessionResponse = await codexAcpAgent.newSession({cwd: "", mcpServers: []}); + const newSessionResponse = await codexAcpAgent.newSession({cwd: "", mcpServers: []}, 0); fixture.clearAcpConnectionDump(); const prompt: acp.ContentBlock[] = [{ type: "text", text: "/mcp " }]; @@ -2868,7 +2936,7 @@ describe('ACP server test', { timeout: 40_000 }, () => { fixture.getCodexAcpClient().authRequired = vi.fn().mockResolvedValue(false); - const newSessionResponse = await codexAcpAgent.newSession({cwd: "", mcpServers: []}); + const newSessionResponse = await codexAcpAgent.newSession({cwd: "", mcpServers: []}, 0); const prompt: acp.ContentBlock[] = [ { type: "text", text: "/status " }, { diff --git a/src/__tests__/CodexACPAgent/fast-mode-config.test.ts b/src/__tests__/CodexACPAgent/fast-mode-config.test.ts index ba8a71da..6e67993c 100644 --- a/src/__tests__/CodexACPAgent/fast-mode-config.test.ts +++ b/src/__tests__/CodexACPAgent/fast-mode-config.test.ts @@ -43,7 +43,7 @@ describe("Fast mode session config", () => { clientInfo, }); - const response = await codexAcpAgent.newSession({cwd: "/test/cwd", mcpServers: []}); + const response = await codexAcpAgent.newSession({cwd: "/test/cwd", mcpServers: []}, 0); return {fixture, codexAcpAgent, codexAcpClient, response}; } diff --git a/src/__tests__/CodexACPAgent/initialize.test.ts b/src/__tests__/CodexACPAgent/initialize.test.ts index e7050101..06ac4242 100644 --- a/src/__tests__/CodexACPAgent/initialize.test.ts +++ b/src/__tests__/CodexACPAgent/initialize.test.ts @@ -102,11 +102,27 @@ describe('CodexACPAgent - initialize', () => { ])); }); - it('should not advertise ChatGPT auth when browser auth is disabled', () => { + it('should not advertise ChatGPT auth when browser auth is disabled and URL elicitation is unsupported', () => { const methodIds = getCodexAuthMethods(undefined, {NO_BROWSER: "1"} as NodeJS.ProcessEnv) .map((method) => method.id); expect(methodIds).not.toContain("chat-gpt"); expect(methodIds).toEqual(expect.arrayContaining(["api-key"])); }); + + it('should advertise ChatGPT auth when browser auth is disabled and URL elicitation is supported', () => { + const methodIds = getCodexAuthMethods( + {elicitation: {url: {}}}, + {NO_BROWSER: "1"} as NodeJS.ProcessEnv, + ).map((method) => method.id); + + expect(methodIds).toEqual(expect.arrayContaining(["chat-gpt", "api-key"])); + }); + + it('should advertise ChatGPT auth when browser auth is enabled without URL elicitation support', () => { + const methodIds = getCodexAuthMethods(undefined, {} as NodeJS.ProcessEnv) + .map((method) => method.id); + + expect(methodIds).toEqual(expect.arrayContaining(["chat-gpt", "api-key"])); + }); }); diff --git a/src/__tests__/CodexACPAgent/list-sessions.test.ts b/src/__tests__/CodexACPAgent/list-sessions.test.ts index 36529576..881d4ddf 100644 --- a/src/__tests__/CodexACPAgent/list-sessions.test.ts +++ b/src/__tests__/CodexACPAgent/list-sessions.test.ts @@ -73,7 +73,7 @@ describe("CodexACPAgent - list sessions", () => { cwd: "/repo/project", cursor: null, }; - const response = await codexAcpAgent.listSessions(params); + const response = await codexAcpAgent.listSessions(params, 0); expect(codexAppServerClient.threadList).toHaveBeenCalledWith(expect.objectContaining({ sourceKinds: [ @@ -126,10 +126,10 @@ describe("CodexACPAgent - list sessions", () => { nextCursor: null, }); - const response = await codexAcpAgent.listSessions({ - cwd: null, - cursor: null, - }); + const response = await codexAcpAgent.listSessions( + {cwd: null, cursor: null}, + 0, + ); await expect(`${JSON.stringify(response, null, 2)}\n`).toMatchFileSnapshot( "data/list-sessions-thread-name.json" @@ -200,16 +200,15 @@ describe("CodexACPAgent - list sessions", () => { backwardsCursor: null, }); - await codexAcpAgent.newSession({ - cwd: "/repo/project", - additionalDirectories: ["/repo/extra"], - mcpServers: [], - }); + await codexAcpAgent.newSession( + {cwd: "/repo/project", additionalDirectories: ["/repo/extra"], mcpServers: []}, + 0, + ); - const response = await codexAcpAgent.listSessions({ - cwd: null, - cursor: null, - }); + const response = await codexAcpAgent.listSessions( + {cwd: null, cursor: null}, + 0, + ); expect(response.sessions[0]?.additionalDirectories).toEqual(["/repo/extra"]); }); diff --git a/src/__tests__/CodexACPAgent/load-session.test.ts b/src/__tests__/CodexACPAgent/load-session.test.ts index 8fb5dfee..b1896e6a 100644 --- a/src/__tests__/CodexACPAgent/load-session.test.ts +++ b/src/__tests__/CodexACPAgent/load-session.test.ts @@ -194,7 +194,7 @@ describe("CodexACPAgent - loadSession", () => { cwd: "/test/project", mcpServers: [], }; - await codexAcpAgent.loadSession(loadParams); + await codexAcpAgent.loadSession(loadParams, 0); expect(codexAppServerClient.threadRead).toHaveBeenCalledWith({ threadId: thread.id, @@ -278,11 +278,10 @@ describe("CodexACPAgent - loadSession", () => { }); await codexAcpAgent.initialize({ protocolVersion: 1 }); - await codexAcpAgent.loadSession({ - sessionId: "session-1", - cwd: "/test/project", - mcpServers: [], - }); + await codexAcpAgent.loadSession( + {sessionId: "session-1", cwd: "/test/project", mcpServers: []}, + 0, + ); expect(codexAcpAgent.getSessionState("session-1").sessionMcpServers).toEqual([]); }); @@ -533,11 +532,10 @@ describe("CodexACPAgent - loadSession", () => { }, }, }); - await codexAcpAgent.loadSession({ - sessionId: thread.id, - cwd: "/test/project", - mcpServers: [], - }); + await codexAcpAgent.loadSession( + {sessionId: thread.id, cwd: "/test/project", mcpServers: []}, + 0, + ); await expect(fixture.getAcpConnectionDump([])).toMatchFileSnapshot( "data/load-session-response-item-history-fallback.json", @@ -621,16 +619,21 @@ describe("CodexACPAgent - loadSession", () => { await codexAcpAgent.initialize({ protocolVersion: 1 }); - const loadPromise = codexAcpAgent.loadSession({ - sessionId: "session-1", - cwd: "/test/project", - mcpServers: [{ - name: "broken-mcp", - command: "npx", - args: ["broken"], - env: [], - }], - }); + const loadPromise = codexAcpAgent.loadSession( + { + sessionId: "session-1", + cwd: "/test/project", + mcpServers: [ + { + name: "broken-mcp", + command: "npx", + args: ["broken"], + env: [], + }, + ], + }, + 0, + ); await vi.waitFor(() => { expect(codexAcpAgent.getSessionState("session-1").sessionMcpServers).toEqual(["broken-mcp"]); diff --git a/src/__tests__/CodexACPAgent/mcp-config-merge.test.ts b/src/__tests__/CodexACPAgent/mcp-config-merge.test.ts index f2cb510c..72fdf771 100644 --- a/src/__tests__/CodexACPAgent/mcp-config-merge.test.ts +++ b/src/__tests__/CodexACPAgent/mcp-config-merge.test.ts @@ -52,10 +52,10 @@ url = "https://example.com/mcp" env: [{name: "example", value: "example"}], }; - const newSessionResponse = await codexAcpAgent.newSession({ - cwd: "", - mcpServers: [conflictingMcp], - }); + const newSessionResponse = await codexAcpAgent.newSession( + {cwd: "", mcpServers: [conflictingMcp]}, + 0, + ); fixture.clearAcpConnectionDump(); await codexAcpAgent.prompt({ @@ -82,9 +82,11 @@ url = "https://example.com/mcp" env: [{name: "example", value: "example"}], }; - await expect(codexAcpAgent.newSession({ - cwd: "", - mcpServers: [conflictingMcp], - })).rejects.toThrow("url is not supported for stdio"); + await expect( + codexAcpAgent.newSession( + {cwd: "", mcpServers: [conflictingMcp]}, + 0, + ), + ).rejects.toThrow("url is not supported for stdio"); }); }); diff --git a/src/__tests__/CodexACPAgent/mcp-session.test.ts b/src/__tests__/CodexACPAgent/mcp-session.test.ts index 84865074..3feb0d6d 100644 --- a/src/__tests__/CodexACPAgent/mcp-session.test.ts +++ b/src/__tests__/CodexACPAgent/mcp-session.test.ts @@ -22,7 +22,7 @@ describe('MCP session configuration', { timeout: 40_000 }, () => { 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]}); + const newSessionResponse = await codexAcpAgent.newSession({cwd: "", mcpServers: [mcpServer]}, 0); fixture.clearAcpConnectionDump(); await codexAcpAgent.prompt({sessionId: newSessionResponse.sessionId, prompt: [{type: "text", text: "/mcp"}]}); const transportDump = fixture.getAcpConnectionDump([]); diff --git a/src/__tests__/CodexACPAgent/model-filtering.test.ts b/src/__tests__/CodexACPAgent/model-filtering.test.ts index 280410a3..fe07dddb 100644 --- a/src/__tests__/CodexACPAgent/model-filtering.test.ts +++ b/src/__tests__/CodexACPAgent/model-filtering.test.ts @@ -132,7 +132,7 @@ describe("Model filtering", () => { }); vi.spyOn(codexAcpClient, "getAccount").mockResolvedValue({account: null, requiresOpenaiAuth: false}); - const newSessionResponse = await codexAcpAgent.newSession({ cwd: "", mcpServers: [] }); + const newSessionResponse = await codexAcpAgent.newSession({ cwd: "", mcpServers: [] }, 0); const sessionModels = newSessionResponse.models; const availableModels = sessionModels?.availableModels; diff --git a/src/__tests__/CodexACPAgent/new-session-logout.test.ts b/src/__tests__/CodexACPAgent/new-session-logout.test.ts index c8603f13..94ae298b 100644 --- a/src/__tests__/CodexACPAgent/new-session-logout.test.ts +++ b/src/__tests__/CodexACPAgent/new-session-logout.test.ts @@ -15,7 +15,7 @@ describe("New session logout handling", () => { const logoutSpy = vi.spyOn(codexAcpClient, "logout").mockResolvedValue(); - await expect(codexAcpAgent.newSession({cwd: "", mcpServers: []})) + await expect(codexAcpAgent.newSession({cwd: "", mcpServers: []}, 0)) .rejects.toMatchObject({ data: expect.stringContaining("You have been logged out. Please try again."), }); @@ -34,7 +34,7 @@ describe("New session logout handling", () => { const logoutSpy = vi.spyOn(codexAcpClient, "logout").mockResolvedValue(); - await expect(codexAcpAgent.newSession({cwd: "", mcpServers: []})) + await expect(codexAcpAgent.newSession({cwd: "", mcpServers: []}, 0)) .rejects.toMatchObject({ data: expect.stringContaining("You have been logged out. Please try again."), }); @@ -77,10 +77,10 @@ describe("New session logout handling", () => { .mockRejectedValueOnce(new Error(errorMessage)); const logoutSpy = vi.spyOn(codexAcpClient, "logout").mockResolvedValue(); - const openAiSession = await codexAcpAgent.newSession({cwd: "/workspace", mcpServers: []}); - const customProviderSession = await codexAcpAgent.newSession({cwd: "/workspace", mcpServers: []}); + const openAiSession = await codexAcpAgent.newSession({cwd: "/workspace", mcpServers: []}, 0); + const customProviderSession = await codexAcpAgent.newSession({cwd: "/workspace", mcpServers: []}, 0); - await expect(codexAcpAgent.newSession({cwd: "/workspace", mcpServers: []})) + await expect(codexAcpAgent.newSession({cwd: "/workspace", mcpServers: []}, 0)) .rejects.toMatchObject({ data: expect.stringContaining("You have been logged out. Please try again."), }); diff --git a/src/__tests__/CodexACPAgent/session-close.test.ts b/src/__tests__/CodexACPAgent/session-close.test.ts index 032abe3e..d6ecf452 100644 --- a/src/__tests__/CodexACPAgent/session-close.test.ts +++ b/src/__tests__/CodexACPAgent/session-close.test.ts @@ -202,11 +202,10 @@ describe("ACP session close", () => { vi.spyOn(codexAcpClient, "resumeSession").mockReturnValue(resume.promise); const closeSessionSpy = vi.spyOn(codexAcpClient, "closeSession"); - const resumePromise = codexAcpAgent.resumeSession({ - sessionId, - cwd: "/test/cwd", - mcpServers: [], - }); + const resumePromise = codexAcpAgent.resumeSession( + {sessionId, cwd: "/test/cwd", mcpServers: []}, + 0, + ); await codexAcpAgent.closeSession({sessionId}); resume.resolve(createSessionMetadata()); @@ -225,18 +224,16 @@ describe("ACP session close", () => { .mockResolvedValueOnce(createSessionMetadata()); const closeSessionSpy = vi.spyOn(codexAcpClient, "closeSession"); - const staleResumePromise = codexAcpAgent.resumeSession({ - sessionId, - cwd: "/test/cwd", - mcpServers: [], - }); + const staleResumePromise = codexAcpAgent.resumeSession( + {sessionId, cwd: "/test/cwd", mcpServers: []}, + 0, + ); await codexAcpAgent.closeSession({sessionId}); - await codexAcpAgent.resumeSession({ - sessionId, - cwd: "/test/cwd", - mcpServers: [], - }); + await codexAcpAgent.resumeSession( + {sessionId, cwd: "/test/cwd", mcpServers: []}, + 1, + ); staleResume.resolve(createSessionMetadata()); @@ -256,11 +253,10 @@ describe("ACP session close", () => { .mockResolvedValueOnce() .mockReturnValueOnce(staleUnsubscribe.promise); - const staleResumePromise = codexAcpAgent.resumeSession({ - sessionId, - cwd: "/test/cwd", - mcpServers: [], - }); + const staleResumePromise = codexAcpAgent.resumeSession( + {sessionId, cwd: "/test/cwd", mcpServers: []}, + 0, + ); await codexAcpAgent.closeSession({sessionId}); staleResume.resolve(createSessionMetadata()); @@ -269,25 +265,29 @@ describe("ACP session close", () => { expect(closeSessionSpy).toHaveBeenCalledTimes(2); }); - await expect(codexAcpAgent.resumeSession({ - sessionId, - cwd: "/test/cwd", - mcpServers: [], - })).rejects.toThrow("Invalid request"); + await expect( + codexAcpAgent.resumeSession( + {sessionId, cwd: "/test/cwd", mcpServers: []}, + 0, + ), + ).rejects.toThrow("Invalid request"); expect(resumeSpy).toHaveBeenCalledTimes(1); staleUnsubscribe.resolve(undefined); await expect(staleResumePromise).rejects.toThrow("Invalid request"); - await expect(codexAcpAgent.resumeSession({ - sessionId, - cwd: "/test/cwd", - mcpServers: [], - })).resolves.toEqual(expect.objectContaining({ - models: expect.objectContaining({ - currentModelId: "model-id[medium]", + await expect( + codexAcpAgent.resumeSession( + {sessionId, cwd: "/test/cwd", mcpServers: []}, + 0, + ), + ).resolves.toEqual( + expect.objectContaining({ + models: expect.objectContaining({ + currentModelId: "model-id[medium]", + }), }), - })); + ); }); it("preserves local close cleanup while stale resume cleanup overlaps close", async () => { @@ -300,11 +300,10 @@ describe("ACP session close", () => { .mockReturnValueOnce(activeUnsubscribe.promise) .mockReturnValueOnce(staleUnsubscribe.promise); - const staleResumePromise = codexAcpAgent.resumeSession({ - sessionId, - cwd: "/test/cwd", - mcpServers: [], - }); + const staleResumePromise = codexAcpAgent.resumeSession( + {sessionId, cwd: "/test/cwd", mcpServers: []}, + 0, + ); const closePromise = codexAcpAgent.closeSession({sessionId}); await vi.waitFor(() => { @@ -335,11 +334,12 @@ describe("ACP session close", () => { }); const closeSessionSpy = vi.spyOn(codexAcpClient, "closeSession").mockResolvedValue(); - await expect(codexAcpAgent.resumeSession({ - sessionId, - cwd: "/test/cwd", - mcpServers: [], - })).rejects.toThrow("model list failed"); + await expect( + codexAcpAgent.resumeSession( + {sessionId, cwd: "/test/cwd", mcpServers: []}, + 0, + ), + ).rejects.toThrow("model list failed"); expect(closeSessionSpy).toHaveBeenCalledWith(sessionId); }); @@ -356,11 +356,12 @@ describe("ACP session close", () => { vi.spyOn(codexAcpClient, "getAccount").mockRejectedValue(new Error("account read failed")); const closeSessionSpy = vi.spyOn(codexAcpClient, "closeSession").mockResolvedValue(); - await expect(codexAcpAgent.resumeSession({ - sessionId, - cwd: "/test/cwd", - mcpServers: [], - })).rejects.toThrow("account read failed"); + await expect( + codexAcpAgent.resumeSession( + {sessionId, cwd: "/test/cwd", mcpServers: []}, + 0, + ), + ).rejects.toThrow("account read failed"); expect(closeSessionSpy).toHaveBeenCalledWith(sessionId); }); @@ -388,11 +389,10 @@ describe("ACP session close", () => { await expect(promptPromise).resolves.toMatchObject({stopReason: "cancelled"}); vi.spyOn(codexAcpClient, "resumeSession").mockResolvedValue(createSessionMetadata()); - await codexAcpAgent.resumeSession({ - sessionId, - cwd: "/test/cwd", - mcpServers: [], - }); + await codexAcpAgent.resumeSession( + {sessionId, cwd: "/test/cwd", mcpServers: []}, + 0, + ); await codexAcpAgent.prompt({ sessionId, prompt: [{type: "text", text: "new prompt"}], @@ -463,7 +463,7 @@ async function createSession(options: { options.configure?.({fixture, codexAcpAgent, codexAcpClient}); - await codexAcpAgent.newSession({cwd: "/test/cwd", mcpServers: options.mcpServers ?? []}); + await codexAcpAgent.newSession({cwd: "/test/cwd", mcpServers: options.mcpServers ?? []}, 0); fixture.clearCodexConnectionDump(); fixture.clearAcpConnectionDump(); diff --git a/src/__tests__/CodexACPAgent/session-config-options.test.ts b/src/__tests__/CodexACPAgent/session-config-options.test.ts index 7377c909..4fcd3f06 100644 --- a/src/__tests__/CodexACPAgent/session-config-options.test.ts +++ b/src/__tests__/CodexACPAgent/session-config-options.test.ts @@ -45,7 +45,7 @@ async function createSession(currentModelId: string, availableModels: Array { models: [fast], additionalDirectories: [], }); - await codexAcpAgent.newSession({cwd: "/test/cwd", mcpServers: []}); + await codexAcpAgent.newSession({cwd: "/test/cwd", mcpServers: []}, 0); const extraModel = createTestModel({ id: "extra-model", diff --git a/src/__tests__/CodexACPAgent/session-delete.test.ts b/src/__tests__/CodexACPAgent/session-delete.test.ts index cc8285e6..5a6278b3 100644 --- a/src/__tests__/CodexACPAgent/session-delete.test.ts +++ b/src/__tests__/CodexACPAgent/session-delete.test.ts @@ -78,11 +78,12 @@ describe("ACP session delete", () => { expect(codexAcpClient.deleteSession).toHaveBeenCalledWith(sessionId); }); - await expect(codexAcpAgent.resumeSession({ - sessionId, - cwd: "/test/cwd", - mcpServers: [], - })).rejects.toThrow("Invalid request"); + await expect( + codexAcpAgent.resumeSession( + {sessionId, cwd: "/test/cwd", mcpServers: []}, + 0, + ), + ).rejects.toThrow("Invalid request"); expect(resumeSessionSpy).not.toHaveBeenCalled(); archive.resolve(undefined); @@ -100,11 +101,12 @@ describe("ACP session delete", () => { expect(codexAcpClient.deleteSession).toHaveBeenCalledWith(sessionId); }); - await expect(codexAcpAgent.loadSession({ - sessionId, - cwd: "/test/cwd", - mcpServers: [], - })).rejects.toThrow("Invalid request"); + await expect( + codexAcpAgent.loadSession( + {sessionId, cwd: "/test/cwd", mcpServers: []}, + 0, + ), + ).rejects.toThrow("Invalid request"); expect(loadSessionSpy).not.toHaveBeenCalled(); archive.resolve(undefined); @@ -132,7 +134,7 @@ async function createSession(): Promise<{ additionalDirectories: [], }); - await codexAcpAgent.newSession({cwd: "/test/cwd", mcpServers: []}); + await codexAcpAgent.newSession({cwd: "/test/cwd", mcpServers: []}, 0); fixture.clearCodexConnectionDump(); fixture.clearAcpConnectionDump(); diff --git a/src/__tests__/acp-test-utils.ts b/src/__tests__/acp-test-utils.ts index 2f50d49d..8518a007 100644 --- a/src/__tests__/acp-test-utils.ts +++ b/src/__tests__/acp-test-utils.ts @@ -12,7 +12,12 @@ import fs from "node:fs"; import os from "node:os"; import {AgentMode} from "../AgentMode"; import {expect, vi} from "vitest"; -import type {Model, ReasoningEffortOption} from "../app-server/v2"; +import type { + AccountLoginCompletedNotification, + LoginAccountResponse, + Model, + ReasoningEffortOption +} from "../app-server/v2"; export type MethodCallEvent = { method: string; args: any[] }; @@ -42,6 +47,9 @@ function normalizeAcpConnectionEvent(event: MethodCallEvent): MethodCallEvent { if (event.method === "request" && event.args[0] === acp.methods.client.session.requestPermission) { return {method: "requestPermission", args: [event.args[1]]}; } + if (event.method === "request" && event.args[0] === acp.methods.client.elicitation.create) { + return {method: "elicitationCreate", args: [event.args[1]]}; + } if (event.method === "notify" && event.args[0] === acp.methods.client.session.update) { return {method: "sessionUpdate", args: [event.args[1]]}; } @@ -238,6 +246,9 @@ export interface CodexMockTestFixture extends TestFixture { sendServerNotification(notification: ServerNotification | Record): void, sendServerRequest(method: string, params: unknown): Promise, setPermissionResponse(response: RequestPermissionResponse): void, + setElicitationResponse(response: acp.CreateElicitationResponse): void, + setAccountLoginResponse(response: LoginAccountResponse): void, + sendAccountLoginCompleted(notification: AccountLoginCompletedNotification): void, } /** @@ -250,18 +261,30 @@ export interface CodexMockTestFixture extends TestFixture { export function createCodexMockTestFixture(): CodexMockTestFixture { let unhandledNotificationHandler: ((notification: any) => void) | null = null; const requestHandlers = new Map Promise>(); + const loginCompletedHandlers = new Set<(notification: AccountLoginCompletedNotification) => void>(); // State for controlling permission responses const permissionState: { response: RequestPermissionResponse } = { response: { outcome: { outcome: 'cancelled' } } }; + const elicitationState: { response: acp.CreateElicitationResponse } = { + response: { action: 'cancel' } + }; const mockCodexConnection = { sendRequest: () => Promise.resolve(undefined), onUnhandledNotification: (handler: (notification: any) => void) => { unhandledNotificationHandler = handler; }, - onNotification: () => {}, + onNotification: (method: string, handler: (notification: AccountLoginCompletedNotification) => void) => { + if (method === "account/login/completed") { + loginCompletedHandlers.add(handler); + return { + dispose: () => loginCompletedHandlers.delete(handler), + }; + } + return { dispose: () => {} }; + }, onRequest: (type: { method: string }, handler: (params: unknown) => Promise) => { requestHandlers.set(type.method, handler); }, @@ -276,9 +299,13 @@ export function createCodexMockTestFixture(): CodexMockTestFixture { if (args[0] === acp.methods.client.session.requestPermission) { return permissionState.response; } + if (args[0] === acp.methods.client.elicitation.create) { + return elicitationState.response; + } return { mock: "Mocked return" }; }); returnValues.set('requestPermission', () => permissionState.response); + returnValues.set('elicitationCreate', () => elicitationState.response); const acpConnection = createSmartMock((event) => { const normalizedEvent = normalizeAcpConnectionEvent(event); @@ -313,6 +340,17 @@ export function createCodexMockTestFixture(): CodexMockTestFixture { setPermissionResponse(response: RequestPermissionResponse): void { permissionState.response = response; }, + setElicitationResponse(response: acp.CreateElicitationResponse): void { + elicitationState.response = response; + }, + setAccountLoginResponse(response: LoginAccountResponse): void { + vi.spyOn(baseFixture.getCodexAppServerClient(), "accountLogin").mockResolvedValue(response); + }, + sendAccountLoginCompleted(notification: AccountLoginCompletedNotification): void { + for (const handler of loginCompletedHandlers) { + handler(notification); + } + }, }; } diff --git a/src/index.ts b/src/index.ts index 5f8a83dd..9a6885ad 100644 --- a/src/index.ts +++ b/src/index.ts @@ -114,15 +114,15 @@ function startAcpServer() { }); }) .onRequest(acp.methods.agent.initialize, (ctx) => getAgent().initialize(ctx.params)) - .onRequest(acp.methods.agent.session.new, (ctx) => getAgent().newSession(ctx.params)) - .onRequest(acp.methods.agent.session.load, (ctx) => getAgent().loadSession(ctx.params)) - .onRequest(acp.methods.agent.session.list, (ctx) => getAgent().listSessions(ctx.params)) + .onRequest(acp.methods.agent.session.new, (ctx) => getAgent().newSession(ctx.params, ctx.requestId)) + .onRequest(acp.methods.agent.session.load, (ctx) => getAgent().loadSession(ctx.params, ctx.requestId)) + .onRequest(acp.methods.agent.session.list, (ctx) => getAgent().listSessions(ctx.params, ctx.requestId)) .onRequest(acp.methods.agent.session.delete, (ctx) => getAgent().deleteSession(ctx.params)) - .onRequest(acp.methods.agent.session.resume, (ctx) => getAgent().resumeSession(ctx.params)) + .onRequest(acp.methods.agent.session.resume, (ctx) => getAgent().resumeSession(ctx.params, ctx.requestId)) .onRequest(acp.methods.agent.session.close, (ctx) => getAgent().closeSession(ctx.params)) .onRequest(acp.methods.agent.session.setMode, (ctx) => getAgent().setSessionMode(ctx.params)) .onRequest(acp.methods.agent.session.setConfigOption, (ctx) => getAgent().setSessionConfigOption(ctx.params)) - .onRequest(acp.methods.agent.authenticate, (ctx) => getAgent().authenticate(ctx.params)) + .onRequest(acp.methods.agent.authenticate, (ctx) => getAgent().authenticate(ctx.params, ctx.requestId)) .onRequest(acp.methods.agent.logout, (ctx) => getAgent().logout(ctx.params)) .onRequest(acp.methods.agent.session.prompt, (ctx) => getAgent().prompt(ctx.params, ctx.signal)) .onNotification(acp.methods.agent.session.cancel, (ctx) => getAgent().cancel(ctx.params))