From ec4e81ce0fa2a00ed4ae8272c709ca3d64aa7dd7 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 9 Sep 2026 20:32:21 +0200 Subject: [PATCH] Show Codex questions while the agent keeps working (#4587) * fix(codex): show asynchronous questions and preserve their lifecycle * fix(server): reject concurrent replies to the same question * test(codex): use native temporary paths in question fixtures --- .../app/src/components/question-form-card.tsx | 17 +- .../server/src/server/agent/agent-manager.ts | 22 +- .../agent/providers/codex-app-server-agent.ts | 100 +++- .../providers/codex-async-questions.test.ts | 436 ++++++++++++++++++ .../agent/providers/codex/async-questions.ts | 155 +++++++ .../codex/test-utils/fake-app-server.ts | 2 +- 6 files changed, 710 insertions(+), 22 deletions(-) create mode 100644 packages/server/src/server/agent/providers/codex-async-questions.test.ts create mode 100644 packages/server/src/server/agent/providers/codex/async-questions.ts diff --git a/packages/app/src/components/question-form-card.tsx b/packages/app/src/components/question-form-card.tsx index d612bca0c9..dc5c66a529 100644 --- a/packages/app/src/components/question-form-card.tsx +++ b/packages/app/src/components/question-form-card.tsx @@ -80,8 +80,11 @@ function QuestionOptionRow({ ); const optionLabelStyle = useMemo( - () => [styles.optionLabel, { color: theme.colors.foreground }], - [theme.colors.foreground], + () => [ + styles.optionLabel, + { color: isSelected ? theme.colors.foreground : theme.colors.foregroundMuted }, + ], + [isSelected, theme.colors.foreground, theme.colors.foregroundMuted], ); const optionDescriptionStyle = useMemo( () => [styles.optionDescription, { color: theme.colors.foregroundMuted }], @@ -96,11 +99,11 @@ function QuestionOptionRow({ styles.selectionControl, multiSelect ? styles.selectionControlCheckbox : styles.selectionControlRadio, { - borderColor: isSelected ? theme.colors.accent : theme.colors.foregroundMuted, + borderColor: isSelected ? theme.colors.accent : theme.colors.foregroundExtraMuted, backgroundColor: isSelected && multiSelect ? theme.colors.accent : "transparent", }, ], - [isSelected, multiSelect, theme.colors.accent, theme.colors.foregroundMuted], + [isSelected, multiSelect, theme.colors.accent, theme.colors.foregroundExtraMuted], ); const radioDotStyle = useMemo( () => [styles.selectionRadioDot, { backgroundColor: theme.colors.accent }], @@ -628,7 +631,7 @@ const styles = StyleSheet.create((theme) => ({ questionText: { flex: 1, fontSize: theme.fontSize.base, - fontWeight: theme.fontWeight.medium, + fontWeight: theme.fontWeight.normal, lineHeight: 22, }, optionsWrap: { @@ -653,7 +656,7 @@ const styles = StyleSheet.create((theme) => ({ }, questionNavText: { fontSize: theme.fontSize.base, - fontWeight: theme.fontWeight.medium, + fontWeight: theme.fontWeight.normal, }, optionItem: { flexDirection: "row", @@ -677,7 +680,7 @@ const styles = StyleSheet.create((theme) => ({ }, optionLabel: { fontSize: theme.fontSize.base, - fontWeight: theme.fontWeight.semibold, + fontWeight: theme.fontWeight.normal, lineHeight: 22, }, optionDescription: { diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index d5dc1ab52b..5fd1c85847 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -2885,6 +2885,9 @@ export class AgentManager { response: AgentPermissionResponse, ): Promise { const agent = this.requireAgent(agentId); + if (agent.inFlightPermissionResponses.has(requestId)) { + throw new Error("A response to this permission request is already being submitted"); + } agent.inFlightPermissionResponses.add(requestId); try { @@ -3079,8 +3082,11 @@ export class AgentManager { epoch: this.timelineStore.getEpoch(agentId), }); } - await this.refreshRuntimeInfo(agent); + // Rewind stages provider events under the run lock; publish its final state directly. + this.refreshSessionPersistence(agent); + await this.refreshSessionState(agent, { emit: false }); await this.persistSnapshot(agent); + this.emitState(agent, { persist: false }); this.logger.info( { agentId, provider: agent.provider, messageId, mode }, "agent.rewind.complete", @@ -4231,14 +4237,18 @@ export class AgentManager { private onStreamThreadStarted(agent: ActiveManagedAgent): void { const previousSessionId = agent.persistence?.sessionId ?? null; + this.refreshSessionPersistence(agent); + if (agent.persistence?.sessionId !== previousSessionId) { + this.emitState(agent); + } + void this.refreshRuntimeInfo(agent); + } + + private refreshSessionPersistence(agent: ActiveManagedAgent): void { const handle = agent.session.describePersistence(); if (handle) { agent.persistence = attachPersistenceCwd(handle, agent.cwd); - if (agent.persistence?.sessionId !== previousSessionId) { - this.emitState(agent); - } } - void this.refreshRuntimeInfo(agent); } private async onStreamTimelineEvent(params: { @@ -4443,6 +4453,7 @@ export class AgentManager { ): void { const hadPendingPermissions = agent.pendingPermissions.size > 0; agent.pendingPermissions.set(event.request.id, event.request); + this.refreshSessionPersistence(agent); if (!hadPendingPermissions && !agent.internal) { this.broadcastAgentAttention(agent, "permission"); } @@ -4457,6 +4468,7 @@ export class AgentManager { }): void { const { agent, event, options, flags } = params; agent.pendingPermissions.delete(event.requestId); + this.refreshSessionPersistence(agent); if (!options?.fromHistory && agent.inFlightPermissionResponses.has(event.requestId)) { agent.bufferedPermissionResolutions.set(event.requestId, event); flags.shouldDispatchEvent = false; 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 adff09dd9c..7123db17cc 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 @@ -52,6 +52,7 @@ import { z } from "zod"; import { renderPromptAttachmentAsText } from "../prompt-attachments.js"; import { composeSystemPromptParts } from "../system-prompt.js"; import { curateAgentActivity } from "../activity-curator.js"; +import { CodexAsyncQuestions, codexAsyncQuestionToTimeline } from "./codex/async-questions.js"; import { mapCodexToolCallEnvelope, mapCodexToolCallFromThreadItem, @@ -1845,6 +1846,17 @@ function mapCodexThreadImageItem( ); } +function mapCodexAgentMessage(item: Record): AgentTimelineItem { + const question = codexAsyncQuestionToTimeline(item); + if (question) return question; + const messageId = nonEmptyString(item.id); + return { + type: "assistant_message", + text: typeof item.text === "string" ? item.text : "", + ...(messageId ? { messageId } : {}), + }; +} + export function threadItemToTimeline( item: unknown, options?: { includeUserMessage?: boolean; cwd?: string | null }, @@ -1871,14 +1883,8 @@ export function threadItemToTimeline( switch (normalizedType) { case "userMessage": return mapCodexThreadUserMessageItem(normalizedItem, includeUserMessage); - case "agentMessage": { - const messageId = nonEmptyString(normalizedItem.id); - return { - type: "assistant_message", - text: typeof normalizedItem.text === "string" ? normalizedItem.text : "", - ...(messageId ? { messageId } : {}), - }; - } + case "agentMessage": + return mapCodexAgentMessage(normalizedItem); case "plan": return mapCodexThreadPlanItem(normalizedItem); case "reasoning": @@ -3293,6 +3299,7 @@ export class CodexAppServerAgentSession implements AgentSession { private readonly logger: Logger; private readonly config: AgentSessionConfig; + private readonly asyncQuestions: CodexAsyncQuestions; private currentMode: string; private hasWorkflowModeOverride: boolean; private readonly providerOptions: CodexProviderOptions; @@ -3405,6 +3412,7 @@ export class CodexAppServerAgentSession implements AgentSession { this.currentMode = config.modeId ?? DEFAULT_CODEX_MODE_ID; this.providerOptions = CodexProviderOptionsSchema.parse(config.providerOptions ?? {}); this.config = config; + this.asyncQuestions = new CodexAsyncQuestions(resumeHandle?.metadata?.asyncQuestions); this.config.thinkingOptionId = normalizeCodexThinkingOptionId(this.config.thinkingOptionId); if (this.config.featureValues?.fast_mode && codexModelSupportsFastMode(this.config.model)) { this.serviceTier = "fast"; @@ -3799,6 +3807,9 @@ export class CodexAppServerAgentSession implements AgentSession { } this.resetCodexUserMessageTurns(); for (const entry of timeline) { + if (entry.item.type === "tool_call" && entry.item.name === "request_user_input_async") { + entry.item = this.asyncQuestions.timeline(entry.item.callId) ?? entry.item; + } if (entry.item.type === "user_message") { this.rememberCodexUserMessageTurn(entry.item.messageId, entry.providerTurnId); } @@ -4464,13 +4475,16 @@ export class CodexAppServerAgentSession implements AgentSession { } getPendingPermissions(): AgentPermissionRequest[] { - return Array.from(this.pendingPermissions.values()); + return [...this.pendingPermissions.values(), ...this.asyncQuestions.pending()]; } async respondToPermission( requestId: string, response: AgentPermissionResponse, ): Promise { + if (this.asyncQuestions.hasPending(requestId)) { + return this.respondToAsyncQuestion(requestId, response); + } const pending = this.pendingPermissionHandlers.get(requestId); if (!pending) { throw new Error(`No pending Codex app-server permission request with id '${requestId}'`); @@ -4564,6 +4578,33 @@ export class CodexAppServerAgentSession implements AgentSession { pending.resolve({ answers: {} }); } + private async respondToAsyncQuestion( + requestId: string, + response: AgentPermissionResponse, + ): Promise { + const prepared = this.asyncQuestions.prepareResponse(requestId, response); + let followUpPrompt = prepared.prompt; + const expectedTurnId = this.activeForegroundTurnId; + if (prepared.prompt && expectedTurnId) { + const result = await this.steerActiveTurn(prepared.prompt, { + expectedTurnId, + clientMessageId: randomUUID(), + }); + if (result.status !== "accepted") { + throw new Error("The active Codex turn changed. Retry sending your answer."); + } + followUpPrompt = undefined; + } + this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: prepared.complete() }); + this.emitEvent({ + type: "permission_resolved", + provider: CODEX_PROVIDER, + requestId, + resolution: response, + }); + return followUpPrompt ? { followUpPrompt } : undefined; + } + private handlePlanPermissionResponse(params: { requestId: string; response: AgentPermissionResponse; @@ -4700,6 +4741,7 @@ export class CodexAppServerAgentSession implements AgentSession { toolPolicy: this.config.toolPolicy, systemPrompt: this.config.systemPrompt, mcpServers: this.config.mcpServers, + asyncQuestions: this.asyncQuestions.serialize(), }, }; } @@ -4729,6 +4771,7 @@ export class CodexAppServerAgentSession implements AgentSession { this.persistedHistory = []; this.historyPending = false; await this.loadPersistedHistory(); + this.reconcileAsyncQuestionsAfterRewind(); }, }); } @@ -5903,6 +5946,7 @@ export class CodexAppServerAgentSession implements AgentSession { error: parsed.errorMessage ?? "Codex turn failed", }); } else if (parsed.status === "interrupted") { + this.dismissInterruptedAsyncQuestions(); this.emitEvent({ type: "turn_canceled", provider: CODEX_PROVIDER, reason: "interrupted" }); } else { if (this.planModeEnabled && this.latestPlanResult?.text) { @@ -6255,6 +6299,43 @@ export class CodexAppServerAgentSession implements AgentSession { } } + private receiveAsyncQuestion(threadId: string | null, item: unknown): void { + if (threadId !== this.currentThreadId) return; + const request = this.asyncQuestions.receive(item); + if (request) + this.emitEvent({ type: "permission_requested", provider: CODEX_PROVIDER, request }); + } + + private dismissInterruptedAsyncQuestions(): void { + const resolution: AgentPermissionResponse = { behavior: "deny", message: "Interrupted" }; + for (const request of this.asyncQuestions.pending()) { + const prepared = this.asyncQuestions.prepareResponse(request.id, resolution); + this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: prepared.complete() }); + this.emitEvent({ + type: "permission_resolved", + provider: CODEX_PROVIDER, + requestId: request.id, + resolution, + }); + } + } + + private reconcileAsyncQuestionsAfterRewind(): void { + const retainedIds = new Set( + this.persistedHistory.flatMap(({ item }) => + item.type === "tool_call" && item.name === "request_user_input_async" ? [item.callId] : [], + ), + ); + for (const requestId of this.asyncQuestions.retain(retainedIds)) { + this.emitEvent({ + type: "permission_resolved", + provider: CODEX_PROVIDER, + requestId, + resolution: { behavior: "deny", message: "Removed by rewind" }, + }); + } + } + private handleItemCompletedNotification( parsed: Extract, ): void { @@ -6265,6 +6346,7 @@ export class CodexAppServerAgentSession implements AgentSession { if (shouldIgnoreMirroredLifecycleItem(parsed.source, parsed.item)) { return; } + this.receiveAsyncQuestion(parsed.threadId, parsed.item); if (this.isUserMessageItem(parsed.item)) { this.handleUserMessageItem(parsed); return; diff --git a/packages/server/src/server/agent/providers/codex-async-questions.test.ts b/packages/server/src/server/agent/providers/codex-async-questions.test.ts new file mode 100644 index 0000000000..8915688bde --- /dev/null +++ b/packages/server/src/server/agent/providers/codex-async-questions.test.ts @@ -0,0 +1,436 @@ +import { tmpdir } from "node:os"; +import { expect, test } from "vitest"; +import { CodexAppServerAgentSession } from "./codex-app-server-agent.js"; +import { + createFakeCodexAppServer, + waitForNextEvent, + waitForTimelineToolCall, +} from "./codex/test-utils/fake-app-server.js"; +import { createTestLogger } from "../../../test-utils/test-logger.js"; +import type { AgentStreamEvent } from "../agent-sdk-types.js"; +import { AgentManager } from "../agent-manager.js"; + +const questionItem = { + type: "agentMessage", + id: "async-question-1", + text: "Which color?\n- Blue\n- Green", + phase: "final_answer", + delivery: "async", + questions: [{ title: "Which color?", options: ["Blue", "Green"] }], +}; + +async function setup(metadata?: Record, rejectSteer = false) { + const appServer = createFakeCodexAppServer({ + "turn/interrupt": () => ({}), + "turn/steer": () => { + if (rejectSteer) return { __jsonRpcError: { code: -32000, message: "Delivery failed" } }; + return { turnId: "native-turn" }; + }, + "thread/read": () => ({ thread: { id: "thread-1", turns: [] } }), + }); + const session = new CodexAppServerAgentSession( + { provider: "codex", cwd: tmpdir(), model: "gpt-5.4", modeId: "full-access" }, + metadata ? { sessionId: "thread-1", metadata } : null, + createTestLogger(), + async () => appServer.child, + ); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + await session.startTurn("Help me choose a color while you inspect the project."); + const started = waitForNextEvent(session, "turn_started"); + appServer.startsTurn({ threadId: "thread-1", turnId: "native-turn" }); + await started; + async function ask() { + const shown = waitForTimelineToolCall(session, questionItem.id); + appServer.child.stdout.write( + JSON.stringify({ + method: "item/completed", + params: { threadId: "thread-1", turnId: "native-turn", item: questionItem }, + }) + "\n", + ); + await shown; + } + async function say(text: string) { + const shown = waitForNextEvent( + session, + "timeline", + (event) => event.item.type === "assistant_message" && event.item.text.includes(text), + ); + appServer.says({ threadId: "thread-1", text }); + await shown; + } + async function finish(status: "completed" | "interrupted" = "completed") { + const finished = waitForNextEvent( + session, + status === "completed" ? "turn_completed" : "turn_canceled", + ); + appServer.completeTurn({ status }); + await finished; + } + function allowAnswers() { + rejectSteer = false; + } + return { session, appServer, events, ask, say, finish, allowAnswers }; +} + +const answer = { behavior: "allow" as const, updatedInput: { answers: { "Question 1": "Green" } } }; + +async function setupRewind(fail = false) { + const records = [ + { item: { ...questionItem, id: "earlier-pending" } }, + { item: { ...questionItem, id: "earlier-answered" }, resolution: ["Blue"] }, + { item: { ...questionItem, id: "removed-pending" } }, + { item: { ...questionItem, id: "removed-answered" }, resolution: ["Green"] }, + ]; + function userMessage(id: string) { + return { type: "userMessage", id, content: [{ type: "text", text: id }] }; + } + const turns = [ + { + id: "earlier-turn", + items: [userMessage("earlier"), ...records.slice(0, 2).map((r) => r.item)], + }, + { + id: "removed-turn", + items: [userMessage("rewind-here"), ...records.slice(2).map((r) => r.item)], + }, + ]; + const appServer = createFakeCodexAppServer({ + "thread/read": (params) => { + const { threadId } = params as { threadId: string }; + return { + thread: { id: threadId, turns: threadId === "thread-1" ? turns : turns.slice(0, 1) }, + }; + }, + ...(fail + ? { + "thread/rollback": () => ({ __jsonRpcError: { code: -32000, message: "Rewind failed" } }), + } + : {}), + }); + const session = new CodexAppServerAgentSession( + { provider: "codex", cwd: tmpdir(), model: "gpt-5.4", modeId: "full-access" }, + { sessionId: "thread-1", metadata: { asyncQuestions: records } }, + createTestLogger(), + async () => appServer.child, + ); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + await session.connect(); + return { session, events, records }; +} + +test("rewind removes only questions outside the remaining history, including after resume", async () => { + const { session, events, records } = await setupRewind(); + let metadata: Record | undefined; + try { + await session.revertConversation({ messageId: "rewind-here" }); + expect(session.getPendingPermissions().map((p) => p.id)).toEqual([ + "permission-earlier-pending", + ]); + metadata = session.describePersistence()!.metadata; + expect(metadata.asyncQuestions).toEqual( + records.slice(0, 2).map((record) => ({ + resolution: record.resolution, + item: { + type: "agentMessage", + id: record.item.id, + delivery: "async", + questions: questionItem.questions, + }, + })), + ); + expect(events.filter((e) => e.type === "permission_resolved")).toEqual([ + expect.objectContaining({ requestId: "permission-removed-pending" }), + ]); + const history = []; + for await (const event of session.streamHistory()) history.push(event); + expect( + history.some( + (e) => + e.type === "timeline" && + e.item.type === "tool_call" && + e.item.callId === "removed-pending", + ), + ).toBe(false); + } finally { + await session.close(); + } + const resumed = await setup(metadata); + try { + expect(resumed.session.getPendingPermissions().map((p) => p.id)).toEqual([ + "permission-earlier-pending", + ]); + } finally { + await resumed.session.close(); + } +}); + +test("failed rewind preserves question state", async () => { + const { session, events } = await setupRewind(true); + try { + const before = session.describePersistence(); + await expect(session.revertConversation({ messageId: "rewind-here" })).rejects.toThrow( + "Rewind failed", + ); + expect(session.describePersistence()).toEqual(before); + expect(session.getPendingPermissions().map((p) => p.id)).toEqual([ + "permission-earlier-pending", + "permission-removed-pending", + ]); + expect(events.some((e) => e.type === "permission_resolved")).toBe(false); + } finally { + await session.close(); + } +}); + +async function manage(session: CodexAppServerAgentSession) { + const manager = new AgentManager({ + clients: { + codex: { + provider: "codex", + capabilities: session.capabilities, + createSession: async () => session, + resumeSession: async () => session, + isAvailable: async () => true, + fetchCatalog: async () => ({ models: [], modes: [] }), + }, + }, + logger: createTestLogger(), + }); + const agent = await manager.createAgent( + { provider: "codex", cwd: tmpdir(), model: "gpt-5.4" }, + undefined, + { workspaceId: undefined }, + ); + return { manager, agent }; +} + +test("manager publishes and saves the provider state after rewind", async () => { + const { session } = await setupRewind(); + const { manager, agent } = await manage(session); + try { + await manager.rewind(agent.id, "rewind-here", "conversation"); + const snapshot = manager.getAgent(agent.id)!; + expect(Array.from(snapshot.pendingPermissions.keys())).toEqual(["permission-earlier-pending"]); + expect(snapshot.persistence).toEqual(session.describePersistence()); + expect(snapshot.persistence?.sessionId).toBe("forked-thread"); + } finally { + await manager.closeAgent(agent.id); + } +}); + +test("manager snapshots capture pending and answered question state before the turn ends", async () => { + const { session, ask } = await setup(); + const { manager, agent } = await manage(session); + try { + await ask(); + const [permission] = session.getPendingPermissions(); + expect(manager.getAgent(agent.id)?.persistence?.metadata?.asyncQuestions).toEqual([ + { + item: { + type: "agentMessage", + id: questionItem.id, + delivery: "async", + questions: questionItem.questions, + }, + }, + ]); + await manager.respondToPermission(agent.id, permission.id, answer); + expect(manager.getAgent(agent.id)?.persistence?.metadata?.asyncQuestions).toEqual([ + { + item: { + type: "agentMessage", + id: questionItem.id, + delivery: "async", + questions: questionItem.questions, + }, + resolution: ["Green"], + }, + ]); + } finally { + await manager.closeAgent(agent.id); + } +}); + +test("concurrent answers deliver only one response to the active Codex turn", async () => { + const { session, appServer, ask } = await setup(); + const { manager, agent } = await manage(session); + try { + await ask(); + const [permission] = session.getPendingPermissions(); + const results = await Promise.allSettled([ + manager.respondToPermission(agent.id, permission.id, answer), + manager.respondToPermission(agent.id, permission.id, answer), + ]); + expect(results.map((result) => result.status)).toEqual(["fulfilled", "rejected"]); + expect(appServer.requests().filter((request) => request.method === "turn/steer")).toHaveLength( + 1, + ); + expect(session.getPendingPermissions()).toEqual([]); + } finally { + await manager.closeAgent(agent.id); + } +}); + +test("a failed submission releases the request so the user can retry", async () => { + const { session, ask, allowAnswers } = await setup(undefined, true); + const { manager, agent } = await manage(session); + try { + await ask(); + const [permission] = session.getPendingPermissions(); + await expect(manager.respondToPermission(agent.id, permission.id, answer)).rejects.toThrow( + "Delivery failed", + ); + expect(session.getPendingPermissions().map((request) => request.id)).toEqual([permission.id]); + allowAnswers(); + await manager.respondToPermission(agent.id, permission.id, answer); + expect(session.getPendingPermissions()).toEqual([]); + } finally { + await manager.closeAgent(agent.id); + } +}); + +test("shows an async question, keeps streaming, and delivers its answer without interrupting", async () => { + const { session, appServer, events, ask, say } = await setup(); + try { + await ask(); + const [permission] = session.getPendingPermissions(); + expect(permission).toMatchObject({ + kind: "question", + input: { + questions: [ + { + header: "Question 1", + question: "Which color?", + isOther: true, + options: [{ label: "Blue" }, { label: "Green" }], + }, + ], + }, + }); + await say("I am still inspecting the project."); + expect( + events.some( + (event) => + event.type === "timeline" && + event.item.type === "assistant_message" && + event.item.text.includes("still inspecting"), + ), + ).toBe(true); + await session.respondToPermission(permission.id, answer); + expect(session.getPendingPermissions()).toEqual([]); + expect(appServer.requests().filter((request) => request.method === "turn/steer")).toMatchObject( + [ + { + params: { + expectedTurnId: "native-turn", + clientUserMessageId: expect.any(String), + input: [{ type: "text", text: expect.stringContaining("Which color?\nGreen") }], + }, + }, + ], + ); + expect(appServer.requests().filter((request) => request.method === "turn/interrupt")).toEqual( + [], + ); + expect( + events.some( + (event) => event.type === "permission_resolved" && event.requestId === permission.id, + ), + ).toBe(true); + } finally { + await session.close(); + } +}); + +test("keeps a failed answer pending for retry", async () => { + const { session, ask } = await setup(undefined, true); + try { + await ask(); + const [permission] = session.getPendingPermissions(); + expect(permission).toBeDefined(); + await expect(session.respondToPermission(permission.id, answer)).rejects.toThrow(); + expect(session.getPendingPermissions()).toHaveLength(1); + } finally { + await session.close(); + } +}); + +test("Stop dismisses async questions before cancellation and keeps them dismissed after resume", async () => { + const first = await setup(); + let metadata: Record | undefined; + try { + await first.ask(); + expect(first.session.getPendingPermissions()).toHaveLength(1); + first.session.subscribe((event) => { + if (event.type === "turn_canceled") { + metadata = first.session.describePersistence()!.metadata; + } + }); + await first.session.interrupt(); + await first.finish("interrupted"); + expect(first.session.getPendingPermissions()).toEqual([]); + expect(metadata?.asyncQuestions).toEqual([ + expect.objectContaining({ resolution: "dismissed" }), + ]); + expect(first.events).toContainEqual( + expect.objectContaining({ + type: "permission_resolved", + requestId: "permission-async-question-1", + resolution: { behavior: "deny", message: "Interrupted" }, + }), + ); + } finally { + await first.session.close(); + } + const resumed = await setup(metadata); + try { + await resumed.ask(); + expect(resumed.session.getPendingPermissions()).toEqual([]); + } finally { + await resumed.session.close(); + } +}); + +test("late answers use the existing follow-up prompt and dismissal does not interrupt", async () => { + const { session, appServer, ask, finish } = await setup(); + try { + await ask(); + const [permission] = session.getPendingPermissions(); + expect(permission).toBeDefined(); + await finish(); + expect(session.getPendingPermissions()).toHaveLength(1); + expect(await session.respondToPermission(permission.id, answer)).toEqual({ + followUpPrompt: "Answers to your questions:\n\nWhich color?\nGreen", + }); + expect(appServer.requests().some((request) => request.method === "turn/interrupt")).toBe(false); + } finally { + await session.close(); + } +}); + +test("restores unanswered questions and does not reopen answered questions on duplicate events", async () => { + const first = await setup(); + let metadata: Record | undefined; + try { + await first.ask(); + expect(first.session.getPendingPermissions()).toHaveLength(1); + metadata = first.session.describePersistence()!.metadata; + } finally { + await first.session.close(); + } + const resumed = await setup(metadata); + try { + const [permission] = resumed.session.getPendingPermissions(); + expect(permission).toBeDefined(); + await resumed.session.respondToPermission(permission.id, { behavior: "deny" }); + await resumed.ask(); + expect(resumed.session.getPendingPermissions()).toEqual([]); + expect( + resumed.appServer.requests().some((request) => request.method === "turn/interrupt"), + ).toBe(false); + } finally { + await resumed.session.close(); + } +}); diff --git a/packages/server/src/server/agent/providers/codex/async-questions.ts b/packages/server/src/server/agent/providers/codex/async-questions.ts new file mode 100644 index 0000000000..6fdba7b67e --- /dev/null +++ b/packages/server/src/server/agent/providers/codex/async-questions.ts @@ -0,0 +1,155 @@ +import { z } from "zod"; +import type { + AgentPermissionRequest, + AgentPermissionResponse, + ToolCallTimelineItem, +} from "../../agent-sdk-types.js"; + +const QuestionSchema = z.object({ + title: z.string().trim().min(1), + options: z.array(z.string().min(1)).nullish(), +}); +const ItemSchema = z.object({ + type: z.literal("agentMessage"), + id: z.string().min(1), + delivery: z.literal("async"), + questions: z.array(QuestionSchema).min(1), +}); +const RecordSchema = z.object({ + item: ItemSchema, + resolution: z.union([z.literal("dismissed"), z.array(z.string())]).optional(), +}); +type QuestionRecord = z.infer; + +function requestId(itemId: string): string { + return `permission-${itemId}`; +} + +function toPermission(record: QuestionRecord): AgentPermissionRequest { + return { + id: requestId(record.item.id), + provider: "codex", + name: "request_user_input_async", + kind: "question", + title: "Question", + input: { + questions: record.item.questions.map((question, index) => ({ + id: String(index), + header: `Question ${index + 1}`, + question: question.title, + options: (question.options ?? []).map((label) => ({ label })), + isOther: true, + })), + }, + }; +} + +function toTimeline(record: QuestionRecord): ToolCallTimelineItem { + const answers = Array.isArray(record.resolution) ? record.resolution : undefined; + return { + type: "tool_call", + callId: record.item.id, + name: "request_user_input_async", + status: "completed", + error: null, + detail: { + type: "plain_text", + icon: "brain", + text: + record.item.questions + .map((question, index) => + [question.title, answers ? answers[index] : (question.options ?? []).join(", ")] + .filter(Boolean) + .join("\n"), + ) + .join("\n\n") + (record.resolution === "dismissed" ? "\n\nDismissed" : ""), + }, + }; +} + +export function codexAsyncQuestionToTimeline(item: unknown): ToolCallTimelineItem | null { + const parsed = ItemSchema.safeParse(item); + return parsed.success ? toTimeline({ item: parsed.data }) : null; +} + +/** Codex emits these as completed messages; the outstanding answer belongs to the session. */ +export class CodexAsyncQuestions { + private readonly records = new Map(); + + constructor(saved: unknown) { + const parsed = z.array(RecordSchema).safeParse(saved); + if (parsed.success) { + for (const record of parsed.data) this.records.set(requestId(record.item.id), record); + } + } + + receive(item: unknown): AgentPermissionRequest | null { + const parsed = ItemSchema.safeParse(item); + if (!parsed.success || this.records.has(requestId(parsed.data.id))) return null; + const record = { item: parsed.data }; + this.records.set(requestId(parsed.data.id), record); + return toPermission(record); + } + + pending(): AgentPermissionRequest[] { + return Array.from(this.records.values()) + .filter((record) => record.resolution === undefined) + .map(toPermission); + } + + hasPending(id: string): boolean { + const record = this.records.get(id); + return record !== undefined && record.resolution === undefined; + } + + prepareResponse( + id: string, + response: AgentPermissionResponse, + ): { prompt?: string; complete: () => ToolCallTimelineItem } { + const record = this.records.get(id); + if (!record || record.resolution !== undefined) + throw new Error("Question is no longer pending"); + let resolution: QuestionRecord["resolution"] = "dismissed"; + let prompt: string | undefined; + if (response.behavior === "allow") { + const answers = z.record(z.string(), z.string()).parse(response.updatedInput?.answers); + resolution = record.item.questions.map((_, index) => { + const answer = answers[`Question ${index + 1}`]?.trim(); + if (!answer) throw new Error(`Answer Question ${index + 1} before submitting`); + return answer; + }); + const values = resolution; + prompt = + "Answers to your questions:\n\n" + + record.item.questions + .map((question, index) => `${question.title}\n${values[index]}`) + .join("\n\n"); + } + return { + prompt, + complete: () => { + record.resolution = resolution; + return toTimeline(record); + }, + }; + } + + timeline(itemId: string): ToolCallTimelineItem | null { + const record = this.records.get(requestId(itemId)); + return record ? toTimeline(record) : null; + } + + retain(itemIds: ReadonlySet): string[] { + const removedPendingIds: string[] = []; + for (const [id, record] of this.records) { + if (itemIds.has(record.item.id)) continue; + this.records.delete(id); + if (record.resolution === undefined) removedPendingIds.push(id); + } + return removedPendingIds; + } + + serialize(): unknown { + return Array.from(this.records.values()); + } +} diff --git a/packages/server/src/server/agent/providers/codex/test-utils/fake-app-server.ts b/packages/server/src/server/agent/providers/codex/test-utils/fake-app-server.ts index 3f4a7af01c..31e231cdb1 100644 --- a/packages/server/src/server/agent/providers/codex/test-utils/fake-app-server.ts +++ b/packages/server/src/server/agent/providers/codex/test-utils/fake-app-server.ts @@ -618,7 +618,7 @@ function toJsonObject(value: unknown): JsonObject { type StreamEventType = AgentStreamEvent["type"]; type StreamEventOfType = Extract; -function waitForNextEvent( +export function waitForNextEvent( session: AgentSession, type: TType, accepts?: (event: StreamEventOfType) => boolean,