mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-27 03:32:55 +00:00
Support catalog-driven Codex Fast and Ultrafast speeds (#5708)
* feat(codex): discover Fast and Ultrafast speeds from the catalog Replace the model allowlist with advertised service tiers so new models and speeds need no Paseo catalog update. Normal explicitly overrides the thread tier. Desktop icon-only triggers are provider metadata; mobile selectors remain labeled. * chore: remove QA screenshots from the change
This commit is contained in:
@@ -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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
const client = await connectDaemonClient<
|
||||
Pick<DaemonClient, "setAgentModel" | "close" | "connect">
|
||||
>({
|
||||
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();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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 } });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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 (
|
||||
<>
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||
@@ -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}`}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<Text style={styles.tooltipText}>{getFeatureTooltip(feature)}</Text>
|
||||
<Text style={styles.tooltipText}>{tooltip}</Text>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Combobox
|
||||
|
||||
@@ -49,6 +49,22 @@ describe("agent feature schemas", () => {
|
||||
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",
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
+228
-55
@@ -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<AgentSessionConfig> = {}): AgentSession
|
||||
|
||||
function createSessionHarness(
|
||||
configOverrides: Partial<AgentSessionConfig> = {},
|
||||
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<AgentSessionConfig> = {},
|
||||
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",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
// 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<string> {
|
||||
const { model, thinkingOptionId } = await this.resolveModelAndThinking();
|
||||
this.config.model = model;
|
||||
this.reconcileServiceTier();
|
||||
this.config.thinkingOptionId = thinkingOptionId;
|
||||
return model;
|
||||
}
|
||||
|
||||
@@ -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<AgentFeatureToggle, "value"> = {
|
||||
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<typeof CodexServiceTierSchema>;
|
||||
|
||||
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<AgentFeatureToggle, "value"> = {
|
||||
type: "toggle",
|
||||
@@ -32,40 +38,22 @@ export const CODEX_PLAN_MODE_FEATURE: Omit<AgentFeatureToggle, "value"> = {
|
||||
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<string, unknown> | 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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user