From d4268e388116a4aac74571589852ef9c2ac50516 Mon Sep 17 00:00:00 2001 From: David Longman Date: Mon, 6 Apr 2026 23:01:07 -0600 Subject: [PATCH] fix(stream): flush buffered final assistant output reliably Ensure Codex provider flushes buffered assistant deltas before turn completion and make stream completion merge replace stale same-id tail items with finalized head content. Includes focused regressions for both races. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- packages/app/src/types/stream-event.test.ts | 31 + packages/app/src/types/stream.ts | 26 +- .../providers/codex-app-server-agent.test.ts | 261 ++++++- .../agent/providers/codex-app-server-agent.ts | 641 ++++++++++++++---- 4 files changed, 833 insertions(+), 126 deletions(-) diff --git a/packages/app/src/types/stream-event.test.ts b/packages/app/src/types/stream-event.test.ts index 2185b9339..f159ec119 100644 --- a/packages/app/src/types/stream-event.test.ts +++ b/packages/app/src/types/stream-event.test.ts @@ -131,6 +131,37 @@ describe("applyStreamEvent", () => { expect(result.tail[0].kind).toBe("assistant_message"); }); + it("replaces stale tail content with finalized head content on turn completion", () => { + const result = applyStreamEvent({ + tail: [ + { + kind: "assistant_message", + id: "assistant-shared", + text: "Hello", + timestamp: new Date(0), + }, + ], + head: [ + { + kind: "assistant_message", + id: "assistant-shared", + text: "Hello world", + timestamp: new Date(1), + }, + ], + event: completionEvent(), + timestamp: baseTimestamp, + }); + + expect(result.head).toHaveLength(0); + expect(result.tail).toHaveLength(1); + expect(result.tail[0]).toMatchObject({ + kind: "assistant_message", + id: "assistant-shared", + text: "Hello world", + }); + }); + it("flushes reasoning when assistant message starts", () => { let result = applyStreamEvent({ tail: [], diff --git a/packages/app/src/types/stream.ts b/packages/app/src/types/stream.ts index 677667a2e..95a302dbe 100644 --- a/packages/app/src/types/stream.ts +++ b/packages/app/src/types/stream.ts @@ -735,14 +735,30 @@ function flushHeadToTail(tail: StreamItem[], head: StreamItem[]): StreamItem[] { } const finalized = finalizeHeadItems(head); - const tailIds = new Set(tail.map((item) => item.id)); - const newItems = finalized.filter((item) => !tailIds.has(item.id)); + const tailIndexById = new Map(tail.map((item, index) => [item.id, index])); + let nextTail = tail; - if (newItems.length === 0) { - return tail; + for (const item of finalized) { + const existingIndex = tailIndexById.get(item.id); + if (existingIndex === undefined) { + if (nextTail === tail) { + nextTail = [...tail]; + } + nextTail.push(item); + tailIndexById.set(item.id, nextTail.length - 1); + continue; + } + + const existing = nextTail[existingIndex]; + if (existing !== item) { + if (nextTail === tail) { + nextTail = [...tail]; + } + nextTail[existingIndex] = item; + } } - return [...tail, ...newItems]; + return nextTail; } /** 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 3ec99eaca..1e70abc7f 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(); @@ -55,6 +81,25 @@ describe("Codex app-server provider", () => { } }); + test("maps Codex plan markdown to a synthetic plan tool call", () => { + const item = __codexAppServerInternals.mapCodexPlanToToolCall({ + callId: "plan-turn-1", + text: "### Login Screen\n- Build layout\n- Add validation", + }); + + expect(item).toEqual({ + type: "tool_call", + callId: "plan-turn-1", + name: "plan", + status: "completed", + error: null, + detail: { + type: "plan", + text: "### Login Screen\n- Build layout\n- Add validation", + }, + }); + }); + test("maps patch notifications with object-style single change payloads", () => { const item = __codexAppServerInternals.mapCodexPatchNotificationToToolCall({ callId: "patch-object-single", @@ -119,4 +164,218 @@ 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"], + }, + }, + }, + }); + }); + + test("emits buffered assistant text before task_complete closes the turn", () => { + const session = createSession(); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + + ;(session as any).handleNotification("item/agentMessage/delta", { + itemId: "msg-late-final", + delta: "COMPLEX_REPRO_OK", + }); + + ;(session as any).handleNotification("codex/event/task_complete", { + msg: { type: "task_complete" }, + }); + + expect(events).toEqual([ + { + type: "timeline", + provider: "codex", + turnId: "test-turn", + item: { + type: "assistant_message", + text: "COMPLEX_REPRO_OK", + }, + }, + { + type: "turn_completed", + provider: "codex", + turnId: "test-turn", + usage: undefined, + }, + ]); + }); }); 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 ccd83cf8c..dd4dc9bed 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 @@ -1,6 +1,7 @@ import type { AgentCapabilityFlags, AgentClient, + AgentFeature, AgentLaunchContext, AgentMode, AgentModelDefinition, @@ -41,11 +42,23 @@ import { } from "./codex/tool-call-mapper.js"; import { applyProviderEnv, - findExecutable, resolveProviderCommandPrefix, type ProviderRuntimeSettings, } from "../provider-launch-config.js"; +import { + findExecutable, + quoteWindowsArgument, + quoteWindowsCommand, +} from "../../../utils/executable.js"; import { extractCodexTerminalSessionId, nonEmptyString } from "./tool-call-mapper-utils.js"; +import { buildCodexFeatures, codexModelSupportsFastMode } from "./codex-feature-definitions.js"; +import { + formatDiagnosticStatus, + formatProviderDiagnostic, + formatProviderDiagnosticError, + resolveBinaryVersion, + toDiagnosticErrorMessage, +} from "./diagnostic-utils.js"; const DEFAULT_TIMEOUT_MS = 14 * 24 * 60 * 60 * 1000; const TURN_START_TIMEOUT_MS = 90 * 1000; @@ -62,16 +75,10 @@ const CODEX_APP_SERVER_CAPABILITIES: AgentCapabilityFlags = { }; const CODEX_MODES: AgentMode[] = [ - { - id: "read-only", - label: "Read Only", - description: - "Read files and answer questions. Manual approval required for edits, commands, or network ops.", - }, { id: "auto", - label: "Auto", - description: "Edit files and run commands but still request approval before escalating scope.", + label: "Default Permissions", + description: "Edit files and run commands with Codex's default approval flow.", }, { id: "full-access", @@ -573,6 +580,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 +635,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; } @@ -684,28 +700,224 @@ function extractUserText(content: unknown): string | null { return parts.length > 0 ? parts.join("\n") : null; } -function parsePlanTextToTodoItems(text: string): { text: string; completed: boolean }[] { - const lines = text +function normalizePlanMarkdown(text: string): string { + return text .split("\n") - .map((line) => line.trim()) - .filter((line) => line.length > 0); - if (lines.length === 0) { - return [{ text, completed: false }]; - } - return lines.map((line) => ({ - text: line.replace(/^[-*]\s+/, ""), - completed: false, - })); + .map((line) => line.replace(/\s+$/, "")) + .join("\n") + .trim(); +} + +function planStepsToMarkdown(steps: Array<{ step: string; status: string }>): string { + const lines = steps + .map((entry) => entry.step.trim()) + .filter((step) => step.length > 0) + .map((step) => { + if (/^(#{1,6}\s|[-*+]\s|\d+\.\s)/.test(step)) { + return step; + } + return `- ${step}`; + }); + return normalizePlanMarkdown(lines.join("\n")); +} + +function mapCodexPlanToToolCall(params: { callId: string; text: string }): ToolCallTimelineItem | null { + const text = normalizePlanMarkdown(params.text); + if (!text) { + return null; + } + return { + type: "tool_call", + callId: params.callId, + name: "plan", + status: "completed", + error: null, + detail: { + type: "plan", + text, + }, + }; +} + +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 planStepsToTodoItems(steps: Array<{ step: string; status: string }>): { - text: string; - completed: boolean; -}[] { - return steps.map((entry) => ({ - text: entry.step, - completed: entry.status === "completed", - })); +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 = { @@ -1090,9 +1302,12 @@ function threadItemToTimeline( return { type: "assistant_message", text: normalizedItem.text ?? "" }; } case "plan": { - const text = normalizedItem.text ?? ""; - const items = parsePlanTextToTodoItems(text); - return { type: "todo", items }; + return mapCodexPlanToToolCall({ + callId: + nonEmptyString(normalizedItem.id ?? normalizedItem.itemId ?? undefined) ?? + `plan:${normalizePlanMarkdown(normalizedItem.text ?? "")}`, + text: normalizedItem.text ?? "", + }); } case "reasoning": { const summary = Array.isArray(normalizedItem.summary) @@ -2015,10 +2230,21 @@ function buildCodexAppServerEnv( }; } -export const __codexAppServerInternals = { - buildCodexAppServerEnv, - mapCodexPatchNotificationToToolCall, -}; +function buildCodexAppServerInitializeParams(): { + clientInfo: { name: string; title: string; version: string }; + capabilities: { experimentalApi: true }; +} { + return { + clientInfo: { + name: "paseo", + title: "Paseo", + version: "0.0.0", + }, + capabilities: { + experimentalApi: true, + }, + }; +} class CodexAppServerAgentSession implements AgentSession { readonly provider = CODEX_PROVIDER; @@ -2034,6 +2260,8 @@ class CodexAppServerAgentSession implements AgentSession { private nextTurnOrdinal = 0; private activeForegroundTurnId: string | null = null; private cachedRuntimeInfo: AgentRuntimeInfo | null = null; + private serviceTier: "fast" | null = null; + private planModeEnabled = false; private historyPending = false; private persistedHistory: AgentTimelineItem[] = []; private pendingPermissions = new Map(); @@ -2041,8 +2269,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(); @@ -2090,6 +2318,12 @@ class CodexAppServerAgentSession implements AgentSession { this.currentMode = config.modeId; this.config = config; this.config.thinkingOptionId = normalizeCodexThinkingOptionId(this.config.thinkingOptionId); + if (this.config.featureValues?.fast_mode) { + this.serviceTier = "fast"; + } + if (this.config.featureValues?.plan_mode) { + this.planModeEnabled = true; + } if (this.resumeHandle?.sessionId) { this.currentThreadId = this.resumeHandle.sessionId; @@ -2101,6 +2335,15 @@ class CodexAppServerAgentSession implements AgentSession { return this.currentThreadId; } + get features(): AgentFeature[] { + return buildCodexFeatures({ + modelId: this.config.model, + fastModeEnabled: this.serviceTier === "fast", + planModeEnabled: this.planModeEnabled, + planModeAvailable: this.hasPlanCollaborationMode(), + }); + } + async connect(): Promise { if (this.connected) return; const child = this.spawnAppServer(); @@ -2108,13 +2351,7 @@ class CodexAppServerAgentSession implements AgentSession { this.client.setNotificationHandler((method, params) => this.handleNotification(method, params)); this.registerRequestHandlers(); - await this.client.request("initialize", { - clientInfo: { - name: "paseo", - title: "Paseo", - version: "0.0.0", - }, - }); + await this.client.request("initialize", buildCodexAppServerInitializeParams()); this.client.notify("initialized", {}); await this.loadCollaborationModes(); @@ -2146,7 +2383,7 @@ class CodexAppServerAgentSession implements AgentSession { this.logger.trace({ error }, "Failed to load collaboration modes"); this.collaborationModes = []; } - this.resolvedCollaborationMode = this.resolveCollaborationMode(this.currentMode); + this.refreshResolvedCollaborationMode(); } private async loadSkills(): Promise { @@ -2175,23 +2412,46 @@ class CodexAppServerAgentSession implements AgentSession { } } - private resolveCollaborationMode( - modeId: string, - ): { mode: string; settings: Record; name: string } | null { + private findCollaborationMode( + target: "code" | "plan", + ): { + name: string; + mode?: string | null; + model?: string | null; + reasoning_effort?: string | null; + developer_instructions?: string | null; + } | null { if (this.collaborationModes.length === 0) return null; - const normalized = modeId.toLowerCase(); const findByName = (predicate: (name: string) => boolean) => this.collaborationModes.find((entry) => predicate(entry.name.toLowerCase())); - let match = - normalized === "read-only" - ? findByName((name) => name.includes("read") || name.includes("plan")) - : normalized === "full-access" - ? findByName((name) => name.includes("full") || name.includes("exec")) - : findByName((name) => name.includes("auto") || name.includes("code")); - if (!match) { - match = this.collaborationModes[0] ?? null; + + if (target === "plan") { + return findByName((name) => name.includes("plan") || name.includes("read")) ?? null; } + + return ( + findByName((name) => name.includes("auto") || name.includes("code")) ?? + this.collaborationModes.find((entry) => { + const name = entry.name.toLowerCase(); + return !name.includes("plan") && !name.includes("read"); + }) ?? + this.collaborationModes[0] ?? + null + ); + } + + private hasPlanCollaborationMode(): boolean { + return this.findCollaborationMode("plan") !== null; + } + + private resolveCollaborationMode(): { + mode: string; + settings: Record; + name: string; + } | null { + const match = this.findCollaborationMode(this.planModeEnabled ? "plan" : "code"); if (!match) return null; + const settings: Record = {}; if (match.model) settings.model = match.model; if (match.reasoning_effort) settings.reasoning_effort = match.reasoning_effort; @@ -2208,6 +2468,10 @@ class CodexAppServerAgentSession implements AgentSession { return { mode: match.mode ?? "code", settings, name: match.name }; } + private refreshResolvedCollaborationMode(): void { + this.resolvedCollaborationMode = this.resolveCollaborationMode(); + } + private registerRequestHandlers(): void { if (!this.client) return; @@ -2217,6 +2481,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), ); @@ -2389,6 +2657,8 @@ class CodexAppServerAgentSession implements AgentSession { timeline.push(event.item); if (event.item.type === "assistant_message") { finalText = event.item.text; + } else if (event.item.type === "tool_call" && event.item.detail.type === "plan") { + finalText = event.item.detail.text; } return; } @@ -2491,6 +2761,9 @@ class CodexAppServerAgentSession implements AgentSession { if (thinkingOptionId) { params.effort = thinkingOptionId; } + if (this.serviceTier) { + params.serviceTier = this.serviceTier; + } if (this.resolvedCollaborationMode) { params.collaborationMode = { mode: this.resolvedCollaborationMode.mode, @@ -2576,22 +2849,39 @@ class CodexAppServerAgentSession implements AgentSession { async setMode(modeId: string): Promise { validateCodexMode(modeId); this.currentMode = modeId; - this.resolvedCollaborationMode = this.resolveCollaborationMode(modeId); this.cachedRuntimeInfo = null; } async setModel(modelId: string | null): Promise { this.config.model = modelId ?? undefined; - this.resolvedCollaborationMode = this.resolveCollaborationMode(this.currentMode); + if (!codexModelSupportsFastMode(this.config.model)) { + this.serviceTier = null; + } + this.refreshResolvedCollaborationMode(); this.cachedRuntimeInfo = null; } async setThinkingOption(thinkingOptionId: string | null): Promise { this.config.thinkingOptionId = normalizeCodexThinkingOptionId(thinkingOptionId); - this.resolvedCollaborationMode = this.resolveCollaborationMode(this.currentMode); + this.refreshResolvedCollaborationMode(); this.cachedRuntimeInfo = null; } + async setFeature(featureId: string, value: unknown): Promise { + if (featureId === "fast_mode") { + this.serviceTier = value ? "fast" : null; + this.cachedRuntimeInfo = null; + return; + } + if (featureId === "plan_mode") { + this.planModeEnabled = Boolean(value); + this.refreshResolvedCollaborationMode(); + this.cachedRuntimeInfo = null; + return; + } + throw new Error(`Unknown Codex feature: ${featureId}`); + } + getPendingPermissions(): AgentPermissionRequest[] { return Array.from(this.pendingPermissions.values()); } @@ -2656,26 +2946,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] }; - } - if (questions.length === 0) { - answers["default"] = { answers: [decision] }; + 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; } - 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(): { @@ -2857,6 +3174,21 @@ class CodexAppServerAgentSession implements AgentSession { } } + private emitBufferedAssistantMessages(): void { + for (const [itemId, text] of this.pendingAgentMessages.entries()) { + if (!text) { + continue; + } + this.emitEvent({ + type: "timeline", + provider: CODEX_PROVIDER, + item: { type: "assistant_message", text }, + }); + this.emittedItemCompletedIds.add(itemId); + } + this.pendingAgentMessages.clear(); + } + private createTurnId(): string { return `codex-turn-${this.nextTurnOrdinal++}`; } @@ -2888,6 +3220,9 @@ class CodexAppServerAgentSession implements AgentSession { } if (parsed.kind === "turn_completed") { + if (parsed.status === "completed" && this.pendingAgentMessages.size > 0) { + this.emitBufferedAssistantMessages(); + } if (parsed.status === "failed") { this.emitEvent({ type: "turn_failed", @@ -2915,17 +3250,22 @@ class CodexAppServerAgentSession implements AgentSession { } if (parsed.kind === "plan_updated") { - const items = planStepsToTodoItems( - parsed.plan.map((entry) => ({ - step: entry.step ?? "", - status: entry.status ?? "pending", - })), - ); - this.emitEvent({ - type: "timeline", - provider: CODEX_PROVIDER, - item: { type: "todo", items }, + const timelineItem = mapCodexPlanToToolCall({ + callId: `plan:${this.currentTurnId ?? this.currentThreadId ?? "current"}`, + text: planStepsToMarkdown( + parsed.plan.map((entry) => ({ + step: entry.step ?? "", + status: entry.status ?? "pending", + })), + ), }); + if (timelineItem) { + this.emitEvent({ + type: "timeline", + provider: CODEX_PROVIDER, + item: timelineItem, + }); + } return; } @@ -3369,34 +3709,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, }); }); } @@ -3419,12 +3768,16 @@ export class CodexAppServerAgentClient implements AgentClient { }, "Spawning Codex app server", ); - return spawn(launchPrefix.command, [...launchPrefix.args, "app-server"], { - detached: process.platform !== "win32", - shell: process.platform === "win32", - stdio: ["pipe", "pipe", "pipe"], - env: buildCodexAppServerEnv(this.runtimeSettings, launchEnv), - }); + return spawn( + quoteWindowsCommand(launchPrefix.command), + [...launchPrefix.args, "app-server"].map((argument) => quoteWindowsArgument(argument)), + { + detached: process.platform !== "win32", + shell: process.platform === "win32", + stdio: ["pipe", "pipe", "pipe"], + env: buildCodexAppServerEnv(this.runtimeSettings, launchEnv), + }, + ); } async createSession( @@ -3465,9 +3818,7 @@ export class CodexAppServerAgentClient implements AgentClient { const client = new CodexAppServerClient(child, this.logger); try { - await client.request("initialize", { - clientInfo: { name: "paseo", title: "Paseo", version: "0.0.0" }, - }); + await client.request("initialize", buildCodexAppServerInitializeParams()); client.notify("initialized", {}); const limit = options?.limit ?? 20; @@ -3537,13 +3888,7 @@ export class CodexAppServerAgentClient implements AgentClient { const client = new CodexAppServerClient(child, this.logger); try { - await client.request("initialize", { - clientInfo: { - name: "paseo", - title: "Paseo", - version: "0.0.0", - }, - }); + await client.request("initialize", buildCodexAppServerInitializeParams()); client.notify("initialized", {}); const response = (await client.request("model/list", {})) as { data?: Array }; @@ -3627,4 +3972,60 @@ export class CodexAppServerAgentClient implements AgentClient { } return true; } + + async getDiagnostic(): Promise<{ diagnostic: string }> { + try { + const available = await this.isAvailable(); + const resolvedBinary = findExecutable("codex"); + const entries: Array<{ label: string; value: string }> = [ + { + label: "Binary", + value: resolvedBinary ?? "not found", + }, + { label: "Version", value: resolvedBinary ? resolveBinaryVersion(resolvedBinary) : "unknown" }, + ]; + let status = formatDiagnosticStatus(available); + + if (!available) { + entries.push({ label: "Models", value: "Not checked" }); + } else { + try { + const models = await this.listModels(); + entries.push({ label: "Models", value: String(models.length) }); + } catch (error) { + entries.push({ + label: "Models", + value: `Error - ${toDiagnosticErrorMessage(error)}`, + }); + status = formatDiagnosticStatus(available, { + source: "model fetch", + cause: error, + }); + } + } + + entries.push({ label: "Status", value: status }); + + return { + diagnostic: formatProviderDiagnostic("Codex", entries), + }; + } catch (error) { + return { + diagnostic: formatProviderDiagnosticError("Codex", error), + }; + } + } } + +export const __codexAppServerInternals = { + buildCodexAppServerEnv, + codexModelSupportsFastMode, + CodexAppServerAgentSession, + formatCodexQuestionPrompts, + mapCodexQuestionRequestToToolCall, + mapCodexPatchNotificationToToolCall, + planStepsToMarkdown, + mapCodexPlanToToolCall, + normalizeCodexQuestionPrompts, + threadItemToTimeline, +};