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 <dnrtoki@gmail.com>

---------

Co-authored-by: Jeong-Uk,Kim <dnrtoki@gmail.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
This commit is contained in:
paseo-bot[bot]
2026-10-31 18:56:22 +00:00
committed by GitHub
co-authored by Jeong-Uk,Kim
parent 85354fcb6a
commit 56c051503b
5 changed files with 187 additions and 6 deletions
+2
View File
@@ -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
@@ -697,6 +697,7 @@ export interface AgentSession {
requestId: string,
response: AgentPermissionResponse,
): Promise<AgentPermissionResult | void>;
/** 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.
@@ -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 () => {
@@ -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<void>((resolve) => setImmediate(resolve));
await new Promise<void>((resolve) => setImmediate(resolve));
@@ -3435,6 +3435,9 @@ export class CodexAppServerAgentSession implements AgentSession {
> | null = null;
private resolvedSandboxPolicy: Record<string, unknown> | 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<string, unknown>;
} | 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<void> {
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<ParsedCodexNotification, { kind: "turn_completed" }>,
): void {