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
14 changes: 14 additions & 0 deletions src/CodexAcpServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,9 @@ export interface SessionState {
sessionFailure?: SessionFailure;
titleGen?: TitleGenerator;
subagents: CodexSubagentEventRouter;
// Fork creation releases the app-server writer so another ACP process can load it.
// A direct prompt must reacquire that subscription first.
resumeBeforePrompt: boolean;
}

export type SessionFailureCategory =
Expand Down Expand Up @@ -646,6 +649,7 @@ export class CodexAcpServer {
clientSupportsSubagents(this.clientCapabilities),
new ACPSessionConnection(this.connection, sessionId),
),
resumeBeforePrompt: operation === "fork",
};
sessionState.titleGen = new TitleGenerator(
this.codexAcpClient.appServerClient,
Expand Down Expand Up @@ -1697,6 +1701,7 @@ export class CodexAcpServer {
clientSupportsSubagents(this.clientCapabilities),
new ACPSessionConnection(this.connection, sessionId),
),
resumeBeforePrompt: false,
};
sessionState.titleGen = new TitleGenerator(
this.codexAcpClient.appServerClient,
Expand Down Expand Up @@ -2486,6 +2491,15 @@ export class CodexAcpServer {
prompt: params.prompt,
});
const sessionState = this.getSessionState(params.sessionId);
if (sessionState.resumeBeforePrompt) {
await this.runWithProcessCheck(() => this.codexAcpClient.resumeSession({
sessionId: sessionState.sessionId,
cwd: sessionState.cwd,
additionalDirectories: sessionState.additionalDirectories,
mcpServers: sessionState.mcpServers ?? [],
}));
sessionState.resumeBeforePrompt = false;
}
const agentFileChangeReportRequest = clientSupportsAgentFileChangeReports(this.clientCapabilities)
? parseAgentFileChangeReportRequest(params._meta)
: null;
Expand Down
114 changes: 114 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 {ServerNotification} from "../../app-server";

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

it("streams and completes a prompt sent directly to a newly forked session", async () => {
const fixture = createCodexMockTestFixture();
const agent = fixture.getCodexAcpAgent();
const client = fixture.getCodexAcpClient();
const appServer = fixture.getCodexAppServerClient();
const model = createTestModel({id: "gpt-5"});
const metadata = {
sessionId: "fork-id",
currentModelId: "gpt-5[medium]",
models: [model],
collaborationMode: "default" as const,
modelProvider: "openai",
currentServiceTier: null,
additionalDirectories: [],
};

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(metadata);
const resumeSpy = vi.spyOn(client, "resumeSession").mockResolvedValue(metadata);
vi.spyOn(appServer, "turnStart").mockImplementation(async () => {
queueMicrotask(() => {
const notifications: ServerNotification[] = [
{
method: "item/started",
params: {
threadId: "fork-id",
turnId: "turn-id",
startedAtMs: 0,
item: {
type: "agentMessage",
id: "message-id",
text: "",
phase: "final_answer",
memoryCitation: null,
delivery: null,
},
},
},
{
method: "item/agentMessage/delta",
params: {
threadId: "fork-id",
turnId: "turn-id",
itemId: "message-id",
delta: "Fork answer",
},
},
{
method: "turn/completed",
params: {
threadId: "fork-id",
turn: {
id: "turn-id",
items: [],
itemsView: "notLoaded",
status: "completed",
error: null,
startedAt: null,
completedAt: null,
durationMs: null,
},
},
},
];
notifications.forEach(notification => fixture.sendServerNotification(notification));
});
return {
turn: {
id: "turn-id",
items: [],
itemsView: "notLoaded",
status: "inProgress",
error: null,
startedAt: null,
completedAt: null,
durationMs: null,
},
};
});

const fork = await agent.forkSession({sessionId: "source-id", cwd: "/workspace", mcpServers: []});
const prompt = agent.prompt({
sessionId: fork.sessionId,
prompt: [{type: "text", text: "Answer from the fork"}],
});
const response = await Promise.race([
prompt,
new Promise<never>((_, reject) => setTimeout(() => reject(new Error("fork prompt timed out")), 1_000)),
]);

expect(response.stopReason).toBe("end_turn");
expect(resumeSpy).toHaveBeenCalledWith({
sessionId: "fork-id",
cwd: "/workspace",
additionalDirectories: [],
mcpServers: [],
});
expect(fixture.getAcpConnectionEvents([])).toContainEqual({
method: "sessionUpdate",
args: [{
sessionId: "fork-id",
update: {
sessionUpdate: "agent_message_chunk",
content: {type: "text", text: "Fork answer"},
messageId: "message-id",
_meta: {codex: {phase: "final_answer"}},
},
}],
});
});
});
1 change: 1 addition & 0 deletions src/__tests__/acp-test-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -409,6 +409,7 @@ export function createTestSessionState(overrides?: Partial<SessionState>): Sessi
goalRevision: 0,
sessionTitle: null,
sessionTitleSource: "unknown",
resumeBeforePrompt: false,
subagents: new CodexSubagentEventRouter(
sessionId,
false,
Expand Down