Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 38 additions & 17 deletions src/CodexAcpServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -224,7 +224,7 @@ interface ActiveAuthState {

interface PendingMcpStartupSession {
requestedServers: Set<string>;
afterVersion: number;
startup: Promise<McpStartupResult>;
}

interface PendingTurnStart {
Expand Down Expand Up @@ -702,12 +702,25 @@ export class CodexAcpServer {
resumeSubscribed = false;

const canPublishSessionUpdates = operation !== "fork";
if (canPublishSessionUpdates && requestedMcpServers.length > 0 && mcpServerStartupVersion !== null) {
this.pendingMcpStartupSessions.set(sessionId, {
requestedServers: new Set(getRequestedMcpServerNames(requestedMcpServers)),
afterVersion: mcpServerStartupVersion,
});
this.publishMcpStartupStatusAsync(sessionId);
if (requestedMcpServers.length > 0 && mcpServerStartupVersion !== null) {
const pendingStartup = this.createPendingMcpStartupSession(
requestedMcpServers,
mcpServerStartupVersion,
);
if (canPublishSessionUpdates) {
this.pendingMcpStartupSessions.set(sessionId, pendingStartup);
}
try {
await pendingStartup.startup;
} catch (err) {
if (this.pendingMcpStartupSessions.get(sessionId) === pendingStartup) {
this.pendingMcpStartupSessions.delete(sessionId);
}
throw err;
}
if (canPublishSessionUpdates) {
this.publishMcpStartupStatusAsync(sessionId);
}
Comment on lines +705 to +723

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As far as I understand - in case we have any MCP servers - we wait for their init always and for unbounded time. Two questions/concerns:

  1. Maybe we want to make that wait optional or opt-out?
  2. It might be better to introduce some waiting timeout here

What do you think?

}

if (canPublishSessionUpdates) {
Expand Down Expand Up @@ -1959,10 +1972,10 @@ export class CodexAcpServer {
subscribed = false;

if (requestedMcpServers.length > 0 && mcpServerStartupVersion !== null) {
this.pendingMcpStartupSessions.set(sessionId, {
requestedServers: new Set(getRequestedMcpServerNames(requestedMcpServers)),
afterVersion: mcpServerStartupVersion,
});
this.pendingMcpStartupSessions.set(
sessionId,
this.createPendingMcpStartupSession(requestedMcpServers, mcpServerStartupVersion),
);
this.publishMcpStartupStatusAsync(sessionId);
}

Expand Down Expand Up @@ -2417,19 +2430,27 @@ export class CodexAcpServer {
void this.doPublishMcpStartupStatus(sessionId);
}

private createPendingMcpStartupSession(
mcpServers: Array<acp.McpServer>,
afterVersion: number,
): PendingMcpStartupSession {
const requestedServers = new Set(getRequestedMcpServerNames(mcpServers));
return {
requestedServers,
startup: this.runWithProcessCheck(() =>
this.codexAcpClient.awaitMcpServerStartup(Array.from(requestedServers), afterVersion)
),
};
}

private async doPublishMcpStartupStatus(sessionId: string): Promise<void> {
const pendingStartup = this.pendingMcpStartupSessions.get(sessionId);
if (!pendingStartup) {
return;
}

try {
const mcpStartup = await this.runWithProcessCheck(() =>
this.codexAcpClient.awaitMcpServerStartup(
Array.from(pendingStartup.requestedServers),
pendingStartup.afterVersion,
)
);
const mcpStartup = await pendingStartup.startup;
if (!this.sessions.has(sessionId)
|| this.sessionIsClosing(sessionId)
|| this.pendingMcpStartupSessions.get(sessionId) !== pendingStartup) {
Expand Down
80 changes: 75 additions & 5 deletions src/__tests__/CodexACPAgent/CodexAcpClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import type {Model, ReviewStartResponse, ThreadGoal, TurnCompletedNotification,
import type {RateLimitsMap} from "../../RateLimitsMap";
import {ModelId} from "../../ModelId";
import {GOAL_CONTROL_METHOD} from "../../AcpExtensions";
import type {McpStartupResult} from "../../CodexAppServerClient";

describe('ACP server test', { timeout: 40_000 }, () => {

Expand Down Expand Up @@ -979,13 +980,13 @@ describe('ACP server test', { timeout: 40_000 }, () => {
});
});

it('forwards failed MCP startup as failed tool call updates after new session', async () => {
it('waits for all MCP servers before completing new session and forwards failures', async () => {
const mockFixture = createCodexMockTestFixture();
const codexAcpAgent = mockFixture.getCodexAcpAgent();
const codexAppServerClient = mockFixture.getCodexAppServerClient();

vi.spyOn(codexAcpAgent, "checkAuthorization").mockResolvedValue(undefined);
vi.spyOn(codexAppServerClient, "threadStart").mockResolvedValue({
const threadStartSpy = vi.spyOn(codexAppServerClient, "threadStart").mockResolvedValue({
thread: { id: "thread-id" } as any,
model: "gpt-5",
reasoningEffort: "medium",
Expand All @@ -1004,23 +1005,50 @@ describe('ACP server test', { timeout: 40_000 }, () => {
account: null,
} as any);
vi.spyOn(codexAppServerClient, "listSkills").mockResolvedValue({ data: [] });
const mcpServer = {
const readyMcpServer = {
name: "ready-mcp",
command: "npx",
args: ["ready"],
env: [],
} as unknown as acp.McpServerStdio;
const brokenMcpServer = {
name: "broken-mcp",
command: "npx",
args: ["broken"],
env: [],
} as unknown as acp.McpServerStdio;

const session = await codexAcpAgent.newSession({
const sessionPromise = codexAcpAgent.newSession({
cwd: "/workspace",
mcpServers: [mcpServer]
mcpServers: [readyMcpServer, brokenMcpServer]
});
let sessionSettled = false;
void sessionPromise.then(
() => { sessionSettled = true; },
() => { sessionSettled = true; },
);

await vi.waitFor(() => expect(threadStartSpy).toHaveBeenCalled());

mockFixture.sendServerNotification({
method: "mcpServer/startupStatus/updated",
params: { threadId: "thread-id", name: "ready-mcp", status: "ready", error: null }
});
mockFixture.sendServerNotification({
method: "mcpServer/startupStatus/updated",
params: { threadId: "thread-id", name: "broken-mcp", status: "starting", error: null }
});

await flushAsyncWork();
expect(sessionSettled).toBe(false);

mockFixture.sendServerNotification({
method: "mcpServer/startupStatus/updated",
params: { threadId: "thread-id", name: "broken-mcp", status: "failed", error: "boom" }
});

const session = await sessionPromise;

await vi.waitFor(() => {
const dump = mockFixture.getAcpConnectionDump([]);
expect(dump).toContain('"sessionId": "thread-id"');
Expand All @@ -1032,6 +1060,48 @@ describe('ACP server test', { timeout: 40_000 }, () => {
expect(session.sessionId).toBe("thread-id");
});

it('waits for MCP startup before completing session resume', async () => {
const mockFixture = createCodexMockTestFixture();
const codexAcpAgent = mockFixture.getCodexAcpAgent();
const codexAcpClient = mockFixture.getCodexAcpClient();
const mcpStartup = deferred<McpStartupResult>();

vi.spyOn(codexAcpClient, "authRequired").mockResolvedValue(false);
vi.spyOn(codexAcpClient, "getAccount").mockResolvedValue({account: null, requiresOpenaiAuth: false});
vi.spyOn(codexAcpClient, "listSkills").mockResolvedValue({data: []});
vi.spyOn(codexAcpClient, "resumeSession").mockResolvedValue({
sessionId: "resume-id",
currentModelId: "gpt-5[medium]",
models: [createTestModel({id: "gpt-5"})],
collaborationMode: "default",
currentServiceTier: null,
additionalDirectories: [],
});
const awaitMcpStartupSpy = vi.spyOn(codexAcpClient, "awaitMcpServerStartup")
.mockReturnValue(mcpStartup.promise);

const resumePromise = codexAcpAgent.resumeSession({
sessionId: "resume-id",
cwd: "/workspace",
mcpServers: [{name: "resume-mcp", command: "npx", args: ["resume"], env: []}],
});
let resumeSettled = false;
void resumePromise.then(
() => { resumeSettled = true; },
() => { resumeSettled = true; },
);

await vi.waitFor(() => {
expect(awaitMcpStartupSpy).toHaveBeenCalledWith(["resume-mcp"], expect.any(Number));
});
expect(resumeSettled).toBe(false);

mcpStartup.resolve({ready: ["resume-mcp"], failed: [], cancelled: []});
await expect(resumePromise).resolves.toMatchObject({
models: {currentModelId: "gpt-5[medium]"},
});
});

it('prefetches skills before turn start', async () => {
const mockFixture = createCodexMockTestFixture();
const codexAcpAgent = mockFixture.getCodexAcpAgent();
Expand Down
21 changes: 16 additions & 5 deletions src/__tests__/CodexACPAgent/session-close.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,22 +171,32 @@ describe("ACP session close", () => {
});

it("suppresses MCP startup updates while close is in progress", async () => {
const fixture = createCodexMockTestFixture();
const codexAcpAgent = fixture.getCodexAcpAgent();
const codexAcpClient = fixture.getCodexAcpClient();
const mcpStartup = deferred<McpStartupResult>();
const mcpServer: McpServer = {
name: "broken-mcp",
command: "npx",
args: ["broken"],
env: [],
};
const {fixture, codexAcpAgent, codexAcpClient} = await createSession({
mcpServers: [mcpServer],
configure: ({codexAcpClient}) => {
vi.spyOn(codexAcpClient, "awaitMcpServerStartup").mockReturnValue(mcpStartup.promise);
},
vi.spyOn(codexAcpClient, "authRequired").mockResolvedValue(false);
vi.spyOn(codexAcpClient, "getAccount").mockResolvedValue({account: null, requiresOpenaiAuth: false});
vi.spyOn(codexAcpClient, "listSkills").mockResolvedValue({data: []});
vi.spyOn(codexAcpClient, "newSession").mockResolvedValue({
sessionId,
currentModelId: "model-id[medium]",
models: [createTestModel()],
collaborationMode: "default",
currentServiceTier: null,
additionalDirectories: [],
});
vi.spyOn(codexAcpClient, "awaitMcpServerStartup").mockReturnValue(mcpStartup.promise);
const unsubscribe = deferred<void>();
vi.spyOn(codexAcpClient, "closeSession").mockReturnValue(unsubscribe.promise);

const newSessionPromise = codexAcpAgent.newSession({cwd: "/test/cwd", mcpServers: [mcpServer]});
await vi.waitFor(() => {
expect(codexAcpClient.awaitMcpServerStartup).toHaveBeenCalledWith(["broken-mcp"], expect.any(Number));
});
Expand All @@ -202,6 +212,7 @@ describe("ACP session close", () => {
failed: [{server: "broken-mcp", error: "boom"}],
cancelled: [],
});
await newSessionPromise;
await waitForMicrotasks();

expect(fixture.getAcpConnectionEvents([])).toEqual([]);
Expand Down
49 changes: 49 additions & 0 deletions src/__tests__/CodexACPAgent/session-fork.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {describe, expect, it, vi} from "vitest";
import {createCodexMockTestFixture, createTestModel} from "../acp-test-utils";
import type {McpStartupResult} from "../../CodexAppServerClient";

describe("ACP session fork", () => {
it("creates and installs a forked session", async () => {
Expand Down Expand Up @@ -43,4 +44,52 @@ describe("ACP session fork", () => {
mcpServers: [],
});
});

it("waits for MCP startup before completing session fork", async () => {
const fixture = createCodexMockTestFixture();
const agent = fixture.getCodexAcpAgent();
const client = fixture.getCodexAcpClient();
const mcpStartup = deferred<McpStartupResult>();

vi.spyOn(client, "authRequired").mockResolvedValue(false);
vi.spyOn(client, "getAccount").mockResolvedValue({account: null, requiresOpenaiAuth: false});
vi.spyOn(client, "listSkills").mockResolvedValue({data: []});
vi.spyOn(client, "forkSession").mockResolvedValue({
sessionId: "fork-id",
currentModelId: "gpt-5[medium]",
models: [createTestModel({id: "gpt-5"})],
collaborationMode: "default",
currentServiceTier: null,
additionalDirectories: [],
});
const awaitMcpStartupSpy = vi.spyOn(client, "awaitMcpServerStartup")
.mockReturnValue(mcpStartup.promise);

const forkPromise = agent.forkSession({
sessionId: "source-id",
cwd: "/workspace",
mcpServers: [{name: "fork-mcp", command: "npx", args: ["fork"], env: []}],
});
let forkSettled = false;
void forkPromise.then(
() => { forkSettled = true; },
() => { forkSettled = true; },
);

await vi.waitFor(() => {
expect(awaitMcpStartupSpy).toHaveBeenCalledWith(["fork-mcp"], expect.any(Number));
});
expect(forkSettled).toBe(false);

mcpStartup.resolve({ready: ["fork-mcp"], failed: [], cancelled: []});
await expect(forkPromise).resolves.toMatchObject({sessionId: "fork-id", modes: expect.any(Object)});
});
});

function deferred<T>(): {promise: Promise<T>, resolve: (value: T) => void} {
let resolve: (value: T) => void = () => {};
const promise = new Promise<T>((innerResolve) => {
resolve = innerResolve;
});
return {promise, resolve};
}