From b198de2ac941619b1ecfb46094815fab5816cac6 Mon Sep 17 00:00:00 2001 From: Chris Volzer Date: Fri, 8 May 2026 12:45:07 -0700 Subject: [PATCH 1/2] Add Codex Fast Mode support --- apps/code/src/main/services/agent/schemas.ts | 3 + apps/code/src/main/services/agent/service.ts | 35 ++++- .../message-editor/components/PromptInput.tsx | 3 + .../components/ServiceTierSelector.tsx | 55 +++++++ .../sessions/components/SessionView.tsx | 24 +++ .../features/sessions/hooks/useSession.ts | 7 + .../features/sessions/service/service.ts | 21 +++ .../features/sessions/stores/sessionStore.ts | 1 + .../task-detail/components/TaskInput.tsx | 29 ++++ .../task-detail/hooks/usePreviewConfig.ts | 3 + .../task-detail/hooks/useTaskCreation.ts | 6 + .../src/renderer/sagas/task/task-creation.ts | 5 + .../codex/codex-agent.refresh.test.ts | 32 ++++ .../src/adapters/codex/codex-agent.test.ts | 27 ++++ .../agent/src/adapters/codex/codex-agent.ts | 148 +++++++++++++++++- .../agent/src/adapters/codex/session-state.ts | 3 + packages/agent/src/adapters/codex/spawn.ts | 14 ++ packages/agent/src/agent.ts | 1 + packages/agent/src/server/agent-server.ts | 1 + packages/agent/src/server/bin.ts | 2 + packages/agent/src/server/types.ts | 1 + packages/agent/src/types.ts | 1 + 22 files changed, 416 insertions(+), 6 deletions(-) create mode 100644 apps/code/src/renderer/features/sessions/components/ServiceTierSelector.tsx diff --git a/apps/code/src/main/services/agent/schemas.ts b/apps/code/src/main/services/agent/schemas.ts index 3ead6cf15..78d72e131 100644 --- a/apps/code/src/main/services/agent/schemas.ts +++ b/apps/code/src/main/services/agent/schemas.ts @@ -35,6 +35,7 @@ export const sessionConfigSchema = z.object({ export type SessionConfig = z.infer; // Start session input/output +const codexServiceTierSchema = z.enum(["standard", "fast", "flex"]); export const startSessionInput = z.object({ taskId: z.string(), @@ -50,6 +51,7 @@ export const startSessionInput = z.object({ customInstructions: z.string().max(2000).optional(), effort: effortLevelSchema.optional(), model: z.string().optional(), + serviceTier: codexServiceTierSchema.optional(), jsonSchema: z.record(z.string(), z.unknown()).nullish(), }); @@ -174,6 +176,7 @@ export const reconnectSessionInput = z.object({ permissionMode: z.string().optional(), customInstructions: z.string().max(2000).optional(), effort: effortLevelSchema.optional(), + serviceTier: codexServiceTierSchema.optional(), jsonSchema: z.record(z.string(), z.unknown()).nullish(), }); diff --git a/apps/code/src/main/services/agent/service.ts b/apps/code/src/main/services/agent/service.ts index 4c06745f6..36e5e41bc 100644 --- a/apps/code/src/main/services/agent/service.ts +++ b/apps/code/src/main/services/agent/service.ts @@ -224,6 +224,8 @@ interface SessionConfig { effort?: EffortLevel; /** Model to use for the session (e.g. "claude-sonnet-4-6") */ model?: string; + /** Codex service tier, e.g. "standard" or "fast" */ + serviceTier?: string; /** JSON Schema for structured task output — when set, the agent gets a create_output tool */ jsonSchema?: Record | null; } @@ -536,6 +538,7 @@ When creating pull requests, add the following footer at the end of the PR descr customInstructions, effort, model, + serviceTier, jsonSchema, } = config; @@ -599,6 +602,7 @@ When creating pull requests, add the following footer at the end of the PR descr codexBinaryPath: adapter === "codex" ? this.getCodexBinaryPath() : undefined, model, + serviceTier, instructions: adapter === "codex" ? systemPrompt.append : undefined, onStructuredOutput: jsonSchema ? async (output) => { @@ -1502,6 +1506,7 @@ For git operations while detached: "customInstructions" in params ? params.customInstructions : undefined, effort: "effort" in params ? params.effort : undefined, model: "model" in params ? params.model : undefined, + serviceTier: "serviceTier" in params ? params.serviceTier : undefined, jsonSchema: "jsonSchema" in params ? params.jsonSchema : undefined, }; } @@ -1767,10 +1772,38 @@ For git operations while detached: currentValue: resolvedModelId, options: modelOptions, category: "model", - description: "Choose which model Claude should use", + description: `Choose which model ${adapter === "codex" ? "Codex" : "Claude"} should use`, }, ]; + if (adapter === "codex") { + configOptions.push({ + id: "service_tier", + name: "Speed", + type: "select", + currentValue: "standard", + options: [ + { + value: "standard", + name: "Standard", + description: "Default Codex service tier", + }, + { + value: "fast", + name: "Fast", + description: "Request Codex fast mode for lower latency", + }, + { + value: "flex", + name: "Flex", + description: "Request Codex flex mode", + }, + ], + category: "service_tier", + description: "Choose the Codex service tier for new turns", + }); + } + const effortOpts = getReasoningEffortOptions(adapter, resolvedModelId); if (effortOpts) { configOptions.push({ diff --git a/apps/code/src/renderer/features/message-editor/components/PromptInput.tsx b/apps/code/src/renderer/features/message-editor/components/PromptInput.tsx index 9ebd58675..fb446d500 100644 --- a/apps/code/src/renderer/features/message-editor/components/PromptInput.tsx +++ b/apps/code/src/renderer/features/message-editor/components/PromptInput.tsx @@ -40,6 +40,7 @@ export interface PromptInputProps { enableCommands?: boolean; // toolbar slots modelSelector?: React.ReactElement | null | false; + speedSelector?: React.ReactElement | null | false; reasoningSelector?: React.ReactElement | null | false; historyButton?: React.ReactNode; // prompt history provider @@ -80,6 +81,7 @@ export const PromptInput = forwardRef( enableBashMode = false, enableCommands = true, modelSelector, + speedSelector, reasoningSelector, historyButton, getPromptHistory, @@ -318,6 +320,7 @@ export const PromptInput = forwardRef( /> )} {modelSelector && {modelSelector}} + {speedSelector && {speedSelector}} {reasoningSelector && {reasoningSelector}} {isBashMode && ( diff --git a/apps/code/src/renderer/features/sessions/components/ServiceTierSelector.tsx b/apps/code/src/renderer/features/sessions/components/ServiceTierSelector.tsx new file mode 100644 index 000000000..c67344640 --- /dev/null +++ b/apps/code/src/renderer/features/sessions/components/ServiceTierSelector.tsx @@ -0,0 +1,55 @@ +import type { SessionConfigOption } from "@agentclientprotocol/sdk"; +import { Tooltip } from "@components/ui/Tooltip"; +import { Lightning } from "@phosphor-icons/react"; +import { Button } from "@posthog/quill"; +import { flattenSelectOptions } from "../stores/sessionStore"; + +interface ServiceTierSelectorProps { + serviceTierOption?: SessionConfigOption; + onChange?: (value: string) => void; + disabled?: boolean; +} + +export function ServiceTierSelector({ + serviceTierOption, + onChange, + disabled, +}: ServiceTierSelectorProps) { + if (!serviceTierOption || serviceTierOption.type !== "select") { + return null; + } + + const options = flattenSelectOptions(serviceTierOption.options); + const supportsFastMode = options.some((opt) => opt.value === "fast"); + if (!supportsFastMode) return null; + + const activeTier = serviceTierOption.currentValue; + const fastModeEnabled = activeTier === "fast"; + + const handleClick = () => { + onChange?.(fastModeEnabled ? "standard" : "fast"); + }; + + return ( + + + + + + ); +} diff --git a/apps/code/src/renderer/features/sessions/components/SessionView.tsx b/apps/code/src/renderer/features/sessions/components/SessionView.tsx index c542a1fcc..1e7bac545 100644 --- a/apps/code/src/renderer/features/sessions/components/SessionView.tsx +++ b/apps/code/src/renderer/features/sessions/components/SessionView.tsx @@ -12,6 +12,7 @@ import { useAdapterForTask, useModeConfigOptionForTask, usePendingPermissionsForTask, + useServiceTierConfigOptionForTask, useThoughtLevelConfigOptionForTask, } from "@features/sessions/stores/sessionStore"; import type { Plan } from "@features/sessions/types"; @@ -41,6 +42,7 @@ import { ModelSelector } from "./ModelSelector"; import { PlanStatusBar } from "./PlanStatusBar"; import { ReasoningLevelSelector } from "./ReasoningLevelSelector"; import { RawLogsView } from "./raw-logs/RawLogsView"; +import { ServiceTierSelector } from "./ServiceTierSelector"; interface SessionViewProps { events: AcpMessage[]; @@ -129,6 +131,7 @@ export function SessionView({ const pendingPermissions = usePendingPermissionsForTask(taskId); const modeOption = useModeConfigOptionForTask(taskId); const thoughtOption = useThoughtLevelConfigOptionForTask(taskId); + const serviceTierOption = useServiceTierConfigOptionForTask(taskId); const adapter = useAdapterForTask(taskId); const { allowBypassPermissions } = useSettingsStore(); const currentModeId = modeOption?.currentValue; @@ -177,6 +180,18 @@ export function SessionView({ [taskId, thoughtOption], ); + const handleServiceTierChange = useCallback( + (value: string) => { + if (!taskId || !serviceTierOption) return; + getSessionService().setSessionConfigOption( + taskId, + serviceTierOption.id, + value, + ); + }, + [taskId, serviceTierOption], + ); + const sessionId = taskId ?? "default"; const setContext = useDraftStore((s) => s.actions.setContext); const requestFocus = useDraftStore((s) => s.actions.requestFocus); @@ -645,6 +660,15 @@ export function SessionView({ /> ) : null } + speedSelector={ + adapter === "codex" && serviceTierOption ? ( + + ) : null + } onBeforeSubmit={onBeforeSubmit} onSubmit={handleSubmit} onBashCommand={onBashCommand} diff --git a/apps/code/src/renderer/features/sessions/hooks/useSession.ts b/apps/code/src/renderer/features/sessions/hooks/useSession.ts index 12edb747c..bcafa0005 100644 --- a/apps/code/src/renderer/features/sessions/hooks/useSession.ts +++ b/apps/code/src/renderer/features/sessions/hooks/useSession.ts @@ -148,6 +148,13 @@ export const useThoughtLevelConfigOptionForTask = ( return useConfigOptionForTask(taskId, "thought_level"); }; +/** Get the service tier config option for a task */ +export const useServiceTierConfigOptionForTask = ( + taskId: string | undefined, +): SessionConfigOption | undefined => { + return useConfigOptionForTask(taskId, "service_tier"); +}; + /** Get the adapter type for a task */ export const useAdapterForTask = ( taskId: string | undefined, diff --git a/apps/code/src/renderer/features/sessions/service/service.ts b/apps/code/src/renderer/features/sessions/service/service.ts index bcfda3422..4bc817dc3 100644 --- a/apps/code/src/renderer/features/sessions/service/service.ts +++ b/apps/code/src/renderer/features/sessions/service/service.ts @@ -207,6 +207,12 @@ interface CloudLogGapReconcileState { pendingRequest?: CloudLogGapReconcileRequest; } +export type CodexServiceTier = "standard" | "fast" | "flex"; + +export function isCodexServiceTier(value: unknown): value is CodexServiceTier { + return value === "standard" || value === "fast" || value === "flex"; +} + export interface ConnectParams { task: Task; repoPath: string; @@ -215,6 +221,7 @@ export interface ConnectParams { adapter?: "claude" | "codex"; model?: string; reasoningLevel?: string; + serviceTier?: CodexServiceTier; } // --- Singleton Service Instance --- @@ -338,6 +345,7 @@ export class SessionService { adapter, model, reasoningLevel, + serviceTier, } = params; const { id: taskId, latest_run: latestRun } = task; const taskTitle = task.title || task.description || "Task"; @@ -445,6 +453,7 @@ export class SessionService { adapter, model, reasoningLevel, + serviceTier, ); } } catch (error) { @@ -569,6 +578,15 @@ export class SessionService { const modeOpt = getConfigOptionByCategory(persistedConfigOptions, "mode"); const persistedMode = modeOpt?.type === "select" ? modeOpt.currentValue : undefined; + const serviceTierOpt = getConfigOptionByCategory( + persistedConfigOptions, + "service_tier", + ); + const persistedServiceTier = + serviceTierOpt?.type === "select" && + isCodexServiceTier(serviceTierOpt.currentValue) + ? serviceTierOpt.currentValue + : undefined; trpcClient.workspace.verify .query({ taskId }) @@ -601,6 +619,7 @@ export class SessionService { sessionId, adapter: resolvedAdapter, permissionMode: persistedMode, + serviceTier: persistedServiceTier, customInstructions: customInstructions || undefined, }); @@ -885,6 +904,7 @@ export class SessionService { adapter?: "claude" | "codex", model?: string, reasoningLevel?: string, + serviceTier?: CodexServiceTier, ): Promise { const { client } = auth; if (!client) { @@ -912,6 +932,7 @@ export class SessionService { ? (reasoningLevel as EffortLevel) : undefined, model: preferredModel, + serviceTier, }); const session = this.createBaseSession(taskRun.id, taskId, taskTitle); diff --git a/apps/code/src/renderer/features/sessions/stores/sessionStore.ts b/apps/code/src/renderer/features/sessions/stores/sessionStore.ts index 23257d7a7..d74239a47 100644 --- a/apps/code/src/renderer/features/sessions/stores/sessionStore.ts +++ b/apps/code/src/renderer/features/sessions/stores/sessionStore.ts @@ -231,6 +231,7 @@ export { useOptimisticItemsForTask, usePendingPermissionsForTask, useQueuedMessagesForTask, + useServiceTierConfigOptionForTask, useSessionForTask, useSessions, useThoughtLevelConfigOptionForTask, diff --git a/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx b/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx index d0302697d..fb2a3d9fa 100644 --- a/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx +++ b/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx @@ -19,6 +19,7 @@ import type { EditorHandle } from "@features/message-editor/types"; import { resolveAndAttachDroppedFiles } from "@features/message-editor/utils/persistFile"; import { DropZoneOverlay } from "@features/sessions/components/DropZoneOverlay"; import { ReasoningLevelSelector } from "@features/sessions/components/ReasoningLevelSelector"; +import { ServiceTierSelector } from "@features/sessions/components/ServiceTierSelector"; import { UnifiedModelSelector } from "@features/sessions/components/UnifiedModelSelector"; import { getCurrentModeFromConfigOptions } from "@features/sessions/stores/sessionStore"; import type { AgentAdapter } from "@features/settings/stores/settingsStore"; @@ -360,6 +361,7 @@ export function TaskInput({ modeOption, modelOption, thoughtOption, + serviceTierOption, isLoading: isPreviewLoading, setConfigOption, } = usePreviewConfig(adapter); @@ -456,6 +458,12 @@ export function TaskInput({ modeFallback; const currentReasoningLevel = thoughtOption?.type === "select" ? thoughtOption.currentValue : undefined; + const currentServiceTier = + adapter === "codex" && + effectiveWorkspaceMode !== "cloud" && + serviceTierOption?.type === "select" + ? serviceTierOption.currentValue + : undefined; const branchForTaskCreation = effectiveWorkspaceMode === "worktree" || effectiveWorkspaceMode === "cloud" @@ -474,6 +482,7 @@ export function TaskInput({ executionMode: currentExecutionMode, model: currentModel, reasoningLevel: currentReasoningLevel, + serviceTier: currentServiceTier, onTaskCreated, environmentId: selectedEnvironment, sandboxEnvironmentId: @@ -511,6 +520,15 @@ export function TaskInput({ [thoughtOption, setConfigOption, setLastUsedReasoningEffort], ); + const handleServiceTierChange = useCallback( + (value: string) => { + if (serviceTierOption) { + setConfigOption(serviceTierOption.id, value); + } + }, + [serviceTierOption, setConfigOption], + ); + const { isOnline } = useConnectivity(); const promptSessionId = sessionId; @@ -785,6 +803,17 @@ export function TaskInput({ /> ) } + speedSelector={ + adapter === "codex" && + effectiveWorkspaceMode !== "cloud" && + !isPreviewLoading ? ( + + ) : null + } getPromptHistory={getPromptHistory} onEmptyChange={handleEditorEmptyChange} onSubmitClick={handleSubmit} diff --git a/apps/code/src/renderer/features/task-detail/hooks/usePreviewConfig.ts b/apps/code/src/renderer/features/task-detail/hooks/usePreviewConfig.ts index 02a0aa2d0..89ec85c20 100644 --- a/apps/code/src/renderer/features/task-detail/hooks/usePreviewConfig.ts +++ b/apps/code/src/renderer/features/task-detail/hooks/usePreviewConfig.ts @@ -14,6 +14,7 @@ interface PreviewConfigResult { modeOption: SessionConfigOption | undefined; modelOption: SessionConfigOption | undefined; thoughtOption: SessionConfigOption | undefined; + serviceTierOption: SessionConfigOption | undefined; isLoading: boolean; setConfigOption: (configId: string, value: string) => void; } @@ -217,12 +218,14 @@ export function usePreviewConfig( const modeOption = getOptionByCategory(configOptions, "mode"); const modelOption = getOptionByCategory(configOptions, "model"); const thoughtOption = getOptionByCategory(configOptions, "thought_level"); + const serviceTierOption = getOptionByCategory(configOptions, "service_tier"); return { configOptions, modeOption, modelOption, thoughtOption, + serviceTierOption, isLoading, setConfigOption, }; diff --git a/apps/code/src/renderer/features/task-detail/hooks/useTaskCreation.ts b/apps/code/src/renderer/features/task-detail/hooks/useTaskCreation.ts index 6169c42ce..8bc31c1be 100644 --- a/apps/code/src/renderer/features/task-detail/hooks/useTaskCreation.ts +++ b/apps/code/src/renderer/features/task-detail/hooks/useTaskCreation.ts @@ -39,6 +39,7 @@ interface UseTaskCreationOptions { adapter?: "claude" | "codex"; model?: string; reasoningLevel?: string; + serviceTier?: string; environmentId?: string | null; sandboxEnvironmentId?: string; signalReportId?: string; @@ -64,6 +65,7 @@ function prepareTaskInput( adapter?: "claude" | "codex"; model?: string; reasoningLevel?: string; + serviceTier?: string; environmentId?: string | null; sandboxEnvironmentId?: string; signalReportId?: string; @@ -93,6 +95,7 @@ function prepareTaskInput( adapter: options.adapter, model: options.model, reasoningLevel: options.reasoningLevel, + serviceTier: options.serviceTier, environmentId: options.environmentId ?? undefined, sandboxEnvironmentId: options.sandboxEnvironmentId, cloudPrAuthorshipMode: @@ -180,6 +183,7 @@ export function useTaskCreation({ adapter, model, reasoningLevel, + serviceTier, environmentId, sandboxEnvironmentId, signalReportId, @@ -229,6 +233,7 @@ export function useTaskCreation({ adapter, model, reasoningLevel, + serviceTier, environmentId, sandboxEnvironmentId, signalReportId, @@ -286,6 +291,7 @@ export function useTaskCreation({ adapter, model, reasoningLevel, + serviceTier, environmentId, sandboxEnvironmentId, signalReportId, diff --git a/apps/code/src/renderer/sagas/task/task-creation.ts b/apps/code/src/renderer/sagas/task/task-creation.ts index 789b91ea9..f09f267eb 100644 --- a/apps/code/src/renderer/sagas/task/task-creation.ts +++ b/apps/code/src/renderer/sagas/task/task-creation.ts @@ -5,6 +5,7 @@ import { useProvisioningStore } from "@features/provisioning/stores/provisioning import { type ConnectParams, getSessionService, + isCodexServiceTier, } from "@features/sessions/service/service"; import { getCloudPromptTransport, @@ -55,6 +56,7 @@ export interface TaskCreationInput { adapter?: "claude" | "codex"; model?: string; reasoningLevel?: string; + serviceTier?: string; environmentId?: string; sandboxEnvironmentId?: string; cloudPrAuthorshipMode?: PrAuthorshipMode; @@ -326,6 +328,9 @@ export class TaskCreationSaga extends Saga< if (input.model) connectParams.model = input.model; if (input.reasoningLevel) connectParams.reasoningLevel = input.reasoningLevel; + if (isCodexServiceTier(input.serviceTier)) { + connectParams.serviceTier = input.serviceTier; + } getSessionService().connectToTask(connectParams); return { taskId: task.id }; diff --git a/packages/agent/src/adapters/codex/codex-agent.refresh.test.ts b/packages/agent/src/adapters/codex/codex-agent.refresh.test.ts index 6f780851b..3c4d1da30 100644 --- a/packages/agent/src/adapters/codex/codex-agent.refresh.test.ts +++ b/packages/agent/src/adapters/codex/codex-agent.refresh.test.ts @@ -110,6 +110,7 @@ type PrivateAgent = { promptRunning: boolean; }; sessionId: string; + currentMcpServers: McpServer[]; sessionState: { sessionId: string; cwd: string; @@ -120,6 +121,7 @@ type PrivateAgent = { cachedWriteTokens: number; }; configOptions: unknown[]; + serviceTier?: string; taskRunId?: string; }; codexProcess: SpawnHandle; @@ -286,4 +288,34 @@ describe("CodexAcpAgent.extMethod refresh_session", () => { expect(spawnedProcesses).toHaveLength(2); expect(createdConnections[1]?.loadSession).toHaveBeenCalled(); }); + + it("applies service tier changes by respawning with the current MCP servers", async () => { + const agent = makeAgent(); + const { priv } = primeSession(agent, "s-4"); + const mcpServers: McpServer[] = [ + { name: "posthog", type: "http", url: "https://mcp", headers: [] }, + ]; + priv.currentMcpServers = mcpServers; + + const response = await agent.setSessionConfigOption({ + sessionId: "s-4", + configId: "service_tier", + value: "fast", + } as never); + + expect(hoisted.spawnCodexProcessMock).toHaveBeenLastCalledWith( + expect.objectContaining({ serviceTier: "fast" }), + ); + expect(createdConnections[1]?.loadSession).toHaveBeenCalledWith({ + sessionId: "s-4", + cwd: "/tmp/repo", + mcpServers, + }); + expect(response.configOptions).toContainEqual( + expect.objectContaining({ + id: "service_tier", + currentValue: "fast", + }), + ); + }); }); diff --git a/packages/agent/src/adapters/codex/codex-agent.test.ts b/packages/agent/src/adapters/codex/codex-agent.test.ts index bc8ad6eef..530e03e33 100644 --- a/packages/agent/src/adapters/codex/codex-agent.test.ts +++ b/packages/agent/src/adapters/codex/codex-agent.test.ts @@ -5,6 +5,7 @@ import type { NewSessionResponse, } from "@agentclientprotocol/sdk"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { CodexProcessOptions } from "./spawn"; const mockCodexConnection = { initialize: vi.fn(), @@ -69,6 +70,7 @@ describe("CodexAcpAgent", () => { overrides: Partial = {}, agentOptions?: { onStructuredOutput?: (output: Record) => Promise; + codexProcessOptions?: Partial; }, ): { agent: CodexAcpAgent; @@ -89,6 +91,7 @@ describe("CodexAcpAgent", () => { const agent = new CodexAcpAgent(client, { codexProcessOptions: { cwd: process.cwd(), + ...agentOptions?.codexProcessOptions, }, onStructuredOutput: agentOptions?.onStructuredOutput, }); @@ -118,6 +121,30 @@ describe("CodexAcpAgent", () => { ).toBe("read-only"); }); + it("adds the Codex service tier option to new sessions", async () => { + const { agent } = createAgent( + {}, + { codexProcessOptions: { serviceTier: "fast" } }, + ); + mockCodexConnection.newSession.mockResolvedValue({ + sessionId: "session-1", + modes: { currentModeId: "auto", availableModes: [] }, + configOptions: [], + } satisfies Partial); + + const response = await agent.newSession({ + cwd: process.cwd(), + } as never); + + expect(response.configOptions).toContainEqual( + expect.objectContaining({ + id: "service_tier", + category: "service_tier", + currentValue: "fast", + }), + ); + }); + it("propagates taskRunId and fires SDK_SESSION when loading a cloud session", async () => { const { agent, client } = createAgent(); mockCodexConnection.loadSession.mockResolvedValue({ diff --git a/packages/agent/src/adapters/codex/codex-agent.ts b/packages/agent/src/adapters/codex/codex-agent.ts index 7a39ad014..480128357 100644 --- a/packages/agent/src/adapters/codex/codex-agent.ts +++ b/packages/agent/src/adapters/codex/codex-agent.ts @@ -33,6 +33,7 @@ import { RequestError, type ResumeSessionRequest, type ResumeSessionResponse, + type SessionConfigOption, type SetSessionConfigOptionRequest, type SetSessionConfigOptionResponse, type SetSessionModeRequest, @@ -101,6 +102,31 @@ interface NewSessionMeta { jsonSchema?: Record | null; } +type CodexServiceTier = "standard" | "fast" | "flex"; + +const CODEX_SERVICE_TIER_CONFIG_ID = "service_tier"; +const CODEX_SERVICE_TIER_OPTIONS: Array<{ + value: CodexServiceTier; + name: string; + description: string; +}> = [ + { + value: "standard", + name: "Standard", + description: "Default Codex service tier", + }, + { + value: "fast", + name: "Fast", + description: "Request Codex fast mode for lower latency", + }, + { + value: "flex", + name: "Flex", + description: "Request Codex flex mode", + }, +]; + export interface CodexAcpAgentOptions { codexProcessOptions: CodexProcessOptions; processCallbacks?: ProcessSpawnedCallback; @@ -120,6 +146,38 @@ function toCodexPermissionMode(mode?: string): PermissionMode { return "auto"; } +function toCodexServiceTier(serviceTier?: string | null): CodexServiceTier { + switch (serviceTier?.toLowerCase()) { + case "fast": + case "priority": + return "fast"; + case "flex": + return "flex"; + default: + return "standard"; + } +} + +function codexServiceTierConfigValue( + serviceTier: CodexServiceTier, +): string | undefined { + return serviceTier === "standard" ? undefined : serviceTier; +} + +function codexServiceTierConfigOption( + currentValue: CodexServiceTier, +): SessionConfigOption { + return { + id: CODEX_SERVICE_TIER_CONFIG_ID, + name: "Speed", + type: "select", + category: "service_tier", + currentValue, + options: CODEX_SERVICE_TIER_OPTIONS, + description: "Choose the Codex service tier for new turns", + } as SessionConfigOption; +} + /** * Prepend `_meta.prContext` (set by the agent-server on Slack-originated * follow-up runs) to the prompt as a text block, mirroring Claude's @@ -229,6 +287,7 @@ export class CodexAcpAgent extends BaseAcpAgent { private readonly onStructuredOutput?: ( output: Record, ) => Promise; + private currentMcpServers: McpServer[] = []; // Snapshot of the initialize() request so refreshSession can replay the // same handshake against a respawned codex-acp subprocess. private lastInitRequest?: InitializeRequest; @@ -326,9 +385,10 @@ export class CodexAcpAgent extends BaseAcpAgent { const injectedParams = this.applyStructuredOutput(params, meta); const response = await this.codexConnection.newSession(injectedParams); - response.configOptions = normalizeCodexConfigOptions( + response.configOptions = this.normalizeConfigOptions( response.configOptions, ); + this.currentMcpServers = injectedParams.mcpServers ?? []; // Initialize session state this.sessionState = createSessionState(response.sessionId, params.cwd, { @@ -337,6 +397,7 @@ export class CodexAcpAgent extends BaseAcpAgent { modeId: response.modes?.currentModeId ?? "auto", modelId: response.models?.currentModelId, permissionMode: requestedPermissionMode, + serviceTier: this.currentServiceTier(), }); this.sessionId = response.sessionId; this.sessionState.configOptions = response.configOptions ?? []; @@ -368,9 +429,10 @@ export class CodexAcpAgent extends BaseAcpAgent { const meta = params._meta as NewSessionMeta | undefined; const injectedParams = this.applyStructuredOutput(params, meta); const response = await this.codexConnection.loadSession(injectedParams); - response.configOptions = normalizeCodexConfigOptions( + response.configOptions = this.normalizeConfigOptions( response.configOptions, ); + this.currentMcpServers = injectedParams.mcpServers ?? []; const currentPermissionMode = getCurrentPermissionMode( response.modes?.currentModeId, meta?.permissionMode, @@ -385,6 +447,7 @@ export class CodexAcpAgent extends BaseAcpAgent { taskId: meta?.taskId ?? meta?.persistence?.taskId, modeId: response.modes?.currentModeId ?? "auto", permissionMode: currentPermissionMode, + serviceTier: this.currentServiceTier(), }); this.sessionId = params.sessionId; this.sessionState.configOptions = response.configOptions ?? []; @@ -416,9 +479,10 @@ export class CodexAcpAgent extends BaseAcpAgent { // codex-acp doesn't support resume natively, use loadSession instead const loadResponse = await this.codexConnection.loadSession(injectedParams); - loadResponse.configOptions = normalizeCodexConfigOptions( + loadResponse.configOptions = this.normalizeConfigOptions( loadResponse.configOptions, ); + this.currentMcpServers = injectedParams.mcpServers ?? []; const currentPermissionMode = getCurrentPermissionMode( loadResponse.modes?.currentModeId, meta?.permissionMode, @@ -428,6 +492,7 @@ export class CodexAcpAgent extends BaseAcpAgent { taskId: meta?.taskId ?? meta?.persistence?.taskId, modeId: loadResponse.modes?.currentModeId ?? "auto", permissionMode: currentPermissionMode, + serviceTier: this.currentServiceTier(), }); this.sessionId = params.sessionId; this.sessionState.configOptions = loadResponse.configOptions ?? []; @@ -462,9 +527,10 @@ export class CodexAcpAgent extends BaseAcpAgent { // Create a new session via codex-acp (fork isn't natively supported) const newResponse = await this.codexConnection.newSession(injectedParams); - newResponse.configOptions = normalizeCodexConfigOptions( + newResponse.configOptions = this.normalizeConfigOptions( newResponse.configOptions, ); + this.currentMcpServers = injectedParams.mcpServers ?? []; const requestedPermissionMode = toCodexPermissionMode(meta?.permissionMode); this.sessionState = createSessionState(newResponse.sessionId, params.cwd, { @@ -472,6 +538,7 @@ export class CodexAcpAgent extends BaseAcpAgent { taskId: meta?.taskId ?? meta?.persistence?.taskId, modeId: newResponse.modes?.currentModeId ?? "auto", permissionMode: requestedPermissionMode, + serviceTier: this.currentServiceTier(), }); this.sessionId = newResponse.sessionId; this.sessionState.configOptions = newResponse.configOptions ?? []; @@ -485,6 +552,38 @@ export class CodexAcpAgent extends BaseAcpAgent { return newResponse; } + private currentServiceTier(): CodexServiceTier { + return toCodexServiceTier(this.codexProcessOptions.serviceTier); + } + + private withCodexServiceTierOption( + configOptions: SessionConfigOption[] | null | undefined, + ): SessionConfigOption[] { + const serviceTierOption = codexServiceTierConfigOption( + this.currentServiceTier(), + ); + const options = configOptions ?? []; + const existingIndex = options.findIndex( + (option) => option.id === CODEX_SERVICE_TIER_CONFIG_ID, + ); + + if (existingIndex === -1) { + return [...options, serviceTierOption]; + } + + return options.map((option, index) => + index === existingIndex ? serviceTierOption : option, + ); + } + + private normalizeConfigOptions( + configOptions: SessionConfigOption[] | null | undefined, + ): SessionConfigOption[] { + return this.withCodexServiceTierOption( + normalizeCodexConfigOptions(configOptions) ?? [], + ); + } + /** * When the caller wires up `onStructuredOutput` and provides a JSON schema * via `_meta.jsonSchema`, inject the stdio MCP server that exposes @@ -751,6 +850,7 @@ export class CodexAcpAgent extends BaseAcpAgent { cwd: this.sessionState.cwd, mcpServers, }); + this.currentMcpServers = mcpServers; // Swap everything at once so closeSession/prompt/cancel target the new // subprocess going forward. Preserve sessionState (accumulatedUsage, @@ -778,12 +878,50 @@ export class CodexAcpAgent extends BaseAcpAgent { return response ?? {}; } + private async setServiceTier(value: string): Promise { + const previousTier = this.currentServiceTier(); + const nextTier = toCodexServiceTier(value); + if (previousTier === nextTier) { + this.sessionState.serviceTier = nextTier; + this.sessionState.configOptions = this.withCodexServiceTierOption( + this.sessionState.configOptions, + ); + return this.sessionState.configOptions; + } + + const previousConfigValue = this.codexProcessOptions.serviceTier; + this.codexProcessOptions.serviceTier = + codexServiceTierConfigValue(nextTier); + + try { + await this.refreshSession(this.currentMcpServers); + } catch (error) { + this.codexProcessOptions.serviceTier = previousConfigValue; + this.sessionState.serviceTier = previousTier; + this.sessionState.configOptions = this.withCodexServiceTierOption( + this.sessionState.configOptions, + ); + throw error; + } + + this.sessionState.serviceTier = nextTier; + this.sessionState.configOptions = this.withCodexServiceTierOption( + this.sessionState.configOptions, + ); + return this.sessionState.configOptions; + } + async setSessionConfigOption( params: SetSessionConfigOptionRequest, ): Promise { + if (params.configId === CODEX_SERVICE_TIER_CONFIG_ID) { + const configOptions = await this.setServiceTier(String(params.value)); + return { configOptions }; + } + const response = await this.codexConnection.setSessionConfigOption(params); if (response.configOptions) { - response.configOptions = normalizeCodexConfigOptions( + response.configOptions = this.normalizeConfigOptions( response.configOptions, ) as typeof response.configOptions; this.sessionState.configOptions = response.configOptions; diff --git a/packages/agent/src/adapters/codex/session-state.ts b/packages/agent/src/adapters/codex/session-state.ts index 1c68ee8d4..26dad6667 100644 --- a/packages/agent/src/adapters/codex/session-state.ts +++ b/packages/agent/src/adapters/codex/session-state.ts @@ -23,6 +23,7 @@ export interface CodexSessionState { contextSize?: number; contextUsed?: number; permissionMode: PermissionMode; + serviceTier?: string; taskRunId?: string; taskId?: string; } @@ -36,6 +37,7 @@ export function createSessionState( modeId?: string; modelId?: string; permissionMode?: PermissionMode; + serviceTier?: string; }, ): CodexSessionState { return { @@ -51,6 +53,7 @@ export function createSessionState( cachedWriteTokens: 0, }, permissionMode: opts?.permissionMode ?? "auto", + serviceTier: opts?.serviceTier, taskRunId: opts?.taskRunId, taskId: opts?.taskId, }; diff --git a/packages/agent/src/adapters/codex/spawn.ts b/packages/agent/src/adapters/codex/spawn.ts index 6f3fbd695..0c07a2a4d 100644 --- a/packages/agent/src/adapters/codex/spawn.ts +++ b/packages/agent/src/adapters/codex/spawn.ts @@ -12,6 +12,7 @@ export interface CodexProcessOptions { apiKey?: string; model?: string; reasoningEffort?: string; + serviceTier?: string; instructions?: string; binaryPath?: string; logger?: Logger; @@ -26,6 +27,14 @@ export interface CodexProcess { kill: () => void; } +function normalizeServiceTier(value: string | undefined): string | undefined { + const normalized = value?.toLowerCase(); + if (!normalized || normalized === "standard") return undefined; + if (normalized === "fast" || normalized === "priority") return "fast"; + if (normalized === "flex") return "flex"; + return undefined; +} + function buildConfigArgs(options: CodexProcessOptions): string[] { const args: string[] = []; @@ -57,6 +66,11 @@ function buildConfigArgs(options: CodexProcessOptions): string[] { args.push("-c", `model_reasoning_effort="${options.reasoningEffort}"`); } + const serviceTier = normalizeServiceTier(options.serviceTier); + if (serviceTier) { + args.push("-c", `service_tier="${serviceTier}"`); + } + if (options.instructions) { const escaped = options.instructions .replace(/\\/g, "\\\\") diff --git a/packages/agent/src/agent.ts b/packages/agent/src/agent.ts index f66b0fd74..97f6d7fdb 100644 --- a/packages/agent/src/agent.ts +++ b/packages/agent/src/agent.ts @@ -135,6 +135,7 @@ export class Agent { apiKey: gatewayConfig.apiKey, binaryPath: options.codexBinaryPath, model: sanitizedModel, + serviceTier: options.serviceTier, instructions: options.instructions, } : undefined, diff --git a/packages/agent/src/server/agent-server.ts b/packages/agent/src/server/agent-server.ts index 12ea84c47..a7fdc4be7 100644 --- a/packages/agent/src/server/agent-server.ts +++ b/packages/agent/src/server/agent-server.ts @@ -875,6 +875,7 @@ export class AgentServer { apiKey: this.config.apiKey, model: this.config.model ?? DEFAULT_CODEX_MODEL, reasoningEffort: this.config.reasoningEffort, + serviceTier: this.config.serviceTier, instructions: codexInstructions, } : undefined, diff --git a/packages/agent/src/server/bin.ts b/packages/agent/src/server/bin.ts index d72a91ba3..77fc8c5d9 100644 --- a/packages/agent/src/server/bin.ts +++ b/packages/agent/src/server/bin.ts @@ -32,6 +32,7 @@ const envSchema = z.object({ POSTHOG_CODE_REASONING_EFFORT: z .enum(["low", "medium", "high", "xhigh", "max"]) .optional(), + POSTHOG_CODE_SERVICE_TIER: z.enum(["standard", "fast", "flex"]).optional(), }); const program = new Command(); @@ -163,6 +164,7 @@ program runtimeAdapter: env.POSTHOG_CODE_RUNTIME_ADAPTER, model: env.POSTHOG_CODE_MODEL, reasoningEffort: env.POSTHOG_CODE_REASONING_EFFORT, + serviceTier: env.POSTHOG_CODE_SERVICE_TIER, }); process.on("SIGINT", async () => { diff --git a/packages/agent/src/server/types.ts b/packages/agent/src/server/types.ts index 10cf96fc7..e6092595e 100644 --- a/packages/agent/src/server/types.ts +++ b/packages/agent/src/server/types.ts @@ -27,4 +27,5 @@ export interface AgentServerConfig { runtimeAdapter?: "claude" | "codex"; model?: string; reasoningEffort?: "low" | "medium" | "high" | "xhigh" | "max"; + serviceTier?: string; } diff --git a/packages/agent/src/types.ts b/packages/agent/src/types.ts index 18e5572c0..567e87c5e 100644 --- a/packages/agent/src/types.ts +++ b/packages/agent/src/types.ts @@ -121,6 +121,7 @@ export interface TaskExecutionOptions { gatewayUrl?: string; codexBinaryPath?: string; instructions?: string; + serviceTier?: string; processCallbacks?: ProcessSpawnedCallback; /** Callback invoked when the agent calls the create_output tool for structured output */ onStructuredOutput?: (output: Record) => Promise; From e84f91f563e3b00ba24c466dfa19f44b37de6fab Mon Sep 17 00:00:00 2001 From: Chris Volzer Date: Fri, 8 May 2026 12:52:42 -0700 Subject: [PATCH 2/2] Send Codex service tier to cloud runs --- apps/code/src/renderer/api/posthogClient.test.ts | 4 ++++ apps/code/src/renderer/api/posthogClient.ts | 5 +++++ .../features/sessions/service/service.test.ts | 9 +++++++++ .../renderer/features/sessions/service/service.ts | 14 +++++++++++++- .../features/task-detail/components/TaskInput.tsx | 8 ++------ .../src/renderer/sagas/task/task-creation.test.ts | 4 ++++ apps/code/src/renderer/sagas/task/task-creation.ts | 4 ++++ 7 files changed, 41 insertions(+), 7 deletions(-) diff --git a/apps/code/src/renderer/api/posthogClient.test.ts b/apps/code/src/renderer/api/posthogClient.test.ts index f684aa266..00a03bf57 100644 --- a/apps/code/src/renderer/api/posthogClient.test.ts +++ b/apps/code/src/renderer/api/posthogClient.test.ts @@ -25,6 +25,7 @@ describe("PostHogAPIClient", () => { adapter: "codex", model: "gpt-5.4", reasoningLevel: "high", + serviceTier: "fast", }); expect(post).toHaveBeenCalledWith( @@ -37,6 +38,7 @@ describe("PostHogAPIClient", () => { runtime_adapter: "codex", model: "gpt-5.4", reasoning_effort: "high", + service_tier: "fast", }), }), ); @@ -154,6 +156,7 @@ describe("PostHogAPIClient", () => { adapter: "codex", model: "gpt-5.4", reasoningLevel: "high", + serviceTier: "fast", initialPermissionMode: "auto", }), ).resolves.toEqual({ id: "run-123", environment: "cloud" }); @@ -169,6 +172,7 @@ describe("PostHogAPIClient", () => { runtime_adapter: "codex", model: "gpt-5.4", reasoning_effort: "high", + service_tier: "fast", initial_permission_mode: "auto", environment: "cloud", }), diff --git a/apps/code/src/renderer/api/posthogClient.ts b/apps/code/src/renderer/api/posthogClient.ts index cf3595faa..10a881de0 100644 --- a/apps/code/src/renderer/api/posthogClient.ts +++ b/apps/code/src/renderer/api/posthogClient.ts @@ -157,11 +157,13 @@ export interface FinalizedTaskArtifactUpload { } type CloudRuntimeAdapter = "claude" | "codex"; +type CloudRunServiceTier = "standard" | "fast" | "flex"; interface CloudRunOptions { adapter?: CloudRuntimeAdapter; model?: string; reasoningLevel?: string; + serviceTier?: CloudRunServiceTier; sandboxEnvironmentId?: string; prAuthorshipMode?: PrAuthorshipMode; runSource?: CloudRunSource; @@ -233,6 +235,9 @@ function buildCloudRunRequestBody( if (options?.sandboxEnvironmentId) { body.sandbox_environment_id = options.sandboxEnvironmentId; } + if (options?.serviceTier) { + body.service_tier = options.serviceTier; + } if (options?.prAuthorshipMode) { body.pr_authorship_mode = options.prAuthorshipMode; } diff --git a/apps/code/src/renderer/features/sessions/service/service.test.ts b/apps/code/src/renderer/features/sessions/service/service.test.ts index 5ffe3ebf2..53860654a 100644 --- a/apps/code/src/renderer/features/sessions/service/service.test.ts +++ b/apps/code/src/renderer/features/sessions/service/service.test.ts @@ -2489,6 +2489,14 @@ describe("SessionService", () => { currentValue: "high", options: [], }, + { + id: "service_tier", + name: "Speed", + type: "select", + category: "service_tier", + currentValue: "fast", + options: [], + }, ], }), ); @@ -2553,6 +2561,7 @@ describe("SessionService", () => { adapter: "codex", model: "gpt-5.4", reasoningLevel: "high", + serviceTier: "fast", resumeFromRunId: "run-123", }), ); diff --git a/apps/code/src/renderer/features/sessions/service/service.ts b/apps/code/src/renderer/features/sessions/service/service.ts index 4bc817dc3..059c8ba32 100644 --- a/apps/code/src/renderer/features/sessions/service/service.ts +++ b/apps/code/src/renderer/features/sessions/service/service.ts @@ -1955,6 +1955,7 @@ export class SessionService { adapter: runtimeOptions.adapter, model: runtimeOptions.model, reasoningLevel: runtimeOptions.reasoningLevel, + serviceTier: runtimeOptions.serviceTier, resumeFromRunId: session.taskRunId, pendingUserMessage: transport.messageText, pendingUserArtifactIds: @@ -2547,7 +2548,10 @@ export class SessionService { const previewOptions = await pending; const extras = previewOptions .filter( - (opt) => opt.category === "model" || opt.category === "thought_level", + (opt) => + opt.category === "model" || + opt.category === "thought_level" || + opt.category === "service_tier", ) .map((opt) => { if ( @@ -3361,6 +3365,7 @@ export class SessionService { adapter?: Adapter; model?: string; reasoningLevel?: string; + serviceTier?: CodexServiceTier; } { const modelOption = getConfigOptionByCategory( session.configOptions, @@ -3370,6 +3375,10 @@ export class SessionService { session.configOptions, "thought_level", ); + const serviceTierOption = getConfigOptionByCategory( + session.configOptions, + "service_tier", + ); return { adapter: session.adapter ?? previousRun?.runtime_adapter ?? undefined, @@ -3381,6 +3390,9 @@ export class SessionService { typeof thoughtLevelOption?.currentValue === "string" ? thoughtLevelOption.currentValue : (previousRun?.reasoning_effort ?? undefined), + serviceTier: isCodexServiceTier(serviceTierOption?.currentValue) + ? serviceTierOption.currentValue + : undefined, }; } diff --git a/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx b/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx index fb2a3d9fa..fbf34f03a 100644 --- a/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx +++ b/apps/code/src/renderer/features/task-detail/components/TaskInput.tsx @@ -459,9 +459,7 @@ export function TaskInput({ const currentReasoningLevel = thoughtOption?.type === "select" ? thoughtOption.currentValue : undefined; const currentServiceTier = - adapter === "codex" && - effectiveWorkspaceMode !== "cloud" && - serviceTierOption?.type === "select" + adapter === "codex" && serviceTierOption?.type === "select" ? serviceTierOption.currentValue : undefined; @@ -804,9 +802,7 @@ export function TaskInput({ ) } speedSelector={ - adapter === "codex" && - effectiveWorkspaceMode !== "cloud" && - !isPreviewLoading ? ( + adapter === "codex" && !isPreviewLoading ? ( ({ connectToTask: vi.fn(), disconnectFromTask: vi.fn(), }), + isCodexServiceTier: (value: unknown) => + value === "standard" || value === "fast" || value === "flex", })); vi.mock("@renderer/utils/generateTitle", () => ({ @@ -137,6 +139,7 @@ describe("TaskCreationSaga", () => { adapter: "codex", model: "gpt-5.4", reasoningLevel: "high", + serviceTier: "fast", }); expect(result.success).toBe(true); @@ -151,6 +154,7 @@ describe("TaskCreationSaga", () => { adapter: "codex", model: "gpt-5.4", reasoningLevel: "high", + serviceTier: "fast", sandboxEnvironmentId: undefined, prAuthorshipMode: "user", runSource: "manual", diff --git a/apps/code/src/renderer/sagas/task/task-creation.ts b/apps/code/src/renderer/sagas/task/task-creation.ts index f09f267eb..16449eda7 100644 --- a/apps/code/src/renderer/sagas/task/task-creation.ts +++ b/apps/code/src/renderer/sagas/task/task-creation.ts @@ -236,6 +236,9 @@ export class TaskCreationSaga extends Saga< name: "cloud_run", execute: async () => { const prAuthorshipMode = input.cloudPrAuthorshipMode ?? "user"; + const serviceTier = isCodexServiceTier(input.serviceTier) + ? input.serviceTier + : undefined; const transport = (input.content || input.filePaths?.length) && @@ -249,6 +252,7 @@ export class TaskCreationSaga extends Saga< adapter: input.adapter, model: input.model, reasoningLevel: input.reasoningLevel, + ...(serviceTier ? { serviceTier } : {}), sandboxEnvironmentId: input.sandboxEnvironmentId, prAuthorshipMode, runSource: input.cloudRunSource ?? "manual",