From 3e4d72dff12a19e70fff7a9f45cc7ea911b4a4e5 Mon Sep 17 00:00:00 2001 From: Josiah Hunsinger Date: Thu, 3 Sep 2026 14:12:41 -0400 Subject: [PATCH] fix: complete prompts on newly forked sessions --- src/CodexAcpServer.ts | 14 +++ .../CodexACPAgent/session-fork.test.ts | 114 ++++++++++++++++++ src/__tests__/acp-test-utils.ts | 1 + 3 files changed, 129 insertions(+) diff --git a/src/CodexAcpServer.ts b/src/CodexAcpServer.ts index 234e1cd3..f163646e 100644 --- a/src/CodexAcpServer.ts +++ b/src/CodexAcpServer.ts @@ -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 = @@ -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, @@ -1697,6 +1701,7 @@ export class CodexAcpServer { clientSupportsSubagents(this.clientCapabilities), new ACPSessionConnection(this.connection, sessionId), ), + resumeBeforePrompt: false, }; sessionState.titleGen = new TitleGenerator( this.codexAcpClient.appServerClient, @@ -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; diff --git a/src/__tests__/CodexACPAgent/session-fork.test.ts b/src/__tests__/CodexACPAgent/session-fork.test.ts index edc4007e..733679cd 100644 --- a/src/__tests__/CodexACPAgent/session-fork.test.ts +++ b/src/__tests__/CodexACPAgent/session-fork.test.ts @@ -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 () => { @@ -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((_, 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"}}, + }, + }], + }); + }); }); diff --git a/src/__tests__/acp-test-utils.ts b/src/__tests__/acp-test-utils.ts index 5cf73d5d..33574b71 100644 --- a/src/__tests__/acp-test-utils.ts +++ b/src/__tests__/acp-test-utils.ts @@ -409,6 +409,7 @@ export function createTestSessionState(overrides?: Partial): Sessi goalRevision: 0, sessionTitle: null, sessionTitleSource: "unknown", + resumeBeforePrompt: false, subagents: new CodexSubagentEventRouter( sessionId, false,