diff --git a/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.test.ts b/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.test.ts index def493b9e8..ab41111252 100644 --- a/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.test.ts +++ b/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.test.ts @@ -2962,6 +2962,57 @@ describe("CodexAppServerAgent", () => { }); }); + it("expands a persisted plan only when it is the last replayed item", async () => { + const stub = makeStubRpc({ + "thread/start": { thread: { id: "t1" } }, + "thread/resume": { + thread: { + id: "t1", + turns: [ + { + items: [ + { type: "plan", id: "p1", text: "# Earlier plan" }, + { type: "agentMessage", id: "a1", text: "More work" }, + { type: "plan", id: "p2", text: "# Latest plan" }, + ], + }, + ], + }, + }, + }); + const { client, sessionUpdates } = makeFakeClient(); + const agent = new CodexAppServerAgent(client, { + processOptions: { binaryPath: "/x/codex" }, + model: "gpt-5.5", + rpcFactory: stub.factory, + }); + await agent.newSession({ cwd: "/r" } as unknown as NewSessionRequest); + + await agent.loadSession({ + sessionId: "t1", + cwd: "/r", + mcpServers: [], + } as unknown as Parameters[0]); + + const plans = sessionUpdates + .map( + (entry) => + entry as { + update?: { + kind?: string; + rawInput?: Record; + }; + }, + ) + .filter((entry) => entry.update?.kind === "switch_mode"); + expect(plans).toHaveLength(2); + expect(plans[0]?.update?.rawInput).not.toHaveProperty("initiallyExpanded"); + expect(plans[1]?.update?.rawInput).toMatchObject({ + historical: true, + initiallyExpanded: true, + }); + }); + it("restores subagent relationships from resumed thread history", async () => { const stub = makeStubRpc({ initialize: {}, diff --git a/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.ts b/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.ts index 74a7c15390..8625f253f9 100644 --- a/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.ts +++ b/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.ts @@ -448,13 +448,29 @@ export class CodexAppServerAgent extends BaseAcpAgent { /** Replay a resumed thread's persisted turns (from the thread/resume response) as session updates. */ private replayHistory(thread: AppServerThread | undefined): void { if (!this.sessionId || !thread?.turns?.length) return; + const updates: SessionNotification[] = []; for (const turn of thread.turns) { for (const item of turn.items ?? []) { - for (const update of mapHistoryItem(this.sessionId, item)) { - void this.client.sessionUpdate(update).catch(() => undefined); - } + updates.push(...mapHistoryItem(this.sessionId, item)); } } + + const lastUpdate = updates.at(-1)?.update; + if ( + lastUpdate?.sessionUpdate === "tool_call" && + lastUpdate.kind === "switch_mode" && + lastUpdate.rawInput && + typeof lastUpdate.rawInput === "object" + ) { + lastUpdate.rawInput = { + ...lastUpdate.rawInput, + initiallyExpanded: true, + }; + } + + for (const update of updates) { + void this.client.sessionUpdate(update).catch(() => undefined); + } } async listSessions( diff --git a/packages/agent/src/server/agent-server.test.ts b/packages/agent/src/server/agent-server.test.ts index 49cd5ca9d2..6b37a77f4b 100644 --- a/packages/agent/src/server/agent-server.test.ts +++ b/packages/agent/src/server/agent-server.test.ts @@ -578,6 +578,45 @@ describe("AgentServer HTTP Mode", () => { expect(testServer.eventStreamSender.stop).not.toHaveBeenCalled(); }); + it("settles a plan prompt before closing ACP during cleanup", async () => { + const testServer = stubSessionCleanup(createServer()) as ReturnType< + typeof stubSessionCleanup + > & { + activeOwnedTurnCount: number; + pendingPermissions: Map< + string, + { + resolve: (response: { + outcome: { outcome: "selected"; optionId: string }; + }) => void; + } + >; + runOwnedTurn(operation: () => Promise): Promise; + }; + const session = testServer.session as { + acpConnection: { cleanup: ReturnType }; + }; + let settlePrompt!: () => void; + const prompt = testServer.runOwnedTurn( + () => + new Promise((resolve) => { + settlePrompt = resolve; + }), + ); + testServer.pendingPermissions.set("plan-approval", { + resolve: () => queueMicrotask(settlePrompt), + }); + session.acpConnection.cleanup.mockImplementation(async () => { + expect(testServer.activeOwnedTurnCount).toBe(0); + }); + + await testServer.cleanupSession(); + await prompt; + + expect(session.acpConnection.cleanup).toHaveBeenCalledOnce(); + expect(testServer.pendingPermissions).toHaveLength(0); + }); + it("stops event ingest for terminal session cleanup without fake task completion", async () => { const testServer = stubSessionCleanup(createServer()); diff --git a/packages/agent/src/server/agent-server.ts b/packages/agent/src/server/agent-server.ts index 224f42fa16..fffc46a1c1 100644 --- a/packages/agent/src/server/agent-server.ts +++ b/packages/agent/src/server/agent-server.ts @@ -403,6 +403,8 @@ export class AgentServer { private pendingCompactContinuationMessageIds = new Set(); private inFlightMessageDeliveries = new Map>(); private activeOwnedTurnCount = 0; + private ownedTurnsSettled: Promise | null = null; + private resolveOwnedTurnsSettled: (() => void) | null = null; private pendingPermissions = new Map< string, { @@ -1918,11 +1920,38 @@ export class AgentServer { } private async runOwnedTurn(operation: () => Promise): Promise { + if (this.activeOwnedTurnCount === 0) { + this.ownedTurnsSettled = new Promise((resolve) => { + this.resolveOwnedTurnsSettled = resolve; + }); + } this.activeOwnedTurnCount += 1; try { return await operation(); } finally { this.activeOwnedTurnCount -= 1; + if (this.activeOwnedTurnCount === 0) { + this.resolveOwnedTurnsSettled?.(); + this.resolveOwnedTurnsSettled = null; + this.ownedTurnsSettled = null; + } + } + } + + private async waitForOwnedTurnsToSettle(): Promise { + if (this.activeOwnedTurnCount === 0 || !this.ownedTurnsSettled) return; + + let timeout: ReturnType | undefined; + const settled = await Promise.race([ + this.ownedTurnsSettled.then(() => true), + new Promise((resolve) => { + timeout = setTimeout(() => resolve(false), 1_000); + }), + ]).finally(() => clearTimeout(timeout)); + if (!settled) { + this.logger.warn("Timed out waiting for active turns during shutdown", { + activeOwnedTurnCount: this.activeOwnedTurnCount, + }); } } @@ -4496,6 +4525,12 @@ ${signedCommitInstructions}${prLinkInstructions}${shellEfficiencyInstructions} } this.pendingPermissions.clear(); + // A plan handoff keeps the original prompt RPC open while it waits for the + // approval above. Give that prompt a chance to return before closing ACP; + // otherwise the SDK rejects it with "ACP connection closed" and the + // intentional inactivity shutdown is persisted as a user-visible error. + await this.waitForOwnedTurnsToSettle(); + try { await this.session.acpConnection.cleanup(); } catch (error) { diff --git a/packages/ui/src/features/sessions/components/session-update/PlanApprovalView.test.tsx b/packages/ui/src/features/sessions/components/session-update/PlanApprovalView.test.tsx index ef705ba77c..6231ac7855 100644 --- a/packages/ui/src/features/sessions/components/session-update/PlanApprovalView.test.tsx +++ b/packages/ui/src/features/sessions/components/session-update/PlanApprovalView.test.tsx @@ -110,6 +110,25 @@ describe("PlanApprovalView", () => { ).toBeInTheDocument(); }); + it("opens a historical plan when replay marks it as the latest item", () => { + renderView({ + toolCall: makeToolCall({ + status: "completed", + rawInput: { + plan: PLAN_MARKER, + historical: true, + initiallyExpanded: true, + }, + }), + }); + + expect(screen.getByText(PLAN_MARKER)).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /hide plan/i })).toHaveAttribute( + "aria-expanded", + "true", + ); + }); + it("uses updated content instead of stale raw input while streaming", () => { renderView({ toolCall: makeToolCall({ diff --git a/packages/ui/src/features/sessions/components/session-update/PlanApprovalView.tsx b/packages/ui/src/features/sessions/components/session-update/PlanApprovalView.tsx index b70c281953..0fc8681c41 100644 --- a/packages/ui/src/features/sessions/components/session-update/PlanApprovalView.tsx +++ b/packages/ui/src/features/sessions/components/session-update/PlanApprovalView.tsx @@ -21,17 +21,18 @@ export function PlanApprovalView({ turnCancelled, turnComplete, ); - const [isPlanExpanded, setIsPlanExpanded] = useState(false); + const rawInput = toolCall.rawInput as + | { historical?: boolean; initiallyExpanded?: boolean; plan?: string } + | undefined; + const isHistoricalPlan = rawInput?.historical === true; + const [isPlanExpanded, setIsPlanExpanded] = useState( + isHistoricalPlan && rawInput?.initiallyExpanded === true, + ); const taskId = useSessionTaskId(); const modelOption = useModelConfigOptionForTask(taskId ?? undefined); const hasModelSelector = modelOption?.type === "select" && flattenSelectOptions(modelOption.options).length > 0; - const rawInput = toolCall.rawInput as - | { historical?: boolean; plan?: string } - | undefined; - const isHistoricalPlan = rawInput?.historical === true; - const planText = useMemo(() => { if (content?.length) { const textContent = content.find((c) => c.type === "content");