diff --git a/src/CodexAcpClient.ts b/src/CodexAcpClient.ts index 47d76716..39ed8998 100644 --- a/src/CodexAcpClient.ts +++ b/src/CodexAcpClient.ts @@ -7,6 +7,7 @@ import open from "open"; import type {Disposable} from "vscode-jsonrpc"; import type { ClientInfo, + McpStartupCompleteEvent, ReasoningEffort, ServerNotification } from "./app-server"; @@ -275,6 +276,10 @@ export class CodexAcpClient { return startup.ready; } + async awaitMcpStartupResult(mcpStartupVersion: number): Promise { + return await this.codexClient.awaitMcpStartup(mcpStartupVersion); + } + getMcpStartupCompleteVersion(): number { return this.codexClient.getMcpStartupCompleteVersion(); } diff --git a/src/CodexAcpServer.ts b/src/CodexAcpServer.ts index 3b695fa1..4e3832e8 100644 --- a/src/CodexAcpServer.ts +++ b/src/CodexAcpServer.ts @@ -10,9 +10,17 @@ import {CodexApprovalHandler} from "./CodexApprovalHandler"; import {CodexAuthMethods, type CodexAuthRequest} from "./CodexAuthMethod"; import {CodexAcpClient, type SessionMetadata, type SessionMetadataWithThread} from "./CodexAcpClient"; import {ACPSessionConnection, type UpdateSessionEvent} from "./ACPSessionConnection"; -import type {Account, CollabAgentToolCallStatus, Model, Thread, ThreadItem, UserInput, ReasoningEffortOption} from "./app-server/v2"; +import type {McpStartupCompleteEvent, InputModality, ReasoningEffort} from "./app-server"; +import type { + Account, + CollabAgentToolCallStatus, + Model, + Thread, + ThreadItem, + UserInput, + ReasoningEffortOption +} from "./app-server/v2"; import type {RateLimitsMap} from "./RateLimitsMap"; -import type {InputModality, ReasoningEffort} from "./app-server"; import {ModelId} from "./ModelId"; import {AgentMode} from "./AgentMode"; import type {TokenCount} from "./TokenCount"; @@ -43,6 +51,10 @@ export interface SessionState { sessionMcpServers?: Array; } +interface PendingMcpStartupSession { + requestedServers: Set; +} + export class CodexAcpServer implements acp.Agent { private readonly codexAcpClient: CodexAcpClient; private readonly connection: acp.AgentSideConnection; @@ -51,6 +63,7 @@ export class CodexAcpServer implements acp.Agent { private readonly availableCommands: CodexCommands; private readonly sessions: Map; + private readonly pendingMcpStartupSessions: Map; constructor( connection: acp.AgentSideConnection, @@ -59,6 +72,7 @@ export class CodexAcpServer implements acp.Agent { getExitCode?: () => number | null, ) { this.sessions = new Map(); + this.pendingMcpStartupSessions = new Map(); this.connection = connection; this.codexAcpClient = codexAcpClient; this.defaultAuthRequest = defaultAuthRequest ?? null; @@ -159,6 +173,14 @@ export class CodexAcpServer implements acp.Agent { } this.sessions.set(sessionId, sessionState); + const requestedMcpServers = request.mcpServers ?? []; + if (requestedMcpServers.length > 0) { + this.pendingMcpStartupSessions.set(sessionId, { + requestedServers: new Set(requestedMcpServers.map(server => server.name)), + }); + this.publishMcpStartupStatusAsync(sessionId, mcpStartupVersion); + } + this.publishAvailableCommandsAsync(sessionId); const sessionModelState: SessionModelState = this.createModelState(models, currentModelId); const sessionModeState: SessionModeState = sessionState.agentMode.toSessionModeState(); @@ -359,6 +381,14 @@ export class CodexAcpServer implements acp.Agent { }; this.sessions.set(sessionId, sessionState); + const requestedMcpServers = request.mcpServers ?? []; + if (requestedMcpServers.length > 0) { + this.pendingMcpStartupSessions.set(sessionId, { + requestedServers: new Set(requestedMcpServers.map(server => server.name)), + }); + this.publishMcpStartupStatusAsync(sessionId, mcpStartupVersion); + } + await this.availableCommands.publish(sessionId); const sessionModelState: SessionModelState = this.createModelState(models, currentModelId); const sessionModeState: SessionModeState = sessionState.agentMode.toSessionModeState(); @@ -621,6 +651,49 @@ export class CodexAcpServer implements acp.Agent { return await this.runWithProcessCheck(() => this.codexAcpClient.awaitMcpStartup(mcpStartupVersion)); } + private publishMcpStartupStatusAsync(sessionId: string, mcpStartupVersion: number): void { + void this.doPublishMcpStartupStatus(sessionId, mcpStartupVersion); + } + + private async doPublishMcpStartupStatus(sessionId: string, mcpStartupVersion: number): Promise { + try { + const mcpStartup = await this.runWithProcessCheck(() => this.codexAcpClient.awaitMcpStartupResult(mcpStartupVersion)); + const sessionState = this.sessions.get(sessionId); + const pendingStartup = this.pendingMcpStartupSessions.get(sessionId); + if (sessionState && pendingStartup) { + sessionState.sessionMcpServers = mcpStartup.ready.filter(serverName => + pendingStartup.requestedServers.has(serverName) + ); + } + await this.publishMcpStartupStatus(sessionId, mcpStartup, pendingStartup?.requestedServers); + } catch (err) { + logger.error(`Failed to publish MCP startup status for session ${sessionId}`, err); + } finally { + this.pendingMcpStartupSessions.delete(sessionId); + } + } + + private async publishMcpStartupStatus( + sessionId: string, + mcpStartup: McpStartupCompleteEvent, + requestedServers?: Set + ): Promise { + const filteredStartup = requestedServers + ? { + ready: mcpStartup.ready.filter(server => requestedServers.has(server)), + failed: mcpStartup.failed.filter(server => requestedServers.has(server.server)), + cancelled: mcpStartup.cancelled.filter(server => requestedServers.has(server)), + } + : mcpStartup; + + for (const update of CodexEventHandler.createMcpStartupUpdates(filteredStartup)) { + await this.connection.sessionUpdate({ + sessionId, + update, + }); + } + } + async prompt(params: acp.PromptRequest): Promise { logger.log("Prompt received", { sessionId: params.sessionId, diff --git a/src/CodexAppServerClient.ts b/src/CodexAppServerClient.ts index d9c9fda0..8fb6ce71 100644 --- a/src/CodexAppServerClient.ts +++ b/src/CodexAppServerClient.ts @@ -76,7 +76,6 @@ export class CodexAppServerClient { } return; } - const serverNotification = data as ServerNotification; this.notify(serverNotification); for (const callback of this.codexEventHandlers) { diff --git a/src/CodexEventHandler.ts b/src/CodexEventHandler.ts index 00e27310..81cb8f09 100644 --- a/src/CodexEventHandler.ts +++ b/src/CodexEventHandler.ts @@ -20,11 +20,14 @@ import type { ThreadTokenUsageUpdatedNotification, TurnPlanUpdatedNotification } from "./app-server/v2"; +import type { McpStartupCompleteEvent } from "./app-server"; import {toTokenCount} from "./TokenCount"; import { createCommandExecutionUpdate, createDynamicToolCallUpdate, createFileChangeUpdate, + createMcpRawInput, + createMcpRawOutput, createFuzzyFileSearchComplete, createFuzzyFileSearchStartOrUpdate, createMcpToolCallUpdate, @@ -101,12 +104,13 @@ export class CodexEventHandler { case "turn/diff/updated": case "item/commandExecution/terminalInteraction": case "item/fileChange/outputDelta": - case "item/mcpToolCall/progress": case "serverRequest/resolved": case "account/updated": case "fs/changed": case "mcpServer/startupStatus/updated": return null; + case "item/mcpToolCall/progress": + return this.createMcpToolProgressEvent(notification.params); case "account/rateLimits/updated": this.handleRateLimitsUpdated(notification.params); return null; @@ -210,13 +214,20 @@ export class CodexEventHandler { private async completeItemEvent(event: ItemCompletedNotification): Promise { switch (event.item.type) { - case "mcpToolCall": case "fileChange": case "dynamicToolCall": return { sessionUpdate: "tool_call_update", toolCallId: event.item.id, - status: event.item.status === "completed" ? "completed" : "failed" + status: event.item.status === "completed" ? "completed" : "failed", + } + case "mcpToolCall": + return { + sessionUpdate: "tool_call_update", + toolCallId: event.item.id, + status: event.item.status === "completed" ? "completed" : "failed", + rawInput: createMcpRawInput(event.item.server, event.item.tool, event.item.arguments), + rawOutput: createMcpRawOutput(event.item.result, event.item.error), } case "commandExecution": return this.completeCommandExecutionEvent(event.item); @@ -258,6 +269,53 @@ export class CodexEventHandler { } } + private createMcpToolProgressEvent(event: { itemId: string, message: string }): UpdateSessionEvent { + const logDelta = event.message.trim(); + return { + sessionUpdate: "tool_call_update", + toolCallId: event.itemId, + _meta: { + mcp_output_delta: { + data: logDelta, + } + } + }; + } + + static createMcpStartupUpdates(event: McpStartupCompleteEvent): UpdateSessionEvent[] { + const failedUpdates = event.failed.map((server: McpStartupCompleteEvent["failed"][number]) => this.createMcpStartupToolCallUpdate( + server.server, + `[codex-acp forwarded startup error] MCP server \`${server.server}\` failed to start: ${server.error}` + )); + const cancelledUpdates = event.cancelled.map((server: McpStartupCompleteEvent["cancelled"][number]) => this.createMcpStartupToolCallUpdate( + server, + `[codex-acp forwarded startup error] MCP server \`${server}\` startup was cancelled.` + )); + + return [...failedUpdates, ...cancelledUpdates]; + } + + private static createMcpStartupToolCallUpdate(serverName: string, message: string): UpdateSessionEvent { + return { + sessionUpdate: "tool_call", + toolCallId: this.getMcpStartupToolCallId(serverName), + kind: "other", + title: `mcp__${serverName}__startup`, + status: "failed", + content: [{ + type: "content", + content: { + type: "text", + text: message, + }, + }], + }; + } + + private static getMcpStartupToolCallId(serverName: string): string { + return `mcp_startup.${encodeURIComponent(serverName)}`; + } + private completeCommandExecutionEvent(item: ThreadItem & { "type": "commandExecution" }): UpdateSessionEvent { return { sessionUpdate: "tool_call_update", diff --git a/src/CodexToolCallMapper.ts b/src/CodexToolCallMapper.ts index 4f4cb26e..ab818662 100644 --- a/src/CodexToolCallMapper.ts +++ b/src/CodexToolCallMapper.ts @@ -13,6 +13,8 @@ import type { CommandExecutionStatus, DynamicToolCallStatus, FileUpdateChange, + McpToolCallError, + McpToolCallResult, McpToolCallStatus, PatchApplyStatus, ThreadItem, @@ -84,7 +86,12 @@ export async function createCommandExecutionUpdate( export async function createMcpToolCallUpdate( item: ThreadItem & { type: "mcpToolCall" } ): Promise { - return createExecuteToolCallUpdate(item, `mcp.${item.server}.${item.tool}`); + return createExecuteToolCallUpdate( + item, + `mcp.${item.server}.${item.tool}`, + createMcpRawInput(item.server, item.tool, item.arguments), + createMcpRawOutput(item.result, item.error), + ); } export async function createDynamicToolCallUpdate( @@ -96,7 +103,8 @@ export async function createDynamicToolCallUpdate( export async function createExecuteToolCallUpdate( item: ThreadItem & ({ type: "mcpToolCall" } | { type: "dynamicToolCall" }), title: string, - rawInput?: { arguments: JsonValue } + rawInput?: Record, + rawOutput?: Record, ): Promise { return { sessionUpdate: "tool_call", @@ -105,6 +113,29 @@ export async function createExecuteToolCallUpdate( title: title, status: toAcpStatus(item.status), rawInput: rawInput, + rawOutput: rawOutput, + }; +} + +export function createMcpRawInput(server: string, tool: string, argumentsValue: JsonValue): Record { + return { + server, + tool, + arguments: argumentsValue, + }; +} + +export function createMcpRawOutput( + result: McpToolCallResult | null, + error: McpToolCallError | null, +): Record | undefined { + if (result === null && error === null) { + return undefined; + } + + return { + result, + error, }; } diff --git a/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts b/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts index 94883384..b8572be4 100644 --- a/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts +++ b/src/__tests__/CodexACPAgent/CodexAcpClient.test.ts @@ -277,6 +277,69 @@ describe('ACP server test', { timeout: 40_000 }, () => { expect(mcpServers).toEqual(["alpha", "beta"]); }); + it('forwards failed MCP startup as failed tool call updates after new session', async () => { + const mockFixture = createCodexMockTestFixture(); + const codexAcpAgent = mockFixture.getCodexAcpAgent(); + const codexAppServerClient = mockFixture.getCodexAppServerClient(); + + vi.spyOn(codexAcpAgent, "checkAuthorization").mockResolvedValue(undefined); + vi.spyOn(codexAppServerClient, "threadStart").mockResolvedValue({ + thread: { id: "thread-id" } as any, + model: "gpt-5", + reasoningEffort: "medium", + } as any); + vi.spyOn(codexAppServerClient, "listModels").mockResolvedValue({ + data: [{ + id: "gpt-5", + name: "GPT-5", + inputModalities: ["text"], + supportedReasoningEfforts: [], + }], + hasMore: false, + } as any); + vi.spyOn(codexAppServerClient, "accountRead").mockResolvedValue({ + requiresOpenaiAuth: false, + account: null, + } as any); + vi.spyOn(codexAppServerClient, "listSkills").mockResolvedValue({ data: [] }); + const mcpServer = { + name: "broken-mcp", + command: "npx", + args: ["broken"], + env: [], + } as unknown as acp.McpServerStdio; + + const session = await codexAcpAgent.newSession({ + cwd: "/workspace", + mcpServers: [mcpServer] + }); + + mockFixture.sendServerNotification({ + method: "codex/event/mcp_startup_complete", + params: { + msg: { + type: "mcp_startup_complete", + ready: [], + failed: [{ + server: "broken-mcp", + error: "boom", + }], + cancelled: [], + } + } + }); + + await vi.waitFor(() => { + const dump = mockFixture.getAcpConnectionDump([]); + expect(dump).toContain('"sessionId": "thread-id"'); + expect(dump).toContain('"sessionUpdate": "tool_call"'); + expect(dump).toContain('"toolCallId": "mcp_startup.broken-mcp"'); + expect(dump).toContain('MCP server `broken-mcp` failed to start: boom'); + }); + + expect(session.sessionId).toBe("thread-id"); + }); + it('prefetches session additional skill roots before turn start', async () => { const mockFixture = createCodexMockTestFixture(); const codexAcpAgent = mockFixture.getCodexAcpAgent(); diff --git a/src/__tests__/CodexACPAgent/command-action-events.test.ts b/src/__tests__/CodexACPAgent/command-action-events.test.ts index c697d980..ebb3d8c7 100644 --- a/src/__tests__/CodexACPAgent/command-action-events.test.ts +++ b/src/__tests__/CodexACPAgent/command-action-events.test.ts @@ -260,6 +260,132 @@ describe('CodexEventHandler - command action events', () => { ); }); + it('should include mcp progress and final logs', async () => { + const notifications: ServerNotification[] = [ + { + method: 'item/started', + params: { + threadId: 'thread-1', + turnId: 'turn-1', + item: { + type: "mcpToolCall", + id: "call-id", + server: "ijproxy", + tool: "read_file", + status: "inProgress", + arguments: { file_path: ".ai/local.md", mode: "slice", start_line: 1, max_lines: 200 }, + result: null, + error: null, + durationMs: null, + }, + }, + }, + { + method: 'item/mcpToolCall/progress', + params: { + threadId: 'thread-1', + turnId: 'turn-1', + itemId: 'call-id', + message: "File /Users/aleksandr.slapoguzov/Projects/ultimate/.ai/local.md doesn't exist or can't be opened", + }, + }, + { + method: 'item/completed', + params: { + threadId: 'thread-1', + turnId: 'turn-1', + item: { + type: "mcpToolCall", + id: "call-id", + server: "ijproxy", + tool: "read_file", + status: "failed", + arguments: { file_path: ".ai/local.md", mode: "slice", start_line: 1, max_lines: 200 }, + result: null, + error: { + message: "File /Users/aleksandr.slapoguzov/Projects/ultimate/.ai/local.md doesn't exist or can't be opened", + }, + durationMs: 15, + }, + }, + }, + ]; + + await setupPromptAndSendNotifications(mockFixture, sessionId, sessionState, notifications); + + await expect(mockFixture.getAcpConnectionDump([])).toMatchFileSnapshot( + 'data/mcp-tool-completed-with-logs.json' + ); + }); + + it('should preserve repeated mcp progress messages in final output', async () => { + const repeatedMessage = 'Polling for status'; + const notifications: ServerNotification[] = [ + { + method: 'item/started', + params: { + threadId: 'thread-1', + turnId: 'turn-1', + item: { + type: "mcpToolCall", + id: "call-id", + server: "server-name", + tool: "tool-name", + status: "inProgress", + arguments: { argument: "example" }, + result: null, + error: null, + durationMs: null, + }, + }, + }, + { + method: 'item/mcpToolCall/progress', + params: { + threadId: 'thread-1', + turnId: 'turn-1', + itemId: 'call-id', + message: repeatedMessage, + }, + }, + { + method: 'item/mcpToolCall/progress', + params: { + threadId: 'thread-1', + turnId: 'turn-1', + itemId: 'call-id', + message: repeatedMessage, + }, + }, + { + method: 'item/completed', + params: { + threadId: 'thread-1', + turnId: 'turn-1', + item: { + type: "mcpToolCall", + id: "call-id", + server: "server-name", + tool: "tool-name", + status: "failed", + arguments: { argument: "example" }, + result: null, + error: { + message: repeatedMessage, + }, + durationMs: 15, + }, + }, + }, + ]; + + await setupPromptAndSendNotifications(mockFixture, sessionId, sessionState, notifications); + + await expect(mockFixture.getAcpConnectionDump([])).toMatchFileSnapshot( + 'data/mcp-tool-repeated-progress.json' + ); + }); + it('should handle dynamic tools', async () => { const dynamicToolNotification: ServerNotification = { method: 'item/started', diff --git a/src/__tests__/CodexACPAgent/data/load-session-history.json b/src/__tests__/CodexACPAgent/data/load-session-history.json index ba06a341..097e7f02 100644 --- a/src/__tests__/CodexACPAgent/data/load-session-history.json +++ b/src/__tests__/CodexACPAgent/data/load-session-history.json @@ -158,7 +158,12 @@ "toolCallId": "item-mcp-1", "kind": "execute", "title": "mcp.github.search", - "status": "completed" + "status": "completed", + "rawInput": { + "server": "github", + "tool": "search", + "arguments": {} + } } } ] diff --git a/src/__tests__/CodexACPAgent/data/mcp-tool-completed-with-logs.json b/src/__tests__/CodexACPAgent/data/mcp-tool-completed-with-logs.json new file mode 100644 index 00000000..f2530266 --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/mcp-tool-completed-with-logs.json @@ -0,0 +1,71 @@ +{ + "method": "sessionUpdate", + "args": [ + { + "sessionId": "test-session-id", + "update": { + "sessionUpdate": "tool_call_update", + "toolCallId": "call-id", + "_meta": { + "mcp_output_delta": { + "data": "File /Users/aleksandr.slapoguzov/Projects/ultimate/.ai/local.md doesn't exist or can't be opened" + } + } + } + } + ] +} +{ + "method": "sessionUpdate", + "args": [ + { + "sessionId": "test-session-id", + "update": { + "sessionUpdate": "tool_call_update", + "toolCallId": "call-id", + "status": "failed", + "rawInput": { + "server": "ijproxy", + "tool": "read_file", + "arguments": { + "file_path": ".ai/local.md", + "mode": "slice", + "start_line": 1, + "max_lines": 200 + } + }, + "rawOutput": { + "result": null, + "error": { + "message": "File /Users/aleksandr.slapoguzov/Projects/ultimate/.ai/local.md doesn't exist or can't be opened" + } + } + } + } + ] +} +{ + "method": "sessionUpdate", + "args": [ + { + "sessionId": "test-session-id", + "update": { + "sessionUpdate": "tool_call", + "toolCallId": "call-id", + "kind": "execute", + "title": "mcp.ijproxy.read_file", + "status": "in_progress", + "rawInput": { + "server": "ijproxy", + "tool": "read_file", + "arguments": { + "file_path": ".ai/local.md", + "mode": "slice", + "start_line": 1, + "max_lines": 200 + } + } + } + } + ] +} \ No newline at end of file diff --git a/src/__tests__/CodexACPAgent/data/mcp-tool-in-progress.json b/src/__tests__/CodexACPAgent/data/mcp-tool-in-progress.json index ea95bdcb..d6a5122c 100644 --- a/src/__tests__/CodexACPAgent/data/mcp-tool-in-progress.json +++ b/src/__tests__/CodexACPAgent/data/mcp-tool-in-progress.json @@ -8,7 +8,14 @@ "toolCallId": "call-id", "kind": "execute", "title": "mcp.server-name.tool-name", - "status": "in_progress" + "status": "in_progress", + "rawInput": { + "server": "server-name", + "tool": "tool-name", + "arguments": { + "argument": "example" + } + } } } ] diff --git a/src/__tests__/CodexACPAgent/data/mcp-tool-repeated-progress.json b/src/__tests__/CodexACPAgent/data/mcp-tool-repeated-progress.json new file mode 100644 index 00000000..d60c6e28 --- /dev/null +++ b/src/__tests__/CodexACPAgent/data/mcp-tool-repeated-progress.json @@ -0,0 +1,82 @@ +{ + "method": "sessionUpdate", + "args": [ + { + "sessionId": "test-session-id", + "update": { + "sessionUpdate": "tool_call_update", + "toolCallId": "call-id", + "_meta": { + "mcp_output_delta": { + "data": "Polling for status" + } + } + } + } + ] +} +{ + "method": "sessionUpdate", + "args": [ + { + "sessionId": "test-session-id", + "update": { + "sessionUpdate": "tool_call_update", + "toolCallId": "call-id", + "_meta": { + "mcp_output_delta": { + "data": "Polling for status" + } + } + } + } + ] +} +{ + "method": "sessionUpdate", + "args": [ + { + "sessionId": "test-session-id", + "update": { + "sessionUpdate": "tool_call_update", + "toolCallId": "call-id", + "status": "failed", + "rawInput": { + "server": "server-name", + "tool": "tool-name", + "arguments": { + "argument": "example" + } + }, + "rawOutput": { + "result": null, + "error": { + "message": "Polling for status" + } + } + } + } + ] +} +{ + "method": "sessionUpdate", + "args": [ + { + "sessionId": "test-session-id", + "update": { + "sessionUpdate": "tool_call", + "toolCallId": "call-id", + "kind": "execute", + "title": "mcp.server-name.tool-name", + "status": "in_progress", + "rawInput": { + "server": "server-name", + "tool": "tool-name", + "arguments": { + "argument": "example" + } + } + } + } + ] +} \ No newline at end of file diff --git a/src/__tests__/CodexACPAgent/load-session.test.ts b/src/__tests__/CodexACPAgent/load-session.test.ts index 951cb86e..e4464c7a 100644 --- a/src/__tests__/CodexACPAgent/load-session.test.ts +++ b/src/__tests__/CodexACPAgent/load-session.test.ts @@ -236,4 +236,105 @@ describe("CodexACPAgent - loadSession", () => { expect(awaitMcpStartupSpy).toHaveBeenCalledWith(0); expect(codexAcpAgent.getSessionState("session-1").sessionMcpServers).toEqual(["persisted-mcp"]); }); + + it("publishes MCP startup failure for explicitly requested servers during loadSession", async () => { + const fixture = createCodexMockTestFixture(); + const codexAcpAgent = fixture.getCodexAcpAgent(); + const codexAcpClient = fixture.getCodexAcpClient(); + const codexAppServerClient = fixture.getCodexAppServerClient(); + + codexAcpClient.authRequired = vi.fn().mockResolvedValue(false); + codexAcpClient.getAccount = vi.fn().mockResolvedValue({ + account: null, + requiresOpenaiAuth: false, + }); + codexAcpClient.listSkills = vi.fn().mockResolvedValue({ data: [] }); + + const model: Model = { + id: "gpt-5.2", + model: "gpt-5.2", + upgrade: null, + upgradeInfo: null, + availabilityNux: null, + displayName: "GPT-5.2", + description: "Test model", + hidden: false, + supportedReasoningEfforts: [{ reasoningEffort: "medium", description: "Medium" }], + defaultReasoningEffort: "medium", + inputModalities: ["text"], + supportsPersonality: false, + isDefault: true, + }; + + codexAppServerClient.listModels = vi.fn().mockResolvedValue({ + data: [model], + nextCursor: null, + }); + codexAppServerClient.threadResume = vi.fn().mockResolvedValue({ + thread: { + id: "session-1", + preview: "", + ephemeral: false, + modelProvider: "openai", + createdAt: 0, + updatedAt: 0, + status: { type: "idle" }, + path: null, + cwd: "/test/project", + cliVersion: "0.0.0", + source: "cli", + agentNickname: null, + agentRole: null, + gitInfo: null, + name: null, + turns: [], + }, + model: model.id, + modelProvider: "openai", + cwd: "/test/project", + approvalPolicy: "never", + sandbox: { type: "dangerFullAccess" }, + reasoningEffort: model.defaultReasoningEffort, + }); + + await codexAcpAgent.initialize({ protocolVersion: 1 }); + + const loadPromise = codexAcpAgent.loadSession({ + sessionId: "session-1", + cwd: "/test/project", + mcpServers: [{ + name: "broken-mcp", + command: "npx", + args: ["broken"], + env: [], + }], + }); + + await vi.waitFor(() => { + expect(codexAcpAgent.getSessionState("session-1").sessionMcpServers).toEqual(["broken-mcp"]); + }); + + fixture.sendServerNotification({ + method: "codex/event/mcp_startup_complete", + params: { + msg: { + type: "mcp_startup_complete", + ready: [], + failed: [{ + server: "broken-mcp", + error: "boom", + }], + cancelled: [], + } + } + }); + + await loadPromise; + + await vi.waitFor(() => { + const dump = fixture.getAcpConnectionDump([]); + expect(dump).toContain('"toolCallId": "mcp_startup.broken-mcp"'); + expect(dump).toContain('MCP server `broken-mcp` failed to start: boom'); + }); + }); });