From 56c051503b68ae8ad7a5b8baf331f8700b469f24 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Sat, 31 Oct 2026 18:56:22 +0000 Subject: [PATCH] fix(server): keep promptless Codex agents usable after reopening (#8393) * fix(server): report a Codex thread only once Codex can resume it Codex writes a new thread's rollout at its first turn, but the adapter starts a thread when the agent is created and reported its ID at once. AgentManager stored that ID as the persistence handle, so a Codex agent that was reopened before its first prompt (idle runtime release, daemon restart, plugin tool reload) failed every prompt with "no rollout found". The adapter now reports a thread's ID, as runtime info and as a handle, only once it is resumable: after Codex announces the thread's first turn, or when the thread was resumed or forked. Until then a reopened agent starts a new session, as an unprompted Claude agent does. Document that a persistence handle must be resumable. * test(server): reopen promptless Codex agents without model requests Exercise the real app-server before any first turn, so publishing an unsaved persistence handle fails at the actual resume operation. Co-authored-by: Jeong-Uk,Kim --------- Co-authored-by: Jeong-Uk,Kim Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- docs/providers.md | 2 + .../src/server/agent/agent-sdk-types.ts | 1 + .../codex-app-server-agent.local.e2e.test.ts | 92 +++++++++++++++++++ .../providers/codex-app-server-agent.test.ts | 63 ++++++++++++- .../agent/providers/codex-app-server-agent.ts | 35 ++++++- 5 files changed, 187 insertions(+), 6 deletions(-) diff --git a/docs/providers.md b/docs/providers.md index b92e171c12..60fb75a785 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -507,6 +507,8 @@ interface AgentSession { } ``` +`describePersistence()` returns a handle only once a new provider process could resume it. Reload, idle runtime collection and daemon restart resume whatever handle the agent stored; a stored agent without one starts a new session instead. When there is no handle, `AgentManager` stores `getRuntimeInfo().sessionId` as one, so that ID follows the same rule. Codex, for example, starts a thread when the agent is created but writes its rollout only at its first turn, and `thread/resume` fails for it until then, so its adapter reports neither ID before Codex announces that turn. + `setMode` and `setThinkingOption` may return an `AgentProviderNotice` when the provider knows the change needs user-facing context. For example, providers that stage changes until the next turn should return an `info` notice while a turn is already running. The app renders the notice generically as a toast; provider-specific lifecycle behavior stays in the provider implementation. ### Steps diff --git a/packages/server/src/server/agent/agent-sdk-types.ts b/packages/server/src/server/agent/agent-sdk-types.ts index c31d1afb86..903bbe5c99 100644 --- a/packages/server/src/server/agent/agent-sdk-types.ts +++ b/packages/server/src/server/agent/agent-sdk-types.ts @@ -697,6 +697,7 @@ export interface AgentSession { requestId: string, response: AgentPermissionResponse, ): Promise; + /** Null until a new provider process could resume this session; see docs/providers.md. */ describePersistence(): AgentPersistenceHandle | null; /** * Resolve once every foreground turn that predates this call can no longer run or become active. diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.local.e2e.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.local.e2e.test.ts index 0469d9fbe1..dc7f920993 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.local.e2e.test.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.local.e2e.test.ts @@ -264,6 +264,98 @@ describe("Codex app-server provider (local e2e)", () => { 30_000, ); + test.runIf(isCodexInstalled())( + "reopens a promptless agent repeatedly before its first model request", + async () => { + const cwd = mkdtempSync(path.join(os.tmpdir(), "codex-promptless-cwd-")); + const logger = createTestLogger(); + const storage = new AgentStorage(path.join(cwd, "agents"), logger); + const manager = new AgentManager({ + clients: { codex: new CodexAppServerAgentClient(logger) }, + registry: storage, + logger, + }); + let agentId: string | undefined; + try { + const created = await manager.createAgent( + { provider: "codex", cwd, modeId: "auto", model: "mock-model" }, + undefined, + { workspaceId: undefined }, + ); + agentId = created.id; + // A real app-server rejects an unsaved thread on resume. No model request is + // needed to prove that reopening the agent remains safe before its first turn. + for (let i = 0; i < 2; i++) { + const reopened = await manager.reloadAgentSession(agentId); + expect(reopened.lifecycle).toBe("idle"); + expect(reopened.persistence).toBeNull(); + } + await storage.flush(); + expect((await storage.get(agentId))?.persistence).toBeNull(); + } finally { + if (agentId && manager.getAgent(agentId)) await manager.closeAgent(agentId); + await storage.flush(); + rmSync(cwd, { recursive: true, force: true }); + } + }, + 30_000, + ); + + test.runIf(isCodexInstalled())( + "reopens an agent that was never prompted without resuming its unsaved thread", + async () => { + const cwd = mkdtempSync(path.join(os.tmpdir(), "codex-unprompted-cwd-")); + const codexHome = mkdtempSync(path.join(os.tmpdir(), "codex-unprompted-home-")); + const mockServer = await startMockResponsesServer([ + assistantMessageSse("first"), + assistantMessageSse("second"), + ]); + const logger = createTestLogger(); + const storage = new AgentStorage(path.join(cwd, "agents"), logger); + const manager = new AgentManager({ + clients: { codex: new CodexAppServerAgentClient(logger) }, + registry: storage, + logger, + }); + let agentId: string | undefined; + try { + writeMockCodexConfig(codexHome, mockServer.url); + vi.stubEnv("CODEX_HOME", codexHome); + const created = await manager.createAgent( + { provider: "codex", cwd, modeId: "auto", model: "mock-model" }, + undefined, + { workspaceId: undefined }, + ); + agentId = created.id; + // Codex writes a thread's rollout at its first turn, so there is nothing to resume yet. + expect(manager.getAgent(agentId)?.persistence).toBeNull(); + await storage.flush(); + expect((await storage.get(agentId))?.persistence).toBeNull(); + + await manager.reloadAgentSession(agentId); + await manager.runAgent(agentId, "first prompt after reopening"); + const threadId = manager.getAgent(agentId)?.persistence?.sessionId; + expect(threadId).toEqual(expect.any(String)); + await storage.flush(); + expect((await storage.get(agentId))?.persistence?.sessionId).toBe(threadId); + + const reloaded = await manager.reloadAgentSession(agentId); + expect(reloaded.persistence?.sessionId).toBe(threadId); + await manager.runAgent(agentId, "continue the saved thread"); + expect(manager.getAgent(agentId)?.lifecycle).toBe("idle"); + expect(mockServer.requestBodies.at(-1)).toContain("first prompt after reopening"); + } finally { + if (agentId && manager.getAgent(agentId)) await manager.closeAgent(agentId); + await storage.flush(); + vi.unstubAllEnvs(); + await mockServer.close(); + rmSync(cwd, { recursive: true, force: true }); + rmSync(codexHome, { recursive: true, force: true }); + } + }, + 30_000, + ); + test.runIf(isCodexInstalled())( "surfaces request_user_input from the app-server as question permissions and timeline tool calls", async () => { diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts index 62e6bb0404..9986357cf4 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts @@ -90,6 +90,7 @@ import { createFakeCodexAppServer, fakeCodexThreadHistory, type FakeCodexAppServer, + waitForNextEvent, waitForNextPermission, waitForNextTimelineItem, waitForProviderSubagent, @@ -1287,6 +1288,9 @@ describe("Codex app-server provider", () => { ); await firstSession.startTurn("first turn"); await expect(beforeRestart.waitForTurnStart()).resolves.toMatchObject({ effort: "medium" }); + const saved = waitForNextEvent(firstSession, "thread_started"); + beforeRestart.startsTurn({ threadId: "thread-1" }); + await saved; const handle = firstSession.describePersistence()!; await firstSession.close(); @@ -1319,6 +1323,9 @@ describe("Codex app-server provider", () => { try { await session.startTurn("persist the configured session"); await appServer.waitForTurnStart(); + const saved = waitForNextEvent(session, "thread_started"); + appServer.startsTurn({ threadId: "thread-1" }); + await saved; const handle = session.describePersistence()!; expect(handle.metadata).not.toHaveProperty("providerOptions"); appServer.assertNoErrors(); @@ -1327,6 +1334,55 @@ describe("Codex app-server provider", () => { } }); + test("reports a new thread only after Codex announces its first turn", async () => { + const appServer = createFakeCodexAppServer(); + const session = await createProviderWithFakeAppServer(appServer).createSession(createConfig()); + try { + // Codex has started the thread but writes its rollout at the first turn, so another + // app-server could not resume it yet. + await expect(session.getRuntimeInfo()).resolves.toMatchObject({ sessionId: null }); + expect(session.describePersistence()).toBeNull(); + expect( + appServer.requests().filter((request) => request.method === "thread/start"), + ).toHaveLength(1); + + await session.startTurn("first prompt"); + await appServer.waitForTurnStart(); + expect(session.describePersistence()).toBeNull(); + + const saved = waitForNextEvent(session, "thread_started"); + appServer.startsTurn({ threadId: "thread-1" }); + await expect(saved).resolves.toMatchObject({ + type: "thread_started", + provider: "codex", + sessionId: "thread-1", + }); + expect(session.describePersistence()).toMatchObject({ + sessionId: "thread-1", + nativeHandle: "thread-1", + }); + await expect(session.getRuntimeInfo()).resolves.toMatchObject({ sessionId: "thread-1" }); + appServer.assertNoErrors(); + } finally { + await session.close(); + } + }); + + test("reports a resumed thread before its next turn", async () => { + const appServer = createFakeCodexAppServer(); + const session = await createProviderWithFakeAppServer(appServer).resumeSession( + { provider: "codex", sessionId: "saved-thread" }, + createConfig(), + ); + try { + expect(session.describePersistence()).toMatchObject({ sessionId: "saved-thread" }); + await expect(session.getRuntimeInfo()).resolves.toMatchObject({ sessionId: "saved-thread" }); + appServer.assertNoErrors(); + } finally { + await session.close(); + } + }); + test("preapproves only granted tools on the injected Codex MCP server", async () => { const session = createSession({ modeId: undefined, @@ -2604,7 +2660,12 @@ describe("Codex app-server provider", () => { clientMessageId: "client-message", }, }); - expect(events.slice(0, 2).map((event) => event.type)).toEqual(["turn_started", "timeline"]); + // The first turn of a new thread also reports the thread, now that Codex has saved it. + expect(events.slice(0, 3).map((event) => event.type)).toEqual([ + "thread_started", + "turn_started", + "timeline", + ]); appServer.completeTurn(); await new Promise((resolve) => setImmediate(resolve)); await new Promise((resolve) => setImmediate(resolve)); diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.ts index ea4e6378d9..2aecd18e21 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.ts @@ -3435,6 +3435,9 @@ export class CodexAppServerAgentSession implements AgentSession { > | null = null; private resolvedSandboxPolicy: Record | null = null; private currentThreadId: string | null = null; + // Codex writes a new thread's rollout at its first turn. Until then the thread exists only + // in this app-server, so no other app-server can resume it and it is not reported. + private savedThreadId: string | null = null; private currentTurnId: string | null = null; private pendingForegroundTurnIdentification: { foregroundTurnId: string; @@ -3572,6 +3575,7 @@ export class CodexAppServerAgentSession implements AgentSession { if (this.resumeHandle?.sessionId) { this.currentThreadId = this.resumeHandle.sessionId; + this.savedThreadId = this.resumeHandle.sessionId; this.historyIndexed = false; // Resuming can start a goal before the caller receives the session. if (this.initialResumePurpose === "interactive") this.startupEvents = []; @@ -4605,7 +4609,7 @@ export class CodexAppServerAgentSession implements AgentSession { } const info: AgentRuntimeInfo = { provider: CODEX_PROVIDER, - sessionId: this.currentThreadId, + sessionId: this.savedCurrentThreadId(), model: this.config.model ?? null, thinkingOptionId: normalizeCodexThinkingOptionId(this.config.thinkingOptionId) ?? null, modeId: this.currentMode ?? null, @@ -4936,17 +4940,18 @@ export class CodexAppServerAgentSession implements AgentSession { nativeHandle: string; metadata: Record; } | null { - if (!this.currentThreadId) return null; + const threadId = this.savedCurrentThreadId(); + if (!threadId) return null; const thinkingOptionId = normalizeCodexThinkingOptionId(this.config.thinkingOptionId) ?? null; return { provider: CODEX_PROVIDER, - sessionId: this.currentThreadId, - nativeHandle: this.currentThreadId, + sessionId: threadId, + nativeHandle: threadId, metadata: { provider: CODEX_PROVIDER, cwd: this.config.cwd, title: this.config.title ?? null, - threadId: this.currentThreadId, + threadId, modeId: this.config.modeId, model: this.config.model ?? null, thinkingOptionId, @@ -4958,6 +4963,12 @@ export class CodexAppServerAgentSession implements AgentSession { }; } + private savedCurrentThreadId(): string | null { + return this.currentThreadId !== null && this.currentThreadId === this.savedThreadId + ? this.currentThreadId + : null; + } + async revertConversation(input: { messageId: string }): Promise { await this.connect(); if (!this.client) { @@ -4982,6 +4993,8 @@ export class CodexAppServerAgentSession implements AgentSession { threadRollbackAvailable: this.threadRollbackAvailable, setThreadId: async (threadId) => { this.currentThreadId = threadId; + // Codex writes a forked thread's rollout when it forks it, even before any turn. + this.savedThreadId = threadId; this.cachedRuntimeInfo = null; this.stagedHistory = await this.readThreadHistory(this.client); this.reconcileAsyncQuestionsAfterRewind(this.stagedHistory.timeline); @@ -6155,6 +6168,7 @@ export class CodexAppServerAgentSession implements AgentSession { this.emitSubAgentActivityUpdate(subAgentCallId, "running", { reopen: true }); return; } + this.markCurrentThreadSaved(parsed.threadId); this.currentTurnId = parsed.turnId; const pendingIdentification = this.pendingForegroundTurnIdentification; if ( @@ -6168,6 +6182,17 @@ export class CodexAppServerAgentSession implements AgentSession { this.emitEvent({ type: "turn_started", provider: CODEX_PROVIDER }); } + // Codex has written the rollout by the time it announces a thread's turn, including a + // goal's or a compaction's. From here on the thread can be reported and resumed. + private markCurrentThreadSaved(threadId: string | null): void { + const current = this.currentThreadId; + if (!current || (threadId !== null && threadId !== current)) return; + if (this.savedThreadId === current) return; + this.savedThreadId = current; + this.cachedRuntimeInfo = null; + this.emitEvent({ type: "thread_started", provider: CODEX_PROVIDER, sessionId: current }); + } + private handleTurnCompletedNotification( parsed: Extract, ): void {