Skip to content
Merged
5 changes: 5 additions & 0 deletions src/CodexAcpClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import open from "open";
import type {Disposable} from "vscode-jsonrpc";
import type {
ClientInfo,
McpStartupCompleteEvent,
ReasoningEffort,
ServerNotification
} from "./app-server";
Expand Down Expand Up @@ -275,6 +276,10 @@ export class CodexAcpClient {
return startup.ready;
}

async awaitMcpStartupResult(mcpStartupVersion: number): Promise<McpStartupCompleteEvent> {
return await this.codexClient.awaitMcpStartup(mcpStartupVersion);
}

getMcpStartupCompleteVersion(): number {
return this.codexClient.getMcpStartupCompleteVersion();
}
Expand Down
77 changes: 75 additions & 2 deletions src/CodexAcpServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -43,6 +51,10 @@ export interface SessionState {
sessionMcpServers?: Array<string>;
}

interface PendingMcpStartupSession {
requestedServers: Set<string>;
}

export class CodexAcpServer implements acp.Agent {
private readonly codexAcpClient: CodexAcpClient;
private readonly connection: acp.AgentSideConnection;
Expand All @@ -51,6 +63,7 @@ export class CodexAcpServer implements acp.Agent {
private readonly availableCommands: CodexCommands;

private readonly sessions: Map<string, SessionState>;
private readonly pendingMcpStartupSessions: Map<string, PendingMcpStartupSession>;

constructor(
connection: acp.AgentSideConnection,
Expand All @@ -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;
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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);
Comment thread
NikolaiSviridov marked this conversation as resolved.
}

await this.availableCommands.publish(sessionId);
const sessionModelState: SessionModelState = this.createModelState(models, currentModelId);
const sessionModeState: SessionModeState = sessionState.agentMode.toSessionModeState();
Expand Down Expand Up @@ -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<void> {
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<string>
): Promise<void> {
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<acp.PromptResponse> {
logger.log("Prompt received", {
sessionId: params.sessionId,
Expand Down
1 change: 0 additions & 1 deletion src/CodexAppServerClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,6 @@ export class CodexAppServerClient {
}
return;
}

const serverNotification = data as ServerNotification;
this.notify(serverNotification);
for (const callback of this.codexEventHandlers) {
Expand Down
64 changes: 61 additions & 3 deletions src/CodexEventHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -210,13 +214,20 @@ export class CodexEventHandler {

private async completeItemEvent(event: ItemCompletedNotification): Promise<UpdateSessionEvent | null> {
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);
Expand Down Expand Up @@ -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",
Expand Down
35 changes: 33 additions & 2 deletions src/CodexToolCallMapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import type {
CommandExecutionStatus,
DynamicToolCallStatus,
FileUpdateChange,
McpToolCallError,
McpToolCallResult,
McpToolCallStatus,
PatchApplyStatus,
ThreadItem,
Expand Down Expand Up @@ -84,7 +86,12 @@ export async function createCommandExecutionUpdate(
export async function createMcpToolCallUpdate(
item: ThreadItem & { type: "mcpToolCall" }
): Promise<UpdateSessionEvent> {
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(
Expand All @@ -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<string, JsonValue | string>,
rawOutput?: Record<string, JsonValue | string | null>,
): Promise<UpdateSessionEvent> {
return {
sessionUpdate: "tool_call",
Expand All @@ -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<string, JsonValue | string> {
return {
server,
tool,
arguments: argumentsValue,
};
}

export function createMcpRawOutput(
result: McpToolCallResult | null,
error: McpToolCallError | null,
): Record<string, JsonValue | string | null> | undefined {
if (result === null && error === null) {
return undefined;
}

return {
result,
error,
};
}

Expand Down
63 changes: 63 additions & 0 deletions src/__tests__/CodexACPAgent/CodexAcpClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading
Loading