From 702e2e7db93517d40e02429211b2de9b2686a195 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 5 Apr 2026 09:18:46 +0700 Subject: [PATCH] Handle Codex request_user_input app-server questions Fixes #195 --- .../codex-app-server-agent.e2e.test.ts | 358 +++++++++++++++++- .../providers/codex-app-server-agent.test.ts | 209 +++++++++- .../agent/providers/codex-app-server-agent.ts | 305 +++++++++++++-- 3 files changed, 834 insertions(+), 38 deletions(-) diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.e2e.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.e2e.test.ts index 8094fe95e..89d1b0016 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.e2e.test.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.e2e.test.ts @@ -2,11 +2,14 @@ import { describe, expect, test } from "vitest"; import { execFileSync } from "node:child_process"; import os from "node:os"; import path from "node:path"; -import { mkdtempSync } from "node:fs"; +import { createServer } from "node:http"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { once } from "node:events"; import { CodexAppServerAgentClient } from "./codex-app-server-agent.js"; import { createTestLogger } from "../../../test-utils/test-logger.js"; import { agentConfigs } from "../../daemon-e2e/agent-configs.js"; +import type { AgentStreamEvent } from "../agent-sdk-types.js"; const CODEX_TEST_MODEL = agentConfigs.codex.model; const CODEX_TEST_THINKING_OPTION_ID = agentConfigs.codex.thinkingOptionId; @@ -20,6 +23,192 @@ function isCodexInstalled(): boolean { } } +function sse(events: unknown[]): string { + return events + .map((event) => { + const type = + typeof event === "object" && + event !== null && + "type" in event && + typeof (event as { type?: unknown }).type === "string" + ? (event as { type: string }).type + : "message"; + return `event: ${type}\ndata: ${JSON.stringify(event)}\n\n`; + }) + .join(""); +} + +function responseCreated(id: string): Record { + return { + type: "response.created", + response: { id }, + }; +} + +function responseCompleted(id: string): Record { + return { + type: "response.completed", + response: { + id, + usage: { + input_tokens: 0, + input_tokens_details: null, + output_tokens: 0, + output_tokens_details: null, + total_tokens: 0, + }, + }, + }; +} + +function functionCallEvent(callId: string, name: string, argumentsJson: string): Record { + return { + type: "response.output_item.done", + item: { + type: "function_call", + call_id: callId, + name, + arguments: argumentsJson, + }, + }; +} + +function assistantMessageEvent(id: string, text: string): Record { + return { + type: "response.output_item.done", + item: { + type: "message", + role: "assistant", + id, + content: [{ type: "output_text", text }], + }, + }; +} + +function requestUserInputSse(callId: string): string { + return sse([ + responseCreated("resp-1"), + functionCallEvent( + callId, + "request_user_input", + JSON.stringify({ + questions: [ + { + id: "confirm_path", + header: "Confirm", + question: "Proceed with the plan?", + options: [ + { + label: "Yes (Recommended)", + description: "Continue the current plan.", + }, + { + label: "No", + description: "Stop and revisit the approach.", + }, + ], + }, + ], + }), + ), + responseCompleted("resp-1"), + ]); +} + +function assistantMessageSse(text: string): string { + return sse([responseCreated("resp-2"), assistantMessageEvent("msg-1", text), responseCompleted("resp-2")]); +} + +async function startMockResponsesServer(sequence: string[]): Promise<{ + url: string; + close: () => Promise; + requestBodies: string[]; +}> { + const requestBodies: string[] = []; + let index = 0; + const server = createServer((req, res) => { + const chunks: Buffer[] = []; + req.on("data", (chunk) => chunks.push(Buffer.from(chunk))); + req.on("end", () => { + requestBodies.push(Buffer.concat(chunks).toString("utf8")); + if (req.method !== "POST" || req.url !== "/v1/responses") { + res.statusCode = 404; + res.end("not found"); + return; + } + const body = sequence[index] ?? assistantMessageSse("done"); + index += 1; + res.statusCode = 200; + res.setHeader("content-type", "text/event-stream"); + res.setHeader("cache-control", "no-cache"); + res.end(body); + }); + }); + + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("Expected TCP address for mock responses server"); + } + return { + url: `http://127.0.0.1:${address.port}`, + requestBodies, + close: () => + new Promise((resolve, reject) => { + server.close((error) => { + if (error) reject(error); + else resolve(); + }); + }), + }; +} + +function writeMockCodexConfig(codexHome: string, serverUrl: string): void { + writeFileSync( + path.join(codexHome, "config.toml"), + ` +model = "mock-model" +approval_policy = "untrusted" +sandbox_mode = "read-only" + +model_provider = "mock_provider" + +[model_providers.mock_provider] +name = "Mock provider for test" +base_url = "${serverUrl}/v1" +wire_api = "responses" +request_max_retries = 0 +stream_max_retries = 0 +`, + ); +} + +function waitForEvent(params: { + session: { + subscribe(callback: (event: AgentStreamEvent) => void): () => void; + }; + predicate: (event: AgentStreamEvent) => event is TEvent; + timeoutMs: number; + label: string; +}): Promise { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + unsubscribe(); + reject(new Error(`Timed out waiting for ${params.label}`)); + }, params.timeoutMs); + + const unsubscribe = params.session.subscribe((event) => { + if (!params.predicate(event)) { + return; + } + clearTimeout(timeout); + unsubscribe(); + resolve(event); + }); + }); +} + describe("Codex app-server provider (e2e)", () => { test.runIf(isCodexInstalled())( "lists models and runs a simple prompt", @@ -43,4 +232,171 @@ describe("Codex app-server provider (e2e)", () => { }, 30000, ); + + test.runIf(isCodexInstalled())( + "surfaces request_user_input from the app-server as question permissions and timeline tool calls", + async () => { + const cwd = mkdtempSync(path.join(os.tmpdir(), "codex-app-server-question-cwd-")); + const codexHome = mkdtempSync(path.join(os.tmpdir(), "codex-app-server-question-home-")); + const mockServer = await startMockResponsesServer([ + requestUserInputSse("call1"), + assistantMessageSse("done"), + ]); + + try { + writeMockCodexConfig(codexHome, mockServer.url); + + const client = new CodexAppServerAgentClient(createTestLogger()); + const session = await client.createSession( + { + provider: "codex", + cwd, + modeId: "auto", + model: "mock-model", + thinkingOptionId: "medium", + }, + { + env: { + CODEX_HOME: codexHome, + }, + }, + ); + + try { + await session.setFeature?.("plan_mode", true); + + const events: AgentStreamEvent[] = []; + session.subscribe((event) => { + events.push(event); + }); + + const questionRequested = waitForEvent({ + session, + timeoutMs: 15_000, + label: "question permission request", + predicate: ( + event, + ): event is Extract< + AgentStreamEvent, + { type: "permission_requested" } + > => + event.type === "permission_requested" && + event.request.provider === "codex" && + event.request.kind === "question" && + event.request.name === "request_user_input", + }); + + const turnFinished = waitForEvent({ + session, + timeoutMs: 15_000, + label: "turn completion", + predicate: ( + event, + ): event is Extract< + AgentStreamEvent, + { type: "turn_completed" | "turn_failed" | "turn_canceled" } + > => + event.type === "turn_completed" || + event.type === "turn_failed" || + event.type === "turn_canceled", + }); + + await session.startTurn("ask something"); + + const permissionEvent = await questionRequested; + expect(permissionEvent.request.input).toEqual({ + questions: [ + { + id: "confirm_path", + header: "Confirm", + question: "Proceed with the plan?", + isOther: true, + options: [ + { + label: "Yes (Recommended)", + description: "Continue the current plan.", + }, + { + label: "No", + description: "Stop and revisit the approach.", + }, + ], + }, + ], + }); + + const runningQuestionCall = events.find( + (event) => + event.type === "timeline" && + event.provider === "codex" && + event.item.type === "tool_call" && + event.item.name === "request_user_input" && + event.item.status === "running", + ); + expect(runningQuestionCall).toBeDefined(); + + await session.respondToPermission(permissionEvent.request.id, { + behavior: "allow", + updatedInput: { + answers: { + Confirm: "Yes (Recommended)", + }, + }, + }); + + const terminalEvent = await turnFinished; + expect(terminalEvent.type).toBe("turn_completed"); + + const completedQuestionCall = events.find( + (event) => + event.type === "timeline" && + event.provider === "codex" && + event.item.type === "tool_call" && + event.item.name === "request_user_input" && + event.item.status === "completed", + ); + expect(completedQuestionCall).toBeDefined(); + if ( + !completedQuestionCall || + completedQuestionCall.type !== "timeline" || + completedQuestionCall.item.type !== "tool_call" + ) { + throw new Error("Expected completed request_user_input tool call"); + } + expect(completedQuestionCall.item.metadata).toMatchObject({ + answers: { + confirm_path: ["Yes (Recommended)"], + }, + }); + expect( + mockServer.requestBodies.some( + (body) => + body.includes('"type":"function_call_output"') && + body.includes('"call_id":"call1"'), + ), + ).toBe(true); + + const finalAssistantMessage = [...events] + .reverse() + .find((event) => event.type === "timeline" && event.item.type === "assistant_message"); + expect(finalAssistantMessage).toBeDefined(); + if ( + !finalAssistantMessage || + finalAssistantMessage.type !== "timeline" || + finalAssistantMessage.item.type !== "assistant_message" + ) { + throw new Error("Expected final assistant message"); + } + expect(finalAssistantMessage.item.text.trim()).toBe("done"); + } finally { + await session.close(); + } + } finally { + await mockServer.close(); + rmSync(cwd, { recursive: true, force: true }); + rmSync(codexHome, { recursive: true, force: true }); + } + }, + 30_000, + ); }); diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts index 671ca4517..1faf6e213 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "vitest"; import { existsSync, rmSync } from "node:fs"; -import type { AgentLaunchContext } from "../agent-sdk-types.js"; +import type { AgentLaunchContext, AgentSession, AgentSessionConfig, AgentStreamEvent } from "../agent-sdk-types.js"; import { __codexAppServerInternals, codexAppServerTurnInputFromPrompt, @@ -10,6 +10,32 @@ import { createTestLogger } from "../../../test-utils/test-logger.js"; const ONE_BY_ONE_PNG_BASE64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+X1r0AAAAASUVORK5CYII="; +const CODEX_PROVIDER = "codex"; + +function createConfig(overrides: Partial = {}): AgentSessionConfig { + return { + provider: CODEX_PROVIDER, + cwd: "/tmp/codex-question-test", + modeId: "auto", + model: "gpt-5.4", + ...overrides, + }; +} + +function createSession(configOverrides: Partial = {}) { + const session = new __codexAppServerInternals.CodexAppServerAgentSession( + createConfig(configOverrides), + null, + createTestLogger(), + () => { + throw new Error("Test session cannot spawn Codex app-server"); + }, + ) as unknown as AgentSession & { [key: string]: unknown }; + session.connected = true; + session.currentThreadId = "test-thread"; + session.activeForegroundTurnId = "test-turn"; + return session; +} describe("Codex app-server provider", () => { const logger = createTestLogger(); @@ -138,4 +164,185 @@ describe("Codex app-server provider", () => { expect(env.PASEO_AGENT_ID).toBe(launchContext.env?.PASEO_AGENT_ID); expect(env.PASEO_TEST_FLAG).toBe(launchContext.env?.PASEO_TEST_FLAG); }); + + test("projects request_user_input into a question permission and running timeline tool call", () => { + const session = createSession(); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + + void (session as any).handleToolApprovalRequest({ + itemId: "call-question-1", + threadId: "thread-1", + turnId: "turn-1", + questions: [ + { + id: "favorite_drink", + header: "Drink", + question: "Which drink do you want?", + options: [ + { label: "Coffee", description: "Default" }, + { label: "Tea" }, + ], + }, + ], + }); + + expect(events).toEqual([ + { + type: "timeline", + provider: "codex", + turnId: "test-turn", + item: { + type: "tool_call", + callId: "call-question-1", + name: "request_user_input", + status: "running", + error: null, + detail: { + type: "plain_text", + text: "Drink: Which drink do you want?\nOptions: Coffee, Tea", + icon: "brain", + }, + metadata: { + questions: [ + { + id: "favorite_drink", + header: "Drink", + question: "Which drink do you want?", + options: [ + { label: "Coffee", description: "Default" }, + { label: "Tea" }, + ], + }, + ], + }, + }, + }, + { + type: "permission_requested", + provider: "codex", + turnId: "test-turn", + request: { + id: "permission-call-question-1", + provider: "codex", + name: "request_user_input", + kind: "question", + title: "Question", + detail: { + type: "plain_text", + text: "Drink: Which drink do you want?\nOptions: Coffee, Tea", + icon: "brain", + }, + input: { + questions: [ + { + id: "favorite_drink", + header: "Drink", + question: "Which drink do you want?", + options: [ + { label: "Coffee", description: "Default" }, + { label: "Tea" }, + ], + }, + ], + }, + metadata: { + itemId: "call-question-1", + threadId: "thread-1", + turnId: "turn-1", + questions: [ + { + id: "favorite_drink", + header: "Drink", + question: "Which drink do you want?", + options: [ + { label: "Coffee", description: "Default" }, + { label: "Tea" }, + ], + }, + ], + }, + }, + }, + ]); + }); + + test("maps question responses from headers back to question ids and completes the tool call", async () => { + const session = createSession(); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + + const pendingResponse = (session as any).handleToolApprovalRequest({ + itemId: "call-question-2", + threadId: "thread-1", + turnId: "turn-1", + questions: [ + { + id: "favorite_drink", + header: "Drink", + question: "Which drink do you want?", + options: [{ label: "Coffee" }, { label: "Tea" }], + }, + ], + }); + + await session.respondToPermission("permission-call-question-2", { + behavior: "allow", + updatedInput: { + answers: { + Drink: "Tea", + }, + }, + }); + + await expect(pendingResponse).resolves.toEqual({ + answers: { + favorite_drink: { answers: ["Tea"] }, + }, + }); + expect(events.at(-2)).toEqual({ + type: "permission_resolved", + provider: "codex", + turnId: "test-turn", + requestId: "permission-call-question-2", + resolution: { + behavior: "allow", + updatedInput: { + answers: { + Drink: "Tea", + }, + }, + }, + }); + expect(events.at(-1)).toEqual({ + type: "timeline", + provider: "codex", + turnId: "test-turn", + item: { + type: "tool_call", + callId: "call-question-2", + name: "request_user_input", + status: "completed", + error: null, + detail: { + type: "plain_text", + text: "Drink: Which drink do you want?\nOptions: Coffee, Tea\n\nAnswers:\n\nfavorite_drink: Tea", + icon: "brain", + }, + metadata: { + questions: [ + { + id: "favorite_drink", + header: "Drink", + question: "Which drink do you want?", + options: [{ label: "Coffee" }, { label: "Tea" }], + }, + ], + answers: { + favorite_drink: ["Tea"], + }, + }, + }, + }); + }); }); 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 cf88f12f8..bf8d3e086 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 @@ -573,6 +573,17 @@ class CodexAppServerClient { this.child.stdin.write(`${JSON.stringify(payload)}\n`); } + private writeJsonRpcResponse(response: JsonRpcResponse): void { + if (this.disposed || this.child.stdin.destroyed || !this.child.stdin.writable) { + return; + } + try { + this.child.stdin.write(`${JSON.stringify(response)}\n`); + } catch (error) { + this.logger.debug({ error }, "Failed to write Codex app-server JSON-RPC response"); + } + } + async dispose(): Promise { if (this.disposed) return; this.disposed = true; @@ -617,14 +628,12 @@ class CodexAppServerClient { const handler = this.requestHandlers.get(request.method); try { const result = handler ? await handler(request.params) : {}; - const response: JsonRpcResponse = { id: request.id, result }; - this.child.stdin.write(`${JSON.stringify(response)}\n`); + this.writeJsonRpcResponse({ id: request.id, result }); } catch (error) { - const response: JsonRpcResponse = { + this.writeJsonRpcResponse({ id: request.id, error: { message: error instanceof Error ? error.message : String(error) }, - }; - this.child.stdin.write(`${JSON.stringify(response)}\n`); + }); } return; } @@ -723,6 +732,187 @@ function mapCodexPlanToToolCall(params: { callId: string; text: string }): ToolC }; } +type CodexQuestionOption = { + label: string; + description?: string; +}; + +type CodexQuestionPrompt = { + id: string; + header: string; + question: string; + options: CodexQuestionOption[]; + multiSelect?: boolean; + isOther?: boolean; + isSecret?: boolean; +}; + +function normalizeCodexQuestionPrompts(raw: unknown): CodexQuestionPrompt[] { + if (!Array.isArray(raw)) { + return []; + } + const questions: CodexQuestionPrompt[] = []; + for (const item of raw) { + if (!item || typeof item !== "object") { + continue; + } + const record = item as Record; + const id = nonEmptyString(record.id); + const header = nonEmptyString(record.header); + const question = nonEmptyString(record.question); + if (!id || !header || !question) { + continue; + } + const options = Array.isArray(record.options) + ? record.options.flatMap((option): CodexQuestionOption[] => { + if (!option || typeof option !== "object") { + return []; + } + const optionRecord = option as Record; + const label = nonEmptyString(optionRecord.label); + if (!label) { + return []; + } + return [ + { + label, + ...(typeof optionRecord.description === "string" && + optionRecord.description.trim().length > 0 + ? { description: optionRecord.description } + : {}), + }, + ]; + }) + : []; + questions.push({ + id, + header, + question, + options, + ...(record.multiSelect === true ? { multiSelect: true } : {}), + ...(record.isOther === true ? { isOther: true } : {}), + ...(record.isSecret === true ? { isSecret: true } : {}), + }); + } + return questions; +} + +function formatCodexQuestionPrompts(questions: CodexQuestionPrompt[]): string { + return questions + .map((question) => { + const lines = [`${question.header}: ${question.question}`]; + if (question.options.length > 0) { + lines.push(`Options: ${question.options.map((option) => option.label).join(", ")}`); + } + return lines.join("\n"); + }) + .join("\n\n") + .trim(); +} + +function mapCodexQuestionRequestToToolCall(params: { + callId: string; + questions: CodexQuestionPrompt[]; + status: ToolCallTimelineItem["status"]; + answers?: Record; + error?: unknown; +}): ToolCallTimelineItem { + const formattedQuestions = formatCodexQuestionPrompts(params.questions); + const formattedAnswers = + params.answers && Object.keys(params.answers).length > 0 + ? Object.entries(params.answers) + .map(([id, values]) => `${id}: ${values.join(", ")}`) + .join("\n") + : null; + const detailText = + params.status === "completed" && formattedAnswers + ? [formattedQuestions, "Answers:", formattedAnswers].filter(Boolean).join("\n\n") + : formattedQuestions; + + const base = { + type: "tool_call" as const, + callId: params.callId, + name: "request_user_input", + detail: { + type: "plain_text" as const, + text: detailText, + icon: "brain" as const, + }, + metadata: { + questions: params.questions, + ...(params.answers ? { answers: params.answers } : {}), + }, + }; + + if (params.status === "failed") { + return { + ...base, + status: "failed", + error: params.error ?? { message: "Question dismissed" }, + }; + } + if (params.status === "canceled") { + return { + ...base, + status: "canceled", + error: null, + }; + } + if (params.status === "running") { + return { + ...base, + status: "running", + error: null, + }; + } + return { + ...base, + status: "completed", + error: null, + }; +} + +function mapCodexQuestionResponseByHeader(params: { + questions: CodexQuestionPrompt[]; + response: AgentPermissionResponse; +}): Record | null { + if (params.response.behavior !== "allow") { + return null; + } + const answersRecord = + params.response.updatedInput && typeof params.response.updatedInput === "object" + ? ((params.response.updatedInput as Record).answers as + | Record + | undefined) + : undefined; + if (!answersRecord || typeof answersRecord !== "object") { + return null; + } + + const answers: Record = {}; + for (const question of params.questions) { + const rawAnswer = answersRecord[question.header]; + if (typeof rawAnswer !== "string") { + continue; + } + const normalizedAnswer = rawAnswer.trim(); + if (!normalizedAnswer) { + continue; + } + const values = question.multiSelect + ? normalizedAnswer + .split(",") + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0) + : [normalizedAnswer]; + if (values.length > 0) { + answers[question.id] = { answers: values }; + } + } + + return Object.keys(answers).length > 0 ? answers : null; +} + type CodexPatchFileChange = { path: string; kind?: string; @@ -2072,8 +2262,8 @@ class CodexAppServerAgentSession implements AgentSession { string, { resolve: (value: unknown) => void; - kind: "command" | "file" | "tool"; - questions?: Array<{ id: string; options?: Array<{ label?: string; value?: string }> }>; + kind: "command" | "file" | "question"; + questions?: CodexQuestionPrompt[]; } >(); private resolvedPermissionRequests = new Set(); @@ -2284,6 +2474,10 @@ class CodexAppServerAgentSession implements AgentSession { this.client.setRequestHandler("item/fileChange/requestApproval", (params) => this.handleFileChangeApprovalRequest(params), ); + this.client.setRequestHandler("item/tool/requestUserInput", (params) => + this.handleToolApprovalRequest(params), + ); + // Keep the legacy method name for older Codex builds. this.client.setRequestHandler("tool/requestUserInput", (params) => this.handleToolApprovalRequest(params), ); @@ -2745,26 +2939,53 @@ class CodexAppServerAgentSession implements AgentSession { return; } - // tool/requestUserInput - const answers: Record = {}; const questions = pending.questions ?? []; - const decision = - response.behavior === "allow" ? "accept" : response.interrupt ? "cancel" : "decline"; - for (const question of questions) { - let picked = decision; - const options = question.options ?? []; - if (options.length > 0) { - const byLabel = options.find((opt) => (opt.label ?? "").toLowerCase().includes(decision)); - const byValue = options.find((opt) => (opt.value ?? "").toLowerCase().includes(decision)); - const option = byLabel ?? byValue ?? options[0]!; - picked = option.value ?? option.label ?? decision; - } - answers[question.id] = { answers: [picked] }; + const itemId = + typeof pendingRequest?.metadata?.itemId === "string" ? pendingRequest.metadata.itemId : requestId; + if (response.behavior === "allow") { + const mappedAnswers = mapCodexQuestionResponseByHeader({ + questions, + response, + }); + const answers = + mappedAnswers ?? + Object.fromEntries( + questions + .map((question) => { + const fallback = question.options[0]?.label?.trim(); + return fallback + ? [question.id, { answers: [fallback] }] + : null; + }) + .filter((entry): entry is [string, { answers: string[] }] => entry !== null), + ); + this.emitEvent({ + type: "timeline", + provider: CODEX_PROVIDER, + item: mapCodexQuestionRequestToToolCall({ + callId: itemId, + questions, + status: "completed", + answers: Object.fromEntries( + Object.entries(answers).map(([id, value]) => [id, value.answers]), + ), + }), + }); + pending.resolve({ answers }); + return; } - if (questions.length === 0) { - answers["default"] = { answers: [decision] }; - } - pending.resolve({ answers }); + + this.emitEvent({ + type: "timeline", + provider: CODEX_PROVIDER, + item: mapCodexQuestionRequestToToolCall({ + callId: itemId, + questions, + status: response.interrupt ? "canceled" : "failed", + error: { message: response.message ?? "Question dismissed" }, + }), + }); + pending.resolve({ answers: {} }); } describePersistence(): { @@ -3463,34 +3684,43 @@ class CodexAppServerAgentSession implements AgentSession { private handleToolApprovalRequest(params: unknown): Promise { const parsed = params as { itemId: string; threadId: string; turnId: string; questions: any[] }; const requestId = `permission-${parsed.itemId}`; + const questions = normalizeCodexQuestionPrompts(parsed.questions); const request: AgentPermissionRequest = { id: requestId, provider: CODEX_PROVIDER, - name: "CodexTool", - kind: "tool", - title: "Tool action requires approval", + name: "request_user_input", + kind: "question", + title: "Question", description: undefined, detail: { - type: "unknown", - input: { - questions: Array.isArray(parsed.questions) ? parsed.questions : [], - }, - output: null, + type: "plain_text", + text: formatCodexQuestionPrompts(questions), + icon: "brain", }, + input: { questions }, metadata: { itemId: parsed.itemId, threadId: parsed.threadId, turnId: parsed.turnId, - questions: parsed.questions, + questions, }, }; this.pendingPermissions.set(requestId, request); + this.emitEvent({ + type: "timeline", + provider: CODEX_PROVIDER, + item: mapCodexQuestionRequestToToolCall({ + callId: parsed.itemId, + questions, + status: "running", + }), + }); this.emitEvent({ type: "permission_requested", provider: CODEX_PROVIDER, request }); return new Promise((resolve) => { this.pendingPermissionHandlers.set(requestId, { resolve, - kind: "tool", - questions: Array.isArray(parsed.questions) ? parsed.questions : [], + kind: "question", + questions, }); }); } @@ -3723,8 +3953,11 @@ export const __codexAppServerInternals = { buildCodexAppServerEnv, codexModelSupportsFastMode, CodexAppServerAgentSession, + formatCodexQuestionPrompts, + mapCodexQuestionRequestToToolCall, mapCodexPatchNotificationToToolCall, planStepsToMarkdown, mapCodexPlanToToolCall, + normalizeCodexQuestionPrompts, threadItemToTimeline, };