diff --git a/packages/app/e2e/browser/codex-speed.spec.ts b/packages/app/e2e/browser/codex-speed.spec.ts new file mode 100644 index 0000000000..654f15207d --- /dev/null +++ b/packages/app/e2e/browser/codex-speed.spec.ts @@ -0,0 +1,122 @@ +import path from "node:path"; +import { expect, test, type Page } from "../support/fixtures"; +import { seedWorkspace } from "../support/helpers/seed-client"; +import { openAgentRoute } from "../support/helpers/mock-agent"; +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import { connectDaemonClient } from "../support/helpers/daemon-client-loader"; + +// This fixture catalog advertises Ultrafast on Sol; another model offers Fast only. +// The real daemon discovers both through the same Codex model/list boundary. +test.use({ + e2eDaemonConfig: { + version: 1, + agents: { + providers: { + codex: { + enabled: true, + command: [process.execPath, path.resolve("e2e/fixtures/catalog-codex.mjs")], + }, + claude: { enabled: false }, + copilot: { enabled: false }, + opencode: { enabled: false }, + pi: { enabled: false }, + omp: { enabled: false }, + }, + }, + }, +}); + +async function openSpeedSelector(page: Page): Promise { + const speed = page.getByRole("button", { name: /^(Select speed|Speed: .+)$/ }); + const features = page.getByRole("button", { name: "Open agent features", exact: true }); + const modelSettings = page.getByRole("button", { name: /^Select model \(/ }); + await expect(speed.or(features).or(modelSettings).filter({ visible: true }).first()).toBeVisible({ + timeout: 30_000, + }); + if (!(await speed.isVisible())) { + if (await features.isVisible()) await features.click(); + else await modelSettings.click(); + } + await expect(speed).toBeVisible({ timeout: 30_000 }); + await speed.click(); + await expect(page.getByText("Normal", { exact: true }).last()).toBeVisible(); +} + +async function selectSpeed(page: Page, label: string): Promise { + await page.getByRole("button", { name: label, exact: true }).last().click(); + const toolbarTrigger = page.getByRole("button", { name: `Speed: ${label}`, exact: true }); + const sheetTrigger = page.getByRole("button", { name: "Select speed", exact: true }); + await expect(toolbarTrigger.or(sheetTrigger)).toBeVisible(); + if (await toolbarTrigger.isVisible()) await expect(toolbarTrigger).toHaveText(""); + else await expect(sheetTrigger).toContainText(label); +} + +async function expectSpeedChoices(page: Page, choices: string[]): Promise { + for (const choice of ["Normal", "Fast", "Ultrafast"]) { + const option = page.getByText(choice, { exact: true }).last(); + if (choices.includes(choice)) { + await expect(option).toBeVisible(); + await expect(option).toBeInViewport({ ratio: 1 }); + } else await expect(option).not.toBeVisible(); + } +} + +async function switchAgentModel(agentId: string, modelId: string): Promise { + const client = await connectDaemonClient< + Pick + >({ + clientIdPrefix: "codex-speed-model", + }); + try { + await client.setAgentModel(agentId, modelId); + } finally { + await client.close(); + } +} + +for (const viewport of [ + { name: "desktop", width: 1280, height: 850 }, + { name: "compact", width: 390, height: 844 }, +]) { + test(`Codex catalog drives speed choices on ${viewport.name}`, async ({ page }, testInfo) => { + test.setTimeout(180_000); + await page.setViewportSize(viewport); + const workspace = await seedWorkspace({ repoPrefix: "codex-speed-" }); + try { + const agent = await workspace.client.createAgent({ + provider: "codex", + cwd: workspace.repoPath, + workspaceId: workspace.workspaceId, + title: "Speed settings", + model: "gpt-6.1-sol", + modeId: "full-access", + initialPrompt: "Get ready to work on this project.", + }); + await openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: agent.id }); + if (viewport.name === "desktop") { + const trigger = page.getByRole("button", { name: "Speed: Normal", exact: true }); + await expect(trigger).toBeVisible({ timeout: 30_000 }); + await expect(trigger).toHaveText(""); + await page.screenshot({ path: testInfo.outputPath("desktop-normal-trigger.png") }); + } + await openSpeedSelector(page); + await expectSpeedChoices(page, ["Normal", "Fast", "Ultrafast"]); + await page.screenshot({ path: testInfo.outputPath(`${viewport.name}-speed-options.png`) }); + await selectSpeed(page, "Ultrafast"); + await openSpeedSelector(page); + await expectSpeedChoices(page, ["Normal", "Fast", "Ultrafast"]); + await page.screenshot({ + path: testInfo.outputPath(`${viewport.name}-ultrafast-selected.png`), + }); + await selectSpeed(page, "Normal"); + if (viewport.name === "desktop") { + await switchAgentModel(agent.id, "gpt-6-sol"); + await openSpeedSelector(page); + await expectSpeedChoices(page, ["Normal", "Fast"]); + await page.screenshot({ path: testInfo.outputPath("desktop-fast-only.png") }); + } + } finally { + await workspace.cleanup(); + } + }); +} diff --git a/packages/app/e2e/fixtures/catalog-codex.mjs b/packages/app/e2e/fixtures/catalog-codex.mjs new file mode 100644 index 0000000000..1b3826cb30 --- /dev/null +++ b/packages/app/e2e/fixtures/catalog-codex.mjs @@ -0,0 +1,88 @@ +import readline from "node:readline"; +import { randomUUID } from "node:crypto"; + +const fast = { id: "priority", name: "Fast", description: "Faster processing, increased usage" }; +const ultra = { + id: "ultrafast", + name: "Ultrafast", + description: "Lowest latency, increased usage", +}; +const models = [ + { id: "gpt-6.1-sol", displayName: "GPT-6.1 Sol", isDefault: true, serviceTiers: [fast, ultra] }, + { id: "gpt-6-sol", displayName: "GPT-6 Sol", serviceTiers: [fast] }, +].map((model) => + Object.assign(model, { + model: model.id, + defaultReasoningEffort: "medium", + supportedReasoningEfforts: [{ reasoningEffort: "medium", description: "Medium" }], + }), +); + +function send(message) { + process.stdout.write(JSON.stringify(message) + "\n"); +} +function respond(method, params) { + switch (method) { + case "initialize": + return {}; + case "model/list": + return { data: models }; + case "config/read": + return { config: { model: models[0].id, model_reasoning_effort: "medium" } }; + case "collaborationMode/list": + return { data: [] }; + case "skills/list": + return { data: [] }; + case "thread/list": + case "thread/loaded/list": + return { data: [] }; + case "thread/start": + return { thread: { id: randomUUID() }, model: params.model }; + case "thread/resume": + return { thread: { id: params.threadId } }; + case "thread/read": + return { thread: { id: params.threadId, turns: [] } }; + case "turn/start": { + const turnId = randomUUID(); + setTimeout(() => { + send({ + method: "turn/started", + params: { threadId: params.threadId, turn: { id: turnId } }, + }); + send({ + method: "item/completed", + params: { + threadId: params.threadId, + turnId, + item: { + id: randomUUID(), + type: "agentMessage", + text: "Ready to work on this project.", + }, + }, + }); + send({ + method: "turn/completed", + params: { threadId: params.threadId, turn: { id: turnId, status: "completed" } }, + }); + }, 50); + return { turn: { id: turnId } }; + } + default: + throw new Error(`Unsupported Codex fixture request: ${method}`); + } +} + +if (process.argv.includes("--version")) { + console.log("codex-cli 0.159.0"); +} else { + readline.createInterface({ input: process.stdin }).on("line", (line) => { + const request = JSON.parse(line); + if (request.id === undefined) return; + try { + send({ id: request.id, result: respond(request.method, request.params ?? {}) }); + } catch (error) { + send({ id: request.id, error: { code: -32601, message: error.message } }); + } + }); +} diff --git a/packages/app/src/composer/agent-controls/index.tsx b/packages/app/src/composer/agent-controls/index.tsx index f873b42148..8dadeec217 100644 --- a/packages/app/src/composer/agent-controls/index.tsx +++ b/packages/app/src/composer/agent-controls/index.tsx @@ -1357,6 +1357,10 @@ function DesktopFeatureItem({ if (feature.type === "select") { const FeatureIcon = getAgentFeatureIcon(feature.icon); const selectedOption = feature.options.find((o) => o.id === feature.value); + const iconOnly = feature.desktopTrigger === "icon"; + const tooltip = iconOnly + ? `${feature.label}: ${selectedOption?.label ?? feature.label}` + : getFeatureTooltip(feature); return ( <> @@ -1367,15 +1371,16 @@ function DesktopFeatureItem({ surface="toolbar" label={feature.label} value={selectedOption?.label ?? feature.label} + showToolbarLabel={!iconOnly} open={openSelector === featureSelector} disabled={disabled} onPress={handleSelectPress} - accessibilityLabel={getFeatureTooltip(feature)} + accessibilityLabel={tooltip} testID={`agent-feature-${feature.id}`} /> - {getFeatureTooltip(feature)} + {tooltip} { expect(parsed.value).toBe("flex"); }); + it.each(["icon", "label"])( + "preserves the %s desktop trigger for any select feature", + (desktopTrigger) => { + const feature = { + type: "select", + id: "custom-feature", + label: "Custom setting", + icon: "gauge", + value: "first", + options: [{ id: "first", label: "First" }], + desktopTrigger, + }; + expect(AgentFeatureSchema.parse(feature)).toEqual(feature); + }, + ); + it("rejects invalid features", () => { const invalidDiscriminator = AgentFeatureSchema.safeParse({ type: "slider", diff --git a/packages/protocol/src/agent-types.ts b/packages/protocol/src/agent-types.ts index ea412c16be..f86dc63575 100644 --- a/packages/protocol/src/agent-types.ts +++ b/packages/protocol/src/agent-types.ts @@ -145,6 +145,8 @@ export interface AgentFeatureSelect { description?: string; tooltip?: string; icon?: string; + /** Desktop toolbar presentation; omitted means labeled. Mobile selectors stay labeled. */ + desktopTrigger?: "icon" | "label"; value: string | null; options: AgentSelectOption[]; } diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index a716d45893..589137d720 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -301,6 +301,7 @@ export const AgentFeatureSelectSchema = z.object({ description: z.string().optional(), tooltip: z.string().optional(), icon: z.string().optional(), + desktopTrigger: z.enum(["icon", "label"]).optional(), value: z.string().nullable(), options: z.array(AgentSelectOptionSchema), }); diff --git a/packages/server/src/server/agent/agent-sdk-types.ts b/packages/server/src/server/agent/agent-sdk-types.ts index d42d98aaf7..effd5157c1 100644 --- a/packages/server/src/server/agent/agent-sdk-types.ts +++ b/packages/server/src/server/agent/agent-sdk-types.ts @@ -1,4 +1,7 @@ import type { + AgentFeature, + AgentFeatureSelect, + AgentFeatureToggle, AgentProviderNotice, AgentTaskItem, JsonValue, @@ -8,7 +11,13 @@ import type { import type { AgentAttachment } from "@getpaseo/protocol/messages"; import type { PaseoToolCatalog } from "./tools/types.js"; -export type { AgentProviderNotice, AgentTaskItem }; +export type { + AgentFeature, + AgentFeatureSelect, + AgentFeatureToggle, + AgentProviderNotice, + AgentTaskItem, +}; export type AgentProvider = string; @@ -156,29 +165,6 @@ export interface AgentCreateConfigUnattendedInput { availableModes: AgentMode[]; } -export interface AgentFeatureToggle { - type: "toggle"; - id: string; - label: string; - description?: string; - tooltip?: string; - icon?: string; - value: boolean; -} - -export interface AgentFeatureSelect { - type: "select"; - id: string; - label: string; - description?: string; - tooltip?: string; - icon?: string; - value: string | null; - options: AgentSelectOption[]; -} - -export type AgentFeature = AgentFeatureToggle | AgentFeatureSelect; - export interface AgentCapabilityFlags { [capability: string]: boolean | undefined; supportsStreaming: boolean; diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.features.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.features.test.ts index 2e20f5f6f1..8f3f8fc76d 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.features.test.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.features.test.ts @@ -19,6 +19,41 @@ interface CollaborationModeRecord { developer_instructions?: string | null; } +const TEST_SPEED_MODELS = [ + "gpt-6-astra", + "gpt-6.1-sol", + "gpt-6-sol", + "gpt-6-luna", + "gpt-5.6", + "gpt-5.6-sol", + "gpt-5.6-terra", + "gpt-5.6-luna", + "gpt-5.5", + "gpt-5.4", +].map((id) => ({ + id, + isDefault: id === "gpt-5.4", + defaultReasoningEffort: "medium", + serviceTiers: [{ id: "priority", name: "Fast", description: "Faster processing" }], +})); + +function speedFeature(value: string) { + return { + type: "select", + id: "service_tier", + label: "Speed", + description: "Choose processing speed. Faster tiers increase usage.", + tooltip: "Select speed", + icon: "zap", + desktopTrigger: "icon", + value, + options: [ + { id: "default", label: "Normal", isDefault: true }, + { id: "priority", label: "Fast" }, + ], + }; +} + const TEST_COLLABORATION_MODES: CollaborationModeRecord[] = [ { name: "Code", @@ -65,7 +100,7 @@ function createConfig(overrides: Partial = {}): AgentSession function createSessionHarness( configOverrides: Partial = {}, - options: { logger?: pino.Logger } = {}, + options: { logger?: pino.Logger; models?: unknown[] } = {}, ): { session: CodexFeaturesTestSession; appServer: FakeCodexAppServer; @@ -73,6 +108,7 @@ function createSessionHarness( const config = createConfig(configOverrides); const appServer = createFakeCodexAppServer({ "collaborationMode/list": () => ({ data: TEST_COLLABORATION_MODES }), + "model/list": () => ({ data: options.models ?? TEST_SPEED_MODELS }), }); const session = new CodexAppServerAgentSession( { ...config, provider: CODEX_PROVIDER }, @@ -85,7 +121,7 @@ function createSessionHarness( async function createConnectedSession( configOverrides: Partial = {}, - options: { logger?: pino.Logger } = {}, + options: { logger?: pino.Logger; models?: unknown[] } = {}, ): Promise<{ session: CodexFeaturesTestSession; appServer: FakeCodexAppServer; @@ -97,8 +133,169 @@ async function createConnectedSession( } describe("Codex app-server provider features", () => { + test("GPT-6.1 Sol offers catalog speed tiers and sends Ultrafast", async () => { + const { session, appServer } = await createConnectedSession( + { model: "gpt-6.1-sol" }, + { + models: [ + { + id: "gpt-6.1-sol", + serviceTiers: [ + { id: "priority", name: "Fast", description: "Faster processing" }, + { id: "ultrafast", name: "Ultrafast", description: "Lowest latency" }, + ], + }, + ], + }, + ); + try { + expect(session.features).toContainEqual( + expect.objectContaining({ + id: "service_tier", + type: "select", + value: "default", + options: [ + { id: "default", label: "Normal", isDefault: true }, + { id: "priority", label: "Fast" }, + { id: "ultrafast", label: "Ultrafast" }, + ], + }), + ); + await session.setFeature?.("service_tier", "ultrafast"); + await session.startTurn("hello"); + await expect(appServer.waitForTurnStart()).resolves.toMatchObject({ + serviceTier: "ultrafast", + }); + } finally { + await session.close(); + } + }); + + test("new models and service tiers work without a Paseo catalog update", async () => { + const { session, appServer } = await createConnectedSession( + { model: "future-codex-model" }, + { + models: [ + { + id: "future-codex-model", + serviceTiers: [ + { id: "future-tier", name: "Future speed", description: "A newly advertised tier" }, + ], + }, + ], + }, + ); + try { + expect(session.features).toContainEqual( + expect.objectContaining({ + id: "service_tier", + options: [ + { id: "default", label: "Normal", isDefault: true }, + { id: "future-tier", label: "Future speed" }, + ], + }), + ); + await session.setFeature?.("service_tier", "future-tier"); + await session.startTurn("hello"); + await expect(appServer.waitForTurnStart()).resolves.toMatchObject({ + model: "future-codex-model", + serviceTier: "future-tier", + }); + } finally { + await session.close(); + } + }); + + test.each(["priority", "ultrafast"])( + "restores %s and Normal explicitly overrides an existing thread tier", + async (tier) => { + const { session, appServer } = await createConnectedSession( + { model: "future-model", featureValues: { service_tier: tier } }, + { + models: [ + { + id: "future-model", + serviceTiers: [ + { id: "priority", name: "Fast", description: "Fast processing" }, + { id: "ultrafast", name: "Ultrafast", description: "Ultra processing" }, + ], + }, + ], + }, + ); + try { + expect(session.features).toContainEqual( + expect.objectContaining({ id: "service_tier", value: tier }), + ); + await session.getRuntimeInfo(); + await session.setFeature?.("service_tier", "default"); + await session.startTurn("hello"); + await expect(appServer.waitForTurnStart()).resolves.toMatchObject({ + serviceTier: "default", + }); + } finally { + await session.close(); + } + }, + ); + + test("rejects unadvertised tiers and non-string values", async () => { + const { session } = await createConnectedSession(); + try { + for (const value of ["ultrafast", "bogus", true, null]) { + await expect(session.setFeature?.("service_tier", value)).rejects.toThrow( + "is not available", + ); + } + expect(session.features).toContainEqual( + expect.objectContaining({ id: "service_tier", value: "default" }), + ); + } finally { + await session.close(); + } + }); + + test("switching to a model without the selected tier resets speed", async () => { + const { session, appServer } = await createConnectedSession( + { model: "ultra-model", featureValues: { service_tier: "ultrafast" } }, + { + models: [ + { + id: "ultra-model", + serviceTiers: [{ id: "ultrafast", name: "Ultrafast", description: "Ultra processing" }], + }, + ...TEST_SPEED_MODELS, + ], + }, + ); + try { + await session.setModel("gpt-6.1-sol"); + expect(session.features).toContainEqual( + expect.objectContaining({ id: "service_tier", value: "default" }), + ); + await session.startTurn("hello"); + await expect(appServer.waitForTurnStart()).resolves.toMatchObject({ serviceTier: "default" }); + } finally { + await session.close(); + } + }); + + test("maps legacy Fast commands to the catalog's request id", async () => { + const { session, appServer } = await createConnectedSession(); + try { + await session.setFeature?.("fast_mode", true); + await session.startTurn("hello"); + await expect(appServer.waitForTurnStart()).resolves.toMatchObject({ + serviceTier: "priority", + }); + } finally { + await session.close(); + } + }); + test.each([ "gpt-6-astra", + "gpt-6.1-sol", "gpt-6-sol", "gpt-6-luna", "gpt-5.6", @@ -112,15 +309,15 @@ describe("Codex app-server provider features", () => { try { expect(session.features).toContainEqual( expect.objectContaining({ - id: "fast_mode", - value: false, + id: "service_tier", + value: "default", }), ); - await session.setFeature?.("fast_mode", true); + await session.setFeature?.("service_tier", "priority"); await session.startTurn("hello"); await expect(appServer.waitForTurnStart()).resolves.toMatchObject({ model, - serviceTier: "fast", + serviceTier: "priority", }); } finally { await session.close(); @@ -149,8 +346,8 @@ describe("Codex app-server provider features", () => { `Codex fast mode is not available for model '${model}'`, ); await session.startTurn("hello"); - await expect(appServer.waitForTurnStart()).resolves.not.toMatchObject({ - serviceTier: expect.anything(), + await expect(appServer.waitForTurnStart()).resolves.toMatchObject({ + serviceTier: "default", }); } finally { await session.close(); @@ -165,8 +362,8 @@ describe("Codex app-server provider features", () => { try { expect(session.features).toContainEqual( expect.objectContaining({ - id: "fast_mode", - value: true, + id: "service_tier", + value: "priority", }), ); await session.setModel("gpt-5.6-sol"); @@ -174,26 +371,18 @@ describe("Codex app-server provider features", () => { await session.startTurn("hello"); await expect(appServer.waitForTurnStart()).resolves.toMatchObject({ model: "gpt-6-astra", - serviceTier: "fast", + serviceTier: "priority", }); } finally { await session.close(); } }); - test("features returns fast and plan toggles when supported", async () => { + test("features returns speed selector and plan toggle when supported", async () => { const { session } = await createConnectedSession(); expect(session.features).toEqual([ - { - type: "toggle", - id: "fast_mode", - label: "Fast", - description: "Priority inference at increased usage", - tooltip: "Toggle fast mode", - icon: "zap", - value: false, - }, + speedFeature("default"), { type: "toggle", id: "plan_mode", @@ -205,19 +394,11 @@ describe("Codex app-server provider features", () => { }, ]); - await session.setFeature?.("fast_mode", true); + await session.setFeature?.("service_tier", "priority"); await session.setFeature?.("plan_mode", true); expect(session.features).toEqual([ - { - type: "toggle", - id: "fast_mode", - label: "Fast", - description: "Priority inference at increased usage", - tooltip: "Toggle fast mode", - icon: "zap", - value: true, - }, + speedFeature("priority"), { type: "toggle", id: "plan_mode", @@ -265,23 +446,23 @@ describe("Codex app-server provider features", () => { ]); await session.startTurn("hello"); - await expect(appServer.waitForTurnStart()).resolves.not.toMatchObject({ - serviceTier: expect.anything(), + await expect(appServer.waitForTurnStart()).resolves.toMatchObject({ + serviceTier: "default", }); }); - test("setFeature('fast_mode', true) sets serviceTier to fast", async () => { + test("selecting Fast sends the catalog service tier", async () => { const { session, appServer } = await createConnectedSession(); - await session.setFeature?.("fast_mode", true); + await session.setFeature?.("service_tier", "priority"); await session.startTurn("hello"); await expect(appServer.waitForTurnStart()).resolves.toMatchObject({ - serviceTier: "fast", + serviceTier: "priority", }); }); - test("setFeature('fast_mode', false) clears serviceTier to null", async () => { + test("legacy Fast off explicitly selects Normal", async () => { const { session, appServer } = await createConnectedSession({ featureValues: { fast_mode: true }, }); @@ -289,8 +470,8 @@ describe("Codex app-server provider features", () => { await session.setFeature?.("fast_mode", false); await session.startTurn("hello"); - await expect(appServer.waitForTurnStart()).resolves.not.toMatchObject({ - serviceTier: expect.anything(), + await expect(appServer.waitForTurnStart()).resolves.toMatchObject({ + serviceTier: "default", }); }); @@ -330,15 +511,7 @@ describe("Codex app-server provider features", () => { }); expect(session.features).toEqual([ - { - type: "toggle", - id: "fast_mode", - label: "Fast", - description: "Priority inference at increased usage", - tooltip: "Toggle fast mode", - icon: "zap", - value: true, - }, + speedFeature("priority"), { type: "toggle", id: "plan_mode", @@ -352,7 +525,7 @@ describe("Codex app-server provider features", () => { await session.startTurn("hello"); await expect(appServer.waitForTurnStart()).resolves.toMatchObject({ - serviceTier: "fast", + serviceTier: "priority", collaborationMode: expect.objectContaining({ mode: "plan", }), @@ -362,11 +535,11 @@ describe("Codex app-server provider features", () => { test("startTurn includes serviceTier when fast mode is enabled", async () => { const { session, appServer } = await createConnectedSession(); - await session.setFeature?.("fast_mode", true); + await session.setFeature?.("service_tier", "priority"); await session.startTurn("hello"); await expect(appServer.waitForTurnStart()).resolves.toMatchObject({ - serviceTier: "fast", + serviceTier: "priority", }); }); @@ -388,7 +561,7 @@ describe("Codex app-server provider features", () => { msg: "Starting Codex app-server turn", model: "gpt-5.4", modeId: "auto", - serviceTier: "fast", + serviceTier: "priority", cwd: "/tmp/codex-fast-mode-test", }); expect(JSON.stringify(entry)).not.toContain(prompt); @@ -397,7 +570,7 @@ describe("Codex app-server provider features", () => { test("setModel clears fast mode when switching to an unsupported model", async () => { const { session, appServer } = await createConnectedSession(); - await session.setFeature?.("fast_mode", true); + await session.setFeature?.("service_tier", "priority"); await session.setModel("gpt-3.5-turbo"); expect(session.features).toEqual([ @@ -413,8 +586,8 @@ describe("Codex app-server provider features", () => { ]); await session.startTurn("hello"); - await expect(appServer.waitForTurnStart()).resolves.not.toMatchObject({ - serviceTier: expect.anything(), + await expect(appServer.waitForTurnStart()).resolves.toMatchObject({ + serviceTier: "default", }); }); 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 d55a0b2a79..f5fb059255 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 @@ -804,6 +804,7 @@ let buffer = ""; function resultFor(method, params) { if (method === "initialize") return {}; if (method === "collaborationMode/list") return { data: [] }; + if (method === "model/list") return { data: [] }; if (method === "skills/list") { const cwds = params && params.cwds; const projectCwd = ${JSON.stringify(projectCwd)}; @@ -2229,7 +2230,12 @@ describe("Codex app-server provider", () => { throw new Error(`resumeSession timed out; thread requests: ${threadRequests.join(", ")}`); } - expect(threadRequests).toEqual(["config/read", "thread/loaded/list", "thread/resume"]); + expect(threadRequests).toEqual([ + "model/list", + "config/read", + "thread/loaded/list", + "thread/resume", + ]); expect(outcome).toBe("rejected"); appServer.assertNoErrors(); }); 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 f86d5de653..1fbe22ec30 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 @@ -73,7 +73,12 @@ import { import { createPathEquivalenceMatcher } from "../../../utils/path.js"; import { spawnProcess } from "../../../utils/spawn.js"; import { extractCodexTerminalSessionId, nonEmptyString } from "./tool-call-mapper-utils.js"; -import { buildCodexFeatures, codexModelSupportsFastMode } from "./codex-feature-definitions.js"; +import { + buildCodexFeatures, + CodexServiceTierSchema, + readCodexServiceTier, + type CodexServiceTier, +} from "./codex-feature-definitions.js"; import { CodexAppServerClient, CodexAppServerRpcError, @@ -929,6 +934,7 @@ interface CodexModel { model?: string; defaultReasoningEffort?: string; supportedReasoningEfforts?: CodexReasoningEffortEntry[]; + serviceTiers?: CodexServiceTier[]; } const CodexModelListResponseSchema = z.object({ @@ -936,6 +942,7 @@ const CodexModelListResponseSchema = z.object({ .array( z.object({ id: z.string(), + serviceTiers: z.array(CodexServiceTierSchema).optional(), displayName: z.string().optional(), description: z.string().optional(), isDefault: z.boolean().optional(), @@ -3426,7 +3433,8 @@ export class CodexAppServerAgentSession implements AgentSession { private activeForegroundTurnId: string | null = null; private activeClientMessageId: string | null = null; private cachedRuntimeInfo: AgentRuntimeInfo | null = null; - private serviceTier: "fast" | null = null; + private serviceTier: string | null = null; + private speedModels: CodexModel[] = []; private planModeEnabled = false; private historyPending = false; private persistedHistory: PersistedTimelineEntry[] = []; @@ -3517,9 +3525,7 @@ export class CodexAppServerAgentSession implements AgentSession { this.asyncQuestions = new CodexAsyncQuestions(resumeHandle?.metadata?.asyncQuestions); this.codexHome = deps.codexHome ?? resolveCodexHomeDir(process.env); this.config.thinkingOptionId = normalizeCodexThinkingOptionId(this.config.thinkingOptionId); - if (this.config.featureValues?.fast_mode && codexModelSupportsFastMode(this.config.model)) { - this.serviceTier = "fast"; - } + this.serviceTier = readCodexServiceTier(this.config.featureValues); if (this.config.featureValues?.plan_mode) { this.planModeEnabled = true; } @@ -3547,8 +3553,8 @@ export class CodexAppServerAgentSession implements AgentSession { get features(): AgentFeature[] { return buildCodexFeatures({ - modelId: this.config.model, - fastModeEnabled: this.serviceTier === "fast", + serviceTiers: this.currentServiceTiers(), + serviceTier: this.serviceTier ?? "default", planModeEnabled: this.planModeEnabled, planModeAvailable: this.hasPlanCollaborationMode(), }); @@ -3604,6 +3610,9 @@ export class CodexAppServerAgentSession implements AgentSession { await client.request("initialize", buildCodexAppServerInitializeParams()); client.notify("initialized", {}); + this.speedModels = + CodexModelListResponseSchema.parse(await client.request("model/list", {})).data ?? []; + this.reconcileServiceTier(); await this.loadResolvedWorkspaceWrite(); await this.loadCollaborationModes(); await this.loadSkills(); @@ -3808,18 +3817,30 @@ export class CodexAppServerAgentSession implements AgentSession { this.resolvedCollaborationMode = this.resolveCollaborationMode(); } - private applyFeatureValue(featureId: "fast_mode" | "plan_mode", value: boolean): void { - this.config.featureValues = { - ...this.config.featureValues, - [featureId]: value, - }; + private currentServiceTiers(): CodexServiceTier[] { + const selectedModel = this.config.model + ? this.speedModels.find( + (model) => model.id === this.config.model || model.model === this.config.model, + ) + : (this.speedModels.find((model) => model.isDefault) ?? this.speedModels[0]); + return selectedModel?.serviceTiers ?? []; + } - if (featureId === "fast_mode") { - this.serviceTier = value ? "fast" : null; - this.cachedRuntimeInfo = null; - return; + private reconcileServiceTier(): void { + const tiers = this.currentServiceTiers(); + // COMPAT(codexFastPreference): added in v0.10.0, remove after 2027-03-29 once saved Fast preferences have migrated. + if (this.serviceTier === "fast" && !tiers.some((tier) => tier.id === "fast")) { + this.serviceTier = tiers.find((tier) => tier.name.toLowerCase() === "fast")?.id ?? null; } + if (this.serviceTier !== "default" && !tiers.some((tier) => tier.id === this.serviceTier)) { + this.serviceTier = null; + } + const { fast_mode: _legacyFast, ...values } = this.config.featureValues ?? {}; + this.config.featureValues = { ...values, service_tier: this.serviceTier ?? "default" }; + } + private applyFeatureValue(featureId: "plan_mode", value: boolean): void { + this.config.featureValues = { ...this.config.featureValues, [featureId]: value }; this.planModeEnabled = value; this.refreshResolvedCollaborationMode(); this.cachedRuntimeInfo = null; @@ -4150,9 +4171,7 @@ export class CodexAppServerAgentSession implements AgentSession { if (thinkingOptionId) { params.effort = thinkingOptionId; } - if (this.serviceTier) { - params.serviceTier = this.serviceTier; - } + params.serviceTier = this.serviceTier ?? "default"; if (this.resolvedCollaborationMode) { params.collaborationMode = { mode: this.resolvedCollaborationMode.mode, @@ -4570,9 +4589,7 @@ export class CodexAppServerAgentSession implements AgentSession { async setModel(modelId: string | null): Promise { this.config.model = modelId ?? undefined; - if (!codexModelSupportsFastMode(this.config.model)) { - this.serviceTier = null; - } + this.reconcileServiceTier(); this.refreshResolvedCollaborationMode(); this.cachedRuntimeInfo = null; } @@ -4587,13 +4604,27 @@ export class CodexAppServerAgentSession implements AgentSession { } async setFeature(featureId: string, value: unknown): Promise { + // COMPAT(codexFastPreference): added in v0.10.0, remove after 2027-03-29 once clients use service_tier. if (featureId === "fast_mode") { - if (Boolean(value) && !codexModelSupportsFastMode(this.config.model)) { + const fast = this.currentServiceTiers().find((tier) => tier.name.toLowerCase() === "fast"); + if (value && !fast) throw new Error( `Codex fast mode is not available for model '${this.config.model ?? "default"}'`, ); + return this.setFeature("service_tier", value ? fast!.id : "default"); + } + if (featureId === "service_tier") { + if ( + typeof value !== "string" || + (value !== "default" && !this.currentServiceTiers().some((tier) => tier.id === value)) + ) { + throw new Error( + `Codex speed '${String(value)}' is not available for model '${this.config.model ?? "default"}'`, + ); } - this.applyFeatureValue("fast_mode", Boolean(value)); + this.serviceTier = value; + this.reconcileServiceTier(); + this.cachedRuntimeInfo = null; return; } if (featureId === "plan_mode") { @@ -5214,6 +5245,7 @@ export class CodexAppServerAgentSession implements AgentSession { private async applyDefaultModelAndThinking(): Promise { const { model, thinkingOptionId } = await this.resolveModelAndThinking(); this.config.model = model; + this.reconcileServiceTier(); this.config.thinkingOptionId = thinkingOptionId; return model; } diff --git a/packages/server/src/server/agent/providers/codex-feature-definitions.ts b/packages/server/src/server/agent/providers/codex-feature-definitions.ts index d2588a5a5f..5eb11d8a0c 100644 --- a/packages/server/src/server/agent/providers/codex-feature-definitions.ts +++ b/packages/server/src/server/agent/providers/codex-feature-definitions.ts @@ -1,27 +1,33 @@ import type { AgentFeature, AgentFeatureToggle } from "../agent-sdk-types.js"; -// Codex Fast is distinct from API Priority processing. Keep model support aligned with -// https://developers.openai.com/codex/speed and https://developers.openai.com/codex/models. -const CODEX_FAST_MODE_SUPPORTED_MODELS = new Set([ - "gpt-6-astra", - "gpt-6-sol", - "gpt-6-luna", - "gpt-5.6", - "gpt-5.6-sol", - "gpt-5.6-terra", - "gpt-5.6-luna", - "gpt-5.5", - "gpt-5.4", -]); +import { z } from "zod"; -export const CODEX_FAST_MODE_FEATURE: Omit = { - type: "toggle", - id: "fast_mode", - label: "Fast", - description: "Priority inference at increased usage", - tooltip: "Toggle fast mode", - icon: "zap", -}; +export const CodexServiceTierSchema = z.object({ + id: z.string(), + name: z.string(), + description: z.string(), +}); +export type CodexServiceTier = z.infer; + +function buildCodexSpeedFeature(tiers: CodexServiceTier[], value: string): AgentFeature[] { + if (tiers.length === 0) return []; + return [ + { + type: "select", + id: "service_tier", + label: "Speed", + description: "Choose processing speed. Faster tiers increase usage.", + tooltip: "Select speed", + icon: "zap", + desktopTrigger: "icon", + value, + options: [ + { id: "default", label: "Normal", isDefault: true }, + ...tiers.map((tier) => ({ id: tier.id, label: tier.name })), + ], + }, + ]; +} export const CODEX_PLAN_MODE_FEATURE: Omit = { type: "toggle", @@ -32,40 +38,22 @@ export const CODEX_PLAN_MODE_FEATURE: Omit = { icon: "list-todo", }; -function normalizeCodexModelId(modelId: string | null | undefined): string | null { - const normalized = typeof modelId === "string" ? modelId.trim() : ""; - return normalized.length > 0 ? normalized : null; -} - -export function codexModelSupportsFastMode(modelId: string | null | undefined): boolean { - const normalizedModelId = normalizeCodexModelId(modelId); - if (!normalizedModelId) { - return false; - } - return CODEX_FAST_MODE_SUPPORTED_MODELS.has(normalizedModelId); -} - export function buildCodexFeatures(input: { - modelId: string | null | undefined; - fastModeEnabled: boolean; + serviceTiers: CodexServiceTier[]; + serviceTier: string; planModeEnabled: boolean; planModeAvailable?: boolean; }): AgentFeature[] { - const features: AgentFeature[] = []; - - if (codexModelSupportsFastMode(input.modelId)) { - features.push({ - ...CODEX_FAST_MODE_FEATURE, - value: input.fastModeEnabled, - }); - } - + const features = buildCodexSpeedFeature(input.serviceTiers, input.serviceTier); if (input.planModeAvailable !== false) { - features.push({ - ...CODEX_PLAN_MODE_FEATURE, - value: input.planModeEnabled, - }); + features.push({ ...CODEX_PLAN_MODE_FEATURE, value: input.planModeEnabled }); } - return features; } + +export function readCodexServiceTier(values: Record | undefined): string | null { + const tier = values?.service_tier; + if (typeof tier === "string") return tier; + // COMPAT(codexFastPreference): added in v0.10.0, remove after 2027-03-29 once saved Fast preferences have migrated. + return values?.fast_mode ? "fast" : null; +}