mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-31 21:52:54 +00:00
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:
co-authored by
Jeong-Uk,Kim
parent
85354fcb6a
commit
56c051503b
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user