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
This commit is contained in:
Mohamed Boudra
2026-09-09 20:32:21 +02:00
committed by GitHub
parent bc385b5930
commit ec4e81ce0f
6 changed files with 710 additions and 22 deletions
@@ -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: {
@@ -2885,6 +2885,9 @@ export class AgentManager {
response: AgentPermissionResponse,
): Promise<AgentPermissionResult | void> {
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;
@@ -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<string, unknown>): 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<AgentPermissionResult | void> {
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<AgentPermissionResult | void> {
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<ParsedCodexNotification, { kind: "item_completed" }>,
): 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;
@@ -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<string, unknown>, 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<string, unknown> | 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<string, unknown> | 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<string, unknown> | 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();
}
});
@@ -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<typeof RecordSchema>;
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<string, QuestionRecord>();
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>): 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());
}
}
@@ -618,7 +618,7 @@ function toJsonObject(value: unknown): JsonObject {
type StreamEventType = AgentStreamEvent["type"];
type StreamEventOfType<TType extends StreamEventType> = Extract<AgentStreamEvent, { type: TType }>;
function waitForNextEvent<TType extends StreamEventType>(
export function waitForNextEvent<TType extends StreamEventType>(
session: AgentSession,
type: TType,
accepts?: (event: StreamEventOfType<TType>) => boolean,