diff --git a/docs/providers.md b/docs/providers.md index 4858ec0537..3bbb4f670d 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -51,7 +51,7 @@ or without a control. rejected too, so a stored option always describes what runs. Claude reads most options when it starts its query, and replacing a started query would end its background work, so after the first prompt only Fast can change. Codex fixes its approval policy and sandbox when the thread - starts, so it applies only `service_tier`, `plan_mode` and `fast_mode` live. A session with no + starts, so it applies only `service_tier` and `plan_mode` live. A session with no `applyOptions` rejects every patch; the daemon enforces this. The provider returns the options it ended up with when they differ from the request. The daemon stores the patch and that result in one step after the call resolves, onto the agent's options @@ -59,6 +59,13 @@ or without a control. none of it is stored. Emit nothing from inside the call. If the provider's own state moved before it refused, it reports the moved values once as `options_changed` after the call has ended, because the stored options must match what runs. +- **Old data converts where it enters.** Codex used to take `fast_mode`; it takes + `service_tier` now. `convertLegacyOptions` (`option-controls.ts`) turns `fast_mode` into + `service_tier` as stored records, `config.json` defaults, profiles, older clients' requests, + MCP `settings.features` and plugin hooks come in (`true` is `"fast"`, `false` is `"default"`, + and a `service_tier` in the same input wins). Past that point nothing sees `fast_mode` for Codex. + Codex resolves `"fast"` to the model's Fast tier and reports the id it runs through + `openOptionsPatch` or the `applyOptions` result like any other value. - **Provider-initiated changes.** A setting the provider changes on its own is an `options_changed` event. The daemon stores and forwards it and does not push the old value back. diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index b34f68d2d1..71ca6b0ea6 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -13093,7 +13093,7 @@ describe("agent options", () => { emit: () => {}, before: async (_name, request) => ({ ...request, - config: { ...request.config, featureValues: { fast_mode: true } }, + config: { ...request.config, featureValues: { web_search: true } }, }), }, }); @@ -13102,7 +13102,7 @@ describe("agent options", () => { workspaceId: undefined, }); - expect(manager.getAgent(created.id)?.config.providerOptions).toEqual({ fast_mode: true }); + expect(manager.getAgent(created.id)?.config.providerOptions).toEqual({ web_search: true }); expect(manager.getAgent(created.id)?.config).not.toHaveProperty("featureValues"); }); @@ -13123,7 +13123,7 @@ describe("agent options", () => { ...request, config: { ...request.config, - featureValues: { ...request.config.featureValues, fast_mode: true }, + featureValues: { ...request.config.featureValues, web_search: true }, }, } : request, @@ -13131,13 +13131,13 @@ describe("agent options", () => { }); const created = await manager.createAgent( - { provider: "codex", cwd: workdir, providerOptions: { fast_mode: false, plan_mode: true } }, + { provider: "codex", cwd: workdir, providerOptions: { web_search: false, plan_mode: true } }, undefined, { workspaceId: undefined }, ); expect(manager.getAgent(created.id)?.config.providerOptions).toEqual({ - fast_mode: true, + web_search: true, plan_mode: true, }); }); @@ -13166,13 +13166,13 @@ describe("agent options", () => { }); const created = await manager.createAgent( - { provider: "codex", cwd: workdir, providerOptions: { fast_mode: false, plan_mode: true } }, + { provider: "codex", cwd: workdir, providerOptions: { web_search: false, plan_mode: true } }, undefined, { workspaceId: undefined }, ); expect(manager.getAgent(created.id)?.config.providerOptions).toEqual({ - fast_mode: false, + web_search: false, plan_mode: false, }); }); @@ -13181,7 +13181,7 @@ describe("agent options", () => { { name: "deletes a key", edit: (values: Record | undefined) => { - const { fast_mode: _removed, ...rest } = values ?? {}; + const { web_search: _removed, ...rest } = values ?? {}; return { featureValues: rest }; }, expected: { plan_mode: true }, @@ -13216,7 +13216,7 @@ describe("agent options", () => { }); const created = await manager.createAgent( - { provider: "codex", cwd: workdir, providerOptions: { fast_mode: true, plan_mode: true } }, + { provider: "codex", cwd: workdir, providerOptions: { web_search: true, plan_mode: true } }, undefined, { workspaceId: undefined }, ); diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index a68510cc17..5681d8e636 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -65,6 +65,7 @@ import { toStoredAgentRecord } from "./agent-projections.js"; import { applyProviderOptionsPatch, exposeFeatureValuesToHook, + convertLegacyOptions, foldHookFeatureValues, type ProviderOptionsPatch, } from "./option-controls.js"; @@ -2317,6 +2318,16 @@ export class AgentManager { this.emitState(agent); } + /** + * COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01. An older client or the MCP + * `settings.features` sends composer values as `featureValues`; this is where they become an + * options patch for the agent's provider. + */ + async setAgentFeatureValues(agentId: string, values: Record): Promise { + const provider = this.requireAgent(agentId).provider; + await this.setAgentOptions(agentId, convertLegacyOptions({ provider, options: values })); + } + /** The config as the opened session left it: its own difference from the options it was given. */ private withOpenedOptions(input: { config: AgentSessionConfig; diff --git a/packages/server/src/server/agent/agent-storage.test.ts b/packages/server/src/server/agent/agent-storage.test.ts index 9c8296d0f6..9b24686f95 100644 --- a/packages/server/src/server/agent/agent-storage.test.ts +++ b/packages/server/src/server/agent/agent-storage.test.ts @@ -218,6 +218,20 @@ describe("AgentStorage", () => { }); }); + test("a Codex record stored with fast_mode loads as service_tier", async () => { + await storage.applySnapshot( + createManagedAgent({ id: "agent-codex-legacy", provider: "codex" }), + ); + const file = findRecordFile(storagePath, "agent-codex-legacy"); + const stored = JSON.parse(await fs.readFile(file, "utf8")); + stored.config.providerOptions = { fast_mode: true, plan_mode: false }; + await fs.writeFile(file, JSON.stringify(stored)); + + const persisted = await new AgentStorage(storagePath, logger).get("agent-codex-legacy"); + + expect(persisted?.config?.providerOptions).toEqual({ service_tier: "fast", plan_mode: false }); + }); + test("a record stored with featureValues loads them in providerOptions", async () => { await storage.applySnapshot(createManagedAgent({ id: "agent-legacy" })); const file = findRecordFile(storagePath, "agent-legacy"); diff --git a/packages/server/src/server/agent/agent-storage.ts b/packages/server/src/server/agent/agent-storage.ts index 07dd93bfa3..159be716c9 100644 --- a/packages/server/src/server/agent/agent-storage.ts +++ b/packages/server/src/server/agent/agent-storage.ts @@ -107,7 +107,7 @@ function foldLegacyRecordFields(record: RawStoredAgentRecord): StoredAgentRecord const { features, config, controls, ...rest } = record; return { ...rest, - ...(config ? { config: foldFeatureValues(config) } : { config }), + ...(config ? { config: foldFeatureValues({ config, provider: record.provider }) } : { config }), ...((controls ?? features) ? { controls: controls ?? controlsFromFeatures(features) } : {}), }; } diff --git a/packages/server/src/server/agent/create-agent/create.ts b/packages/server/src/server/agent/create-agent/create.ts index 3e47eb0978..0db88fa7f8 100644 --- a/packages/server/src/server/agent/create-agent/create.ts +++ b/packages/server/src/server/agent/create-agent/create.ts @@ -221,7 +221,10 @@ async function resolveSessionCreateAgent( input: CreateAgentFromSessionInput, ): Promise { const trimmedPrompt = input.initialPrompt?.trim(); - const builtSessionConfig = foldFeatureValues(input.config); + const builtSessionConfig = foldFeatureValues({ + config: input.config, + provider: input.config.provider, + }); const resolvedCreateConfig = await dependencies.providerSnapshotManager.resolveCreateConfig({ cwd: builtSessionConfig.cwd, provider: builtSessionConfig.provider, diff --git a/packages/server/src/server/agent/mcp-server.test.ts b/packages/server/src/server/agent/mcp-server.test.ts index 28b1914bf3..1d24d942e9 100644 --- a/packages/server/src/server/agent/mcp-server.test.ts +++ b/packages/server/src/server/agent/mcp-server.test.ts @@ -258,6 +258,7 @@ function buildAgentManagerSpies() { setAgentModel: vi.fn().mockResolvedValue(undefined), setAgentThinkingOption: vi.fn().mockResolvedValue(undefined), setAgentOptions: vi.fn().mockResolvedValue(undefined), + setAgentFeatureValues: vi.fn().mockResolvedValue(undefined), setLabels: vi.fn().mockResolvedValue(undefined), setTitle: vi.fn().mockResolvedValue(undefined), updateAgentMetadata: vi.fn().mockResolvedValue(undefined), @@ -1834,7 +1835,7 @@ describe("create_agent MCP tool", () => { expect.objectContaining({ provider: "codex", model: "gpt-5.4", - providerOptions: { fast_mode: true }, + providerOptions: { service_tier: "fast" }, }), undefined, { workspaceId: "workspace-created" }, @@ -3072,7 +3073,7 @@ describe("create_agent MCP tool", () => { expect.objectContaining({ provider: "codex", model: "gpt-5.4", - providerOptions: { fast_mode: true }, + providerOptions: { service_tier: "fast" }, }), undefined, { @@ -4717,7 +4718,9 @@ describe("update_agent MCP tool", () => { expect(spies.agentManager.setAgentMode).toHaveBeenCalledWith("agent-1", "full-access"); expect(spies.agentManager.setAgentModel).toHaveBeenCalledWith("agent-1", "gpt-5.4"); expect(spies.agentManager.setAgentThinkingOption).toHaveBeenCalledWith("agent-1", "high"); - expect(spies.agentManager.setAgentOptions).toHaveBeenCalledWith("agent-1", { fast_mode: true }); + expect(spies.agentManager.setAgentFeatureValues).toHaveBeenCalledWith("agent-1", { + fast_mode: true, + }); expect(spies.agentManager.updateAgentMetadata).toHaveBeenCalledWith("agent-1", { title: "Updated agent", labels: { role: "worker" }, @@ -4742,12 +4745,12 @@ describe("update_agent MCP tool", () => { expect(spies.agentManager.setAgentMode).not.toHaveBeenCalled(); expect(spies.agentManager.setAgentModel).not.toHaveBeenCalled(); expect(spies.agentManager.setAgentThinkingOption).not.toHaveBeenCalled(); - expect(spies.agentManager.setAgentOptions).not.toHaveBeenCalled(); + expect(spies.agentManager.setAgentFeatureValues).not.toHaveBeenCalled(); }); it("does not update metadata when runtime settings fail", async () => { const { agentManager, agentStorage, spies } = createTestDeps(); - spies.agentManager.setAgentOptions.mockRejectedValue(new Error("unsupported feature")); + spies.agentManager.setAgentFeatureValues.mockRejectedValue(new Error("unsupported feature")); const server = await createAgentMcpServer({ agentManager, agentStorage, @@ -5990,7 +5993,7 @@ describe("provider MCP tools", () => { modeId: "full-access", model: "gpt-5.4", thinkingOptionId: "high", - providerOptions: { fast_mode: true }, + providerOptions: { service_tier: "fast" }, }); expect(response.structuredContent).toEqual({ provider: "codex", diff --git a/packages/server/src/server/agent/option-controls.test.ts b/packages/server/src/server/agent/option-controls.test.ts new file mode 100644 index 0000000000..7e3198d00f --- /dev/null +++ b/packages/server/src/server/agent/option-controls.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, test } from "vitest"; +import { + convertLegacyOptions, + foldFeatureValues, + foldHookFeatureValues, +} from "./option-controls.js"; + +describe("convertLegacyOptions", () => { + test.each([ + { legacy: true, tier: "fast" }, + { legacy: false, tier: "default" }, + { legacy: null, tier: null }, + ])("Codex fast_mode: $legacy becomes service_tier: $tier", ({ legacy, tier }) => { + expect( + convertLegacyOptions({ provider: "codex", options: { fast_mode: legacy, plan_mode: true } }), + ).toEqual({ plan_mode: true, service_tier: tier }); + }); + + test("a service_tier in the same input wins", () => { + expect( + convertLegacyOptions({ + provider: "codex", + options: { service_tier: "default", fast_mode: true }, + }), + ).toEqual({ service_tier: "default" }); + }); + + test("other providers keep fast_mode", () => { + const options = { fast_mode: true }; + expect(convertLegacyOptions({ provider: "claude", options })).toBe(options); + }); +}); + +describe("legacy Codex Fast at the other entry points", () => { + test("a stored record's fast_mode folds into service_tier", () => { + expect( + foldFeatureValues({ + config: { featureValues: { fast_mode: true }, providerOptions: { plan_mode: false } }, + provider: "codex", + }).providerOptions, + ).toEqual({ plan_mode: false, service_tier: "fast" }); + }); + + test("a plugin hook that edits featureValues.fast_mode sets service_tier", () => { + const sent = { provider: "codex", providerOptions: {}, featureValues: {} }; + const returned = { ...sent, featureValues: { fast_mode: true } }; + + expect(foldHookFeatureValues({ returned, sent }).providerOptions).toEqual({ + service_tier: "fast", + }); + }); +}); diff --git a/packages/server/src/server/agent/option-controls.ts b/packages/server/src/server/agent/option-controls.ts index d1fcda04e3..846b840233 100644 --- a/packages/server/src/server/agent/option-controls.ts +++ b/packages/server/src/server/agent/option-controls.ts @@ -33,6 +33,35 @@ export interface LegacyFeatureValues { featureValues?: Record; } +/** + * COMPAT(codexFastPreference): added in v0.10.0, remove after 2027-03-29 once saved Fast + * preferences have migrated. Codex used to take `fast_mode`; it takes `service_tier` now. Old + * data (stored records, config.json defaults, profiles, older clients, hooks) is converted here + * as it enters, so nothing past this point sees `fast_mode` for Codex. A `service_tier` in the + * same input wins. A patch's `null` stays `null`, which removes the speed. + */ +interface LegacyOptionsInput { + provider: string | undefined; + options: O; +} + +export function convertLegacyOptions( + input: LegacyOptionsInput>, +): Record; +export function convertLegacyOptions( + input: LegacyOptionsInput | undefined>, +): Record | undefined; +export function convertLegacyOptions( + input: LegacyOptionsInput | undefined>, +): Record | undefined { + const { provider, options } = input; + if (provider !== "codex" || !options || !("fast_mode" in options)) return options; + const { fast_mode: legacy, ...rest } = options; + if ("service_tier" in rest || legacy === undefined) return rest; + if (legacy === null) return { ...rest, service_tier: null }; + return { ...rest, service_tier: legacy ? "fast" : "default" }; +} + /** * COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01. * Older clients, stored records, profiles, and plugin hooks carry composer values in @@ -44,12 +73,14 @@ export function foldFeatureValues< providerOptions?: ProviderOptions | null; featureValues?: Record | null; }, ->(input: T): Omit { - const { featureValues, ...rest } = input; - if (!featureValues || Object.keys(featureValues).length === 0) { - return rest; - } - return { ...rest, providerOptions: { ...featureValues, ...rest.providerOptions } }; +>(input: { config: T; provider: string | undefined }): Omit { + const { featureValues, ...rest } = input.config; + const merged = + featureValues && Object.keys(featureValues).length > 0 + ? { ...featureValues, ...rest.providerOptions } + : (rest.providerOptions ?? undefined); + const providerOptions = convertLegacyOptions({ provider: input.provider, options: merged }); + return providerOptions === rest.providerOptions ? rest : { ...rest, providerOptions }; } /** @@ -67,14 +98,18 @@ export function exposeFeatureValuesToHook< } export function foldHookFeatureValues< - T extends { providerOptions?: ProviderOptions | null; featureValues?: Record }, + T extends { + provider?: string; + providerOptions?: ProviderOptions | null; + featureValues?: Record; + }, >(input: { returned: T; sent: T }): Omit { const { returned, sent } = input; const { featureValues, ...rest } = returned; const before = sent.featureValues; if (!before) { // No copy was sent, so everything the hook put in `featureValues` is new. - return { ...rest, providerOptions: { ...featureValues, ...rest.providerOptions } }; + return foldFeatureValues({ config: returned, provider: returned.provider }); } const after = featureValues ?? {}; const patch: ProviderOptionsPatch = {}; @@ -85,8 +120,11 @@ export function foldHookFeatureValues< return { ...rest, providerOptions: applyProviderOptionsPatch({ - options: rest.providerOptions ?? undefined, - patch, + options: convertLegacyOptions({ + provider: returned.provider, + options: rest.providerOptions ?? undefined, + }), + patch: convertLegacyOptions({ provider: returned.provider, options: patch }), }), }; } @@ -115,7 +153,8 @@ export function foldProfileFeatureValues(input: { source: "stored" | "client write"; }): Omit { const { profile, stored, source } = input; - const { featureValues, ...rest } = profile; + const { featureValues: legacyValues, ...rest } = profile; + const featureValues = convertLegacyOptions({ provider: profile.provider, options: legacyValues }); if (source === "stored") { return featureValues ? { ...rest, options: { ...featureValues, ...rest.options } } : rest; } diff --git a/packages/server/src/server/agent/provider-options.ts b/packages/server/src/server/agent/provider-options.ts index 01a78cda87..76a9d638b6 100644 --- a/packages/server/src/server/agent/provider-options.ts +++ b/packages/server/src/server/agent/provider-options.ts @@ -99,42 +99,6 @@ export function mergeProviderOptions( return merged; } -// COMPAT(codexFastPreference): added in v0.10.0, remove after 2027-03-29 once saved Fast preferences have migrated. -const LEGACY_OPTION_ALIASES: Record< - string, - Record unknown }> -> = { - codex: { - fast_mode: { canonical: "service_tier", value: (legacy) => (legacy ? "fast" : "default") }, - }, -}; - -/** - * The options a provider opens with: `defaults` from config.json under the agent's `own` - * options. A legacy alias (a saved or configured `fast_mode`) stands for the key it aliases: - * a configured alias supplies that key's default, and an agent's own alias replaces the default - * of that key, so a configured `service_tier` does not override the agent's own Fast preference. - */ -export function mergeDefaultsUnderOwn(input: { - provider: string; - defaults: ProviderOptions | undefined; - own: ProviderOptions | undefined; -}): ProviderOptions | undefined { - const aliases = LEGACY_OPTION_ALIASES[input.provider]; - if (!aliases || !input.defaults) return mergeProviderOptions(input.defaults, input.own); - const defaults = { ...input.defaults }; - for (const [alias, { canonical, value }] of Object.entries(aliases)) { - const own = input.own ?? {}; - if (own[alias] !== undefined && own[canonical] === undefined) { - delete defaults[canonical]; - } else if (defaults[alias] != null && defaults[canonical] === undefined) { - defaults[canonical] = value(defaults[alias]); - delete defaults[alias]; - } - } - return mergeProviderOptions(defaults, input.own); -} - function isPlainObject(value: unknown): value is ProviderOptions { if (value === null || typeof value !== "object") return false; const prototype = Object.getPrototypeOf(value); diff --git a/packages/server/src/server/agent/provider-registry.test.ts b/packages/server/src/server/agent/provider-registry.test.ts index 0accbd1c66..2f5b3b0e61 100644 --- a/packages/server/src/server/agent/provider-registry.test.ts +++ b/packages/server/src/server/agent/provider-registry.test.ts @@ -509,6 +509,7 @@ import { createAllClients, } from "./provider-registry.js"; import { FakeOmp } from "./providers/omp/test-utils/fake-omp.js"; +import { foldFeatureValues } from "./option-controls.js"; const logger = createTestLogger(); @@ -796,16 +797,13 @@ test("a saved legacy Codex Fast preference wins over a configured service_tier d providerOverrides: { codex: { options: { service_tier: "default", plan_mode: false } } }, }); const client = registry.codex.createClient(logger); + // A pre-v0.10 record is converted as it is read, before the defaults merge under it. + const { providerOptions: saved } = foldFeatureValues({ + config: { providerOptions: { fast_mode: true } }, + provider: "codex", + }); - // Pre-v0.10 agents saved `fast_mode`; the agent's own preference beats the configured default. - expect(client.effectiveOptions?.({ fast_mode: true })).toEqual({ - fast_mode: true, - plan_mode: false, - }); - expect(client.effectiveOptions?.({ service_tier: "fast" })).toEqual({ - service_tier: "fast", - plan_mode: false, - }); + expect(client.effectiveOptions?.(saved)).toEqual({ service_tier: "fast", plan_mode: false }); expect(client.effectiveOptions?.({})).toEqual({ service_tier: "default", plan_mode: false }); }); @@ -820,7 +818,6 @@ test("a configured legacy Codex Fast default supplies the service_tier default", expect(client.effectiveOptions?.({ service_tier: "default" })).toEqual({ service_tier: "default", }); - expect(client.effectiveOptions?.({ fast_mode: false })).toEqual({ fast_mode: false }); }); test("cursor provider extending acp uses CursorACPAgentClient", () => { diff --git a/packages/server/src/server/agent/provider-registry.ts b/packages/server/src/server/agent/provider-registry.ts index 85561f00c1..1bac569db5 100644 --- a/packages/server/src/server/agent/provider-registry.ts +++ b/packages/server/src/server/agent/provider-registry.ts @@ -48,7 +48,8 @@ import { PiRpcAgentClient } from "./providers/pi/agent.js"; import { TraeACPAgentClient } from "./providers/trae-acp-agent.js"; import { MockLoadTestAgentClient } from "./providers/mock-load-test-agent.js"; import { MockSlowProviderClient } from "./providers/mock-slow-provider.js"; -import { ToolPolicyUnsupportedError, mergeDefaultsUnderOwn } from "./provider-options.js"; +import { convertLegacyOptions } from "./option-controls.js"; +import { ToolPolicyUnsupportedError, mergeProviderOptions } from "./provider-options.js"; import { AGENT_PROVIDER_DEFINITIONS, BUILTIN_PROVIDER_IDS, @@ -490,7 +491,7 @@ function wrapClientProvider( profileModels: ProviderProfileModel[], additionalModels: ProviderProfileModel[], profileModelsAreAdditive: boolean, - providerOptions: ProviderOptions | undefined, + configuredOptions: ProviderOptions | undefined, ): AgentClient { const listImportableSessions = inner.listImportableSessions?.bind(inner); const importSession = inner.importSession?.bind(inner); @@ -498,8 +499,12 @@ function wrapClientProvider( const archiveNativeSession = inner.archiveNativeSession?.bind(inner); const unarchiveNativeSession = inner.unarchiveNativeSession?.bind(inner); const configuredModelIds = [...profileModels, ...additionalModels].map((model) => model.id); + const providerOptions = convertLegacyOptions({ + provider: inner.provider, + options: configuredOptions, + }); const withDefaults = (own: ProviderOptions | undefined) => - mergeDefaultsUnderOwn({ provider: inner.provider, defaults: providerOptions, own }); + mergeProviderOptions(providerOptions, own); return { provider, 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 d6e12f5486..292130b1f1 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 @@ -326,7 +326,7 @@ describe("Codex app-server provider controls", () => { } }); - test("a Fast chosen by alias is kept as the catalog's id, so a model with a different Fast id resets it", async () => { + test("a Fast chosen as `fast` is kept as the catalog's id, so a model with a different Fast id resets it", async () => { const { session, appServer } = await createConnectedSession( { model: "model-a" }, { @@ -346,9 +346,8 @@ describe("Codex app-server provider controls", () => { const events: AgentStreamEvent[] = []; session.subscribe((event) => events.push(event)); try { - await expect(session.applyOptions?.({ fast_mode: true })).resolves.toEqual({ + await expect(session.applyOptions?.({ service_tier: "fast" })).resolves.toEqual({ service_tier: "priority", - fast_mode: null, }); await session.setModel("model-b"); @@ -364,21 +363,10 @@ describe("Codex app-server provider controls", () => { } }); - test("a patch carrying both speed keys reports the folded alias, so removing the speed removes it everywhere", async () => { - const { session } = await createConnectedSession(); - try { - await expect( - session.applyOptions?.({ service_tier: "priority", fast_mode: true }), - ).resolves.toEqual({ fast_mode: null }); - } finally { - await session.close(); - } - }); - - test("maps legacy Fast commands to the catalog's request id", async () => { + test("`fast` maps to the catalog's request id", async () => { const { session, appServer } = await createConnectedSession(); try { - await session.applyOptions?.({ fast_mode: true }); + await session.applyOptions?.({ service_tier: "fast" }); await session.startTurn("hello"); await expect(appServer.waitForTurnStart()).resolves.toMatchObject({ serviceTier: "priority", @@ -433,12 +421,12 @@ describe("Codex app-server provider controls", () => { ])("does not expose or restore Fast for %s", async (model) => { const { session, appServer } = await createConnectedSession({ model, - providerOptions: { fast_mode: true }, + providerOptions: { service_tier: "fast" }, }); const fresh = await createConnectedSession({ model }); try { expect(session.controls.map((control) => control.key)).toEqual(["plan_mode"]); - await expect(fresh.session.applyOptions?.({ fast_mode: true })).rejects.toThrow( + await expect(fresh.session.applyOptions?.({ service_tier: "fast" })).rejects.toThrow( `Codex speed 'fast' is not available for model '${model}'`, ); await session.startTurn("hello"); @@ -454,7 +442,7 @@ describe("Codex app-server provider controls", () => { test("restores Fast on Astra and preserves it when switching supported models", async () => { const { session, appServer } = await createConnectedSession({ model: "gpt-6-astra", - providerOptions: { fast_mode: true }, + providerOptions: { service_tier: "fast" }, }); try { await session.setModel("gpt-5.6-sol"); @@ -492,7 +480,7 @@ describe("Codex app-server provider controls", () => { test("constructor ignores restored fast mode when model does not support it", async () => { const { session, appServer } = await createConnectedSession({ model: "gpt-3.5-turbo", - providerOptions: { fast_mode: true }, + providerOptions: { service_tier: "fast" }, }); expect(session.controls).toEqual([PLAN_CONTROL]); @@ -514,12 +502,12 @@ describe("Codex app-server provider controls", () => { }); }); - test("legacy Fast off explicitly selects Normal", async () => { + test("selecting Normal after Fast explicitly selects Normal", async () => { const { session, appServer } = await createConnectedSession({ - providerOptions: { fast_mode: true }, + providerOptions: { service_tier: "fast" }, }); - await session.applyOptions?.({ fast_mode: false }); + await session.applyOptions?.({ service_tier: "default" }); await session.startTurn("hello"); await expect(appServer.waitForTurnStart()).resolves.toMatchObject({ @@ -527,10 +515,10 @@ describe("Codex app-server provider controls", () => { }); }); - test("applyOptions({ fast_mode: true }) rejects models that do not support fast mode", async () => { + test('applyOptions({ service_tier: "fast" }) rejects models that do not support fast mode', async () => { const { session } = await createConnectedSession({ model: "gpt-3.5-turbo" }); - await expect(session.applyOptions?.({ fast_mode: true })).rejects.toThrow( + await expect(session.applyOptions?.({ service_tier: "fast" })).rejects.toThrow( "Codex speed 'fast' is not available for model 'gpt-3.5-turbo'", ); }); @@ -551,14 +539,14 @@ describe("Codex app-server provider controls", () => { test("constructor restores composer options from config.providerOptions", async () => { const { session, appServer } = await createConnectedSession({ - providerOptions: { fast_mode: true, plan_mode: true }, + providerOptions: { service_tier: "fast", plan_mode: true }, }); const events: AgentStreamEvent[] = []; session.subscribe((event) => events.push(event)); expect(session.controls).toEqual([speedControl(), PLAN_CONTROL]); - // A saved pre-v0.10 Fast preference opens as the model's Fast tier, which the open patch says. - expect(session.openOptionsPatch).toEqual({ service_tier: "priority", fast_mode: null }); + // A saved `fast` opens as the model's Fast tier, which the open patch says. + expect(session.openOptionsPatch).toEqual({ service_tier: "priority" }); expect(events.filter((event) => event.type === "options_changed")).toEqual([]); await session.startTurn("hello"); @@ -570,22 +558,9 @@ describe("Codex app-server provider controls", () => { }); }); - test("removing the legacy Fast key after opening leaves the speed the session opened with", async () => { - const { session, appServer } = await createConnectedSession({ - providerOptions: { fast_mode: true }, - }); - expect(session.openOptionsPatch).toEqual({ service_tier: "priority", fast_mode: null }); - - // The stored options now hold `service_tier`; `fast_mode` is an alias no longer in them. - await expect(session.applyOptions?.({ fast_mode: null })).resolves.toEqual({}); - - await session.startTurn("hello"); - await expect(appServer.waitForTurnStart()).resolves.toMatchObject({ serviceTier: "priority" }); - }); - test("a patch that sets another option keeps the running sandbox and an explicit speed", async () => { const { session, appServer } = await createConnectedSession({ - providerOptions: { sandbox_mode: "read-only", service_tier: "default", fast_mode: true }, + providerOptions: { sandbox_mode: "read-only", service_tier: "default" }, }); await session.applyOptions?.({ plan_mode: true }); @@ -629,7 +604,7 @@ describe("Codex app-server provider controls", () => { { models }, ); - expect(session.openOptionsPatch).toEqual({ service_tier: "default", fast_mode: null }); + expect(session.openOptionsPatch).toEqual({ service_tier: "default" }); await session.startTurn("hello"); await expect(appServer.waitForTurnStart()).resolves.toMatchObject({ serviceTier: "default" }); @@ -640,7 +615,7 @@ describe("Codex app-server provider controls", () => { providerOptions: { service_tier: "fast" }, }); - expect(session.openOptionsPatch).toEqual({ service_tier: "priority", fast_mode: null }); + expect(session.openOptionsPatch).toEqual({ service_tier: "priority" }); await session.startTurn("hello"); await expect(appServer.waitForTurnStart()).resolves.toMatchObject({ serviceTier: "priority" }); @@ -679,7 +654,7 @@ describe("Codex app-server provider controls", () => { await expect(appServer.waitForTurnStart()).resolves.toMatchObject({ serviceTier: "default" }); }); - describe("a pre-v0.10 fast_mode: true option", () => { + describe("Fast chosen as `fast`", () => { function createLegacyFastHarness(configDefaults: Record = {}) { const appServer = createFakeCodexAppServer({ "collaborationMode/list": () => ({ data: TEST_COLLABORATION_MODES }), @@ -789,7 +764,7 @@ describe("Codex app-server provider controls", () => { test("opens as the model's Fast tier and is stored as that tier", async () => { const { manager, create, shownSpeed, runTurn } = createLegacyFastHarness(); - const agentId = await create({ fast_mode: true }); + const agentId = await create({ service_tier: "fast" }); await manager.flush(); expect(shownSpeed(agentId)).toMatchObject({ @@ -802,7 +777,7 @@ describe("Codex app-server provider controls", () => { test("is shown as Fast after a stored record is resumed", async () => { const { manager, resume, shownSpeed, runTurn } = createLegacyFastHarness(); - const agentId = await resume({ fast_mode: true }); + const agentId = await resume({ service_tier: "fast" }); await manager.flush(); expect(shownSpeed(agentId)).toMatchObject({ own: { service_tier: "priority" }, ...FAST }); @@ -810,19 +785,19 @@ describe("Codex app-server provider controls", () => { }); test("from config.json defaults runs Fast and is shown as Fast", async () => { - const { create, shownSpeed, runTurn } = createLegacyFastHarness({ fast_mode: true }); + const { create, shownSpeed, runTurn } = createLegacyFastHarness({ service_tier: "fast" }); const agentId = await create(); expect(shownSpeed(agentId)).toMatchObject(FAST); await runTurn(agentId, "priority"); }); - test("set live runs Fast at once, and a later service_tier replaces it", async () => { + test("an older client's fast_mode runs Fast at once, and a later service_tier replaces it", async () => { const { manager, create, shownSpeed, runTurn } = createLegacyFastHarness(); const agentId = await create(); expect(shownSpeed(agentId).selected).toBe("default"); - await manager.setAgentOptions(agentId, { fast_mode: true }); + await manager.setAgentFeatureValues(agentId, { fast_mode: true }); expect(shownSpeed(agentId)).toMatchObject({ own: { service_tier: "priority" }, ...FAST }); await runTurn(agentId, "priority"); @@ -839,6 +814,26 @@ describe("Codex app-server provider controls", () => { await runTurn(agentId, "priority"); }); + test("a request carrying both speed keys follows service_tier", async () => { + const { manager, create, shownSpeed, runTurn } = createLegacyFastHarness(); + const agentId = await create({ service_tier: "priority" }); + + await manager.setAgentFeatureValues(agentId, { service_tier: "default", fast_mode: true }); + + expect(shownSpeed(agentId)).toMatchObject({ own: { service_tier: "default" } }); + await runTurn(agentId, "default"); + }); + + test("an older client turning fast_mode off selects Normal", async () => { + const { manager, create, shownSpeed, runTurn } = createLegacyFastHarness(); + const agentId = await create({ service_tier: "priority" }); + + await manager.setAgentFeatureValues(agentId, { fast_mode: false }); + + expect(shownSpeed(agentId)).toMatchObject({ own: { service_tier: "default" } }); + await runTurn(agentId, "default"); + }); + test("removing the speed returns the agent to Normal", async () => { const { create, manager, shownSpeed } = createLegacyFastHarness(); const agentId = await create({ service_tier: "priority" }); @@ -854,7 +849,7 @@ describe("Codex app-server provider controls", () => { const capture = createCapturedLogger(); const prompt = "secret prompt text should not be logged"; const { session } = await createConnectedSession( - { providerOptions: { fast_mode: true } }, + { providerOptions: { service_tier: "fast" } }, { logger: capture.logger }, ); 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 d1437fbc7c..7478d4d5f9 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 @@ -6188,7 +6188,7 @@ describe("Codex app-server provider", () => { test("emits a synthetic plan approval permission after a successful Codex plan turn", async () => { const session = createSession({ - providerOptions: { plan_mode: true, fast_mode: true }, + providerOptions: { plan_mode: true, service_tier: "fast" }, }); const events: AgentStreamEvent[] = []; session.subscribe((event) => events.push(event)); @@ -6258,7 +6258,7 @@ describe("Codex app-server provider", () => { test("does not complete Codex plan timeline cards while plan approval is pending", async () => { const session = createSession({ - providerOptions: { plan_mode: true, fast_mode: true }, + providerOptions: { plan_mode: true, service_tier: "fast" }, }); const events: AgentStreamEvent[] = []; session.subscribe((event) => events.push(event)); @@ -7107,7 +7107,7 @@ describe("Codex app-server provider", () => { test("approving a synthetic Codex plan permission disables plan mode, preserves fast mode, and returns follow-up prompt", async () => { const session = createSession({ - providerOptions: { plan_mode: true, fast_mode: true }, + providerOptions: { plan_mode: true, service_tier: "fast" }, }); const events: AgentStreamEvent[] = []; session.subscribe((event) => events.push(event)); @@ -7162,7 +7162,7 @@ describe("Codex app-server provider", () => { test("approving a synthetic Codex plan permission keeps fast mode disabled when it started disabled", async () => { const session = createSession({ - providerOptions: { plan_mode: true, fast_mode: false }, + providerOptions: { plan_mode: true, service_tier: "default" }, }); const events: AgentStreamEvent[] = []; session.subscribe((event) => events.push(event)); @@ -7191,7 +7191,7 @@ describe("Codex app-server provider", () => { selectedActionId: "implement", }); - expect(asInternals(session).serviceTier).toBeNull(); + expect(asInternals(session).serviceTier).toBe("default"); expect(asInternals(session).planModeEnabled).toBe(false); expect(events).toContainEqual({ type: "options_changed", @@ -7205,7 +7205,7 @@ describe("Codex app-server provider", () => { test("follow-up implementation turn keeps fast service tier and switches back to code collaboration mode", async () => { const session = createSession({ - providerOptions: { plan_mode: true, fast_mode: true }, + providerOptions: { plan_mode: true, service_tier: "fast" }, }); asInternals(session).collaborationModes = [ { 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 085100b142..31c6f8e805 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 @@ -3651,7 +3651,7 @@ export class CodexAppServerAgentSession implements AgentSession { this.speedModels = CodexModelListResponseSchema.parse(await client.request("model/list", {})).data ?? []; - this.normalizeLegacySpeed(); + this.normalizeSpeed(); await this.loadResolvedWorkspaceWrite(); await this.loadCollaborationModes(); await this.loadSkills(); @@ -3865,23 +3865,18 @@ export class CodexAppServerAgentSession implements AgentSession { } /** - * COMPAT(codexFastPreference): added in v0.10.0, remove after 2027-03-29 once saved Fast - * preferences have migrated. A saved `fast_mode: true` or `service_tier: "fast"` runs as the - * model's Fast tier; the session says so in `openOptionsPatch`, so the options and the control agree. + * A saved `service_tier: "fast"` runs as the model's Fast tier, and a tier the opening model + * lacks runs as Normal; the session says so in `openOptionsPatch`, so the options and the + * control agree (as when the model is switched later). */ - private normalizeLegacySpeed(): void { + private normalizeSpeed(): void { const requested = this.requestedServiceTier; const resolved = this.resolveServiceTier(requested); - const legacy = this.providerOptions.fast_mode !== undefined || requested !== resolved; - if (!legacy) return; - // A tier the opening model lacks runs as Normal, and the options say so (as when the model - // is switched later). - const runs = resolved ?? (requested === null ? null : "default"); - if (runs === null) return; - const { fast_mode: _legacy, ...rest } = this.providerOptions; - this.providerOptions = { ...rest, service_tier: runs }; + if (requested === resolved) return; + const runs = resolved ?? "default"; + this.providerOptions = { ...this.providerOptions, service_tier: runs }; this.requestedServiceTier = runs; - this.openOptionsPatch = { service_tier: runs, fast_mode: null }; + this.openOptionsPatch = { service_tier: runs }; } private get serviceTier(): string | null { @@ -3892,7 +3887,7 @@ export class CodexAppServerAgentSession implements AgentSession { // Until the model list arrives there is nothing to check the request against. if (this.speedModels.length === 0 || requested === "default") return requested; const tiers = this.currentServiceTiers(); - // COMPAT(codexFastPreference): added in v0.10.0, remove after 2027-03-29 once saved Fast preferences have migrated. + // `fast` names whichever tier the model calls Fast. if (requested === "fast" && !tiers.some((tier) => tier.id === "fast")) { return tiers.find((tier) => tier.name.toLowerCase() === "fast")?.id ?? null; } @@ -4682,12 +4677,11 @@ export class CodexAppServerAgentSession implements AgentSession { if (requested === null || this.speedModels.length === 0) return; if (this.isServiceTierAvailable(requested)) return; this.requestedServiceTier = "default"; - const { fast_mode: legacyFast, ...running } = this.providerOptions; - this.providerOptions = { ...running, service_tier: "default" }; + this.providerOptions = { ...this.providerOptions, service_tier: "default" }; this.emitEvent({ type: "options_changed", provider: CODEX_PROVIDER, - patch: { service_tier: "default", ...(legacyFast === undefined ? {} : { fast_mode: null }) }, + patch: { service_tier: "default" }, }); } @@ -4707,68 +4701,39 @@ export class CodexAppServerAgentSession implements AgentSession { provider: "codex", patch: options, current: this.providerOptions, - live: ["service_tier", "fast_mode", "plan_mode"], + live: ["service_tier", "plan_mode"], }); - const { fast_mode: _legacyFast, ...running } = this.providerOptions; const valid = validateProviderOptions( "codex", CodexProviderOptionsSchema, - applyProviderOptionsPatch({ options: running, patch: options }), + applyProviderOptionsPatch({ options: this.providerOptions, patch: options }), ) ?? {}; - let requestedTier = this.requestedServiceTier; - if ("service_tier" in options || "fast_mode" in options) { - // COMPAT(codexFastPreference): added in v0.10.0, remove after 2027-03-29 once clients use service_tier. - requestedTier = this.requestedTierFrom(options); - if ( - requestedTier !== this.requestedServiceTier && - !this.isServiceTierAvailable(requestedTier) - ) { + const requested = "service_tier" in options ? readCodexServiceTier(options) : null; + let accepted = this.requestedServiceTier; + if ("service_tier" in options) { + if (requested !== accepted && !this.isServiceTierAvailable(requested)) { throw new Error( - `Codex speed '${String(requestedTier)}' is not available for model '${this.config.model ?? "default"}'`, + `Codex speed '${String(requested)}' is not available for model '${this.config.model ?? "default"}'`, ); } + // The session keeps the catalog's id for the tier, so a later model switch sees what runs. + accepted = requested && (this.resolveServiceTier(requested) ?? requested); } - // The session keeps the catalog's id for the tier, so a later model switch sees what runs. - const accepted = requestedTier && (this.resolveServiceTier(requestedTier) ?? requestedTier); - const { fast_mode: _folded, ...kept } = valid; - this.providerOptions = { ...kept, ...(accepted ? { service_tier: accepted } : {}) }; + this.providerOptions = { ...valid, ...(accepted ? { service_tier: accepted } : {}) }; this.requestedServiceTier = accepted || null; if ("plan_mode" in options) this.setPlanMode(options.plan_mode === true); this.cachedRuntimeInfo = null; - // COMPAT(codexFastPreference): added in v0.10.0, remove after 2027-03-29 once clients use service_tier. - const tier = this.serviceTier; - // `fast_mode` is folded into the speed, so the options no longer hold it. - const folded = options.fast_mode == null ? {} : { fast_mode: null }; - if (options.fast_mode != null && !("service_tier" in options)) { - return tier === null ? folded : { service_tier: tier, ...folded }; - } // The catalog may name the tier differently from the request (`fast` is the Fast tier's id). - if ( - "service_tier" in options && - tier !== null && - requestedTier !== null && - tier !== requestedTier - ) { - return { service_tier: tier, ...folded }; - } - return folded; - } - - private requestedTierFrom(options: ProviderOptions): string | null { - if ("service_tier" in options) return readCodexServiceTier(options); - // Opening moved a legacy Fast into `service_tier`, so removing the alias changes no speed. - if (options.fast_mode === null) return this.requestedServiceTier; - return options.fast_mode ? "fast" : "default"; + const tier = this.serviceTier; + return tier !== null && requested !== null && tier !== requested ? { service_tier: tier } : {}; } private isServiceTierAvailable(tier: string | null): boolean { if (tier === null || tier === "default") return true; const tiers = this.currentServiceTiers(); - // COMPAT(codexFastPreference): added in v0.10.0, remove after 2027-03-29 once saved Fast preferences have migrated. - const legacyFast = - tier === "fast" && tiers.some((entry) => entry.name.toLowerCase() === "fast"); - return legacyFast || tiers.some((entry) => entry.id === tier); + const namesFast = tier === "fast" && tiers.some((entry) => entry.name.toLowerCase() === "fast"); + return namesFast || tiers.some((entry) => entry.id === tier); } private setPlanMode(enabled: boolean): void { @@ -5494,12 +5459,7 @@ export class CodexAppServerAgentSession implements AgentSession { private buildCodexInnerConfig(): Record | null { const innerConfig: Record = {}; - const { - service_tier: _tier, - plan_mode: _plan, - fast_mode: _fast, - ...codexConfig - } = this.providerOptions; + const { service_tier: _tier, plan_mode: _plan, ...codexConfig } = this.providerOptions; Object.assign(innerConfig, codexConfig); if (this.deps.customCodexConfig) { Object.assign(innerConfig, this.deps.customCodexConfig); 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 d2ffdece1c..096ffc6c64 100644 --- a/packages/server/src/server/agent/providers/codex-feature-definitions.ts +++ b/packages/server/src/server/agent/providers/codex-feature-definitions.ts @@ -51,7 +51,5 @@ export function buildCodexControls(input: { export function readCodexServiceTier(options: Record | undefined): string | null { const tier = options?.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 options?.fast_mode ? "fast" : null; + return typeof tier === "string" ? tier : null; } diff --git a/packages/server/src/server/agent/providers/codex/options.ts b/packages/server/src/server/agent/providers/codex/options.ts index 8d540d4130..83e9b15017 100644 --- a/packages/server/src/server/agent/providers/codex/options.ts +++ b/packages/server/src/server/agent/providers/codex/options.ts @@ -40,8 +40,6 @@ export const CodexProviderOptionsSchema = z // Composer controls, not Codex config: the provider reads them and keeps them out of `config`. service_tier: z.string().optional(), plan_mode: z.boolean().optional(), - // COMPAT(codexFastPreference): added in v0.10.0, remove after 2027-03-29 once saved Fast preferences have migrated. - fast_mode: z.boolean().optional(), approval_policy: ApprovalPolicySchema.optional(), sandbox_mode: z.enum(["read-only", "workspace-write", "danger-full-access"]).optional(), sandbox_workspace_write: z diff --git a/packages/server/src/server/agent/tools/paseo-tools.ts b/packages/server/src/server/agent/tools/paseo-tools.ts index 8a0409b188..4e40c9b249 100644 --- a/packages/server/src/server/agent/tools/paseo-tools.ts +++ b/packages/server/src/server/agent/tools/paseo-tools.ts @@ -18,7 +18,7 @@ import type { AgentMode, AgentProvider, AgentSessionConfig } from "../agent-sdk- import type { AgentManager } from "../agent-manager.js"; import { ActiveTurnBehaviorSchema, AgentProfileSchema } from "@getpaseo/protocol/messages"; import type { DaemonConfigStore } from "../../daemon-config-store.js"; -import { featuresFromControls } from "../option-controls.js"; +import { convertLegacyOptions, featuresFromControls } from "../option-controls.js"; import { AgentFeatureSchema, AgentPermissionRequestPayloadSchema, @@ -1636,7 +1636,11 @@ function createToolCatalog( cwd: resolvedArgs.cwd, workspaceId: resolvedArgs.workspaceId, thinking: parsedArgs.settings?.thinkingOptionId, - providerOptions: parsedArgs.settings?.features, + // COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01. `settings.features` is an options map. + providerOptions: convertLegacyOptions({ + provider: selectedProvider, + options: parsedArgs.settings?.features, + }), labels: { ...parsedArgs.labels, ...(callerAgentId && !resolvedArgs.detached && options.serverId @@ -2288,7 +2292,7 @@ function createToolCatalog( await agentManager.setAgentThinkingOption(agentId, settings.thinkingOptionId); } if (settings?.features) { - await agentManager.setAgentOptions(agentId, settings.features); + await agentManager.setAgentFeatureValues(agentId, settings.features); } await updateAgentCommand({ agentManager }, { agentId, name, labels }); @@ -3126,7 +3130,14 @@ function createToolCatalog( ...(settings?.modeId ? { modeId: settings.modeId } : {}), ...(selectedModel ? { model: selectedModel } : {}), ...(settings?.thinkingOptionId ? { thinkingOptionId: settings.thinkingOptionId } : {}), - ...(settings?.features ? { providerOptions: settings.features } : {}), + ...(settings?.features + ? { + providerOptions: convertLegacyOptions({ + provider: providerId, + options: settings.features, + }), + } + : {}), }); return { content: [], diff --git a/packages/server/src/server/daemon-config-store.test.ts b/packages/server/src/server/daemon-config-store.test.ts index 02e64236fd..7a7f567850 100644 --- a/packages/server/src/server/daemon-config-store.test.ts +++ b/packages/server/src/server/daemon-config-store.test.ts @@ -268,7 +268,7 @@ describe("DaemonConfigStore", () => { id: "p", name: "P", provider: "codex", - options: { sandbox_mode: "read-only", fast_mode: false }, + options: { sandbox_mode: "read-only", plan_mode: false }, }, ], }); @@ -279,7 +279,7 @@ describe("DaemonConfigStore", () => { await store.patch({ agentProfiles: [ - { id: "p", name: "Renamed", provider: "codex", featureValues: { fast_mode: true } }, + { id: "p", name: "Renamed", provider: "codex", featureValues: { plan_mode: true } }, ], }); @@ -288,7 +288,7 @@ describe("DaemonConfigStore", () => { id: "p", name: "Renamed", provider: "codex", - options: { sandbox_mode: "read-only", fast_mode: true }, + options: { sandbox_mode: "read-only", plan_mode: true }, }, ]); }); @@ -303,7 +303,7 @@ describe("DaemonConfigStore", () => { id: "p", name: "Renamed", provider: "codex", - options: { sandbox_mode: "read-only", fast_mode: false }, + options: { sandbox_mode: "read-only", plan_mode: false }, }, ]); }); @@ -340,7 +340,7 @@ describe("DaemonConfigStore", () => { name: "P", provider: "codex", options: { sandbox_mode: "workspace-write" }, - featureValues: { fast_mode: true }, + featureValues: { plan_mode: true }, }, ], }); @@ -350,7 +350,26 @@ describe("DaemonConfigStore", () => { id: "p", name: "P", provider: "codex", - options: { sandbox_mode: "workspace-write", fast_mode: true }, + options: { sandbox_mode: "workspace-write", plan_mode: true }, + }, + ]); + }); + + test("converts a Codex profile's legacy fast_mode to service_tier", async () => { + const store = storeWithProfile(); + + await store.patch({ + agentProfiles: [ + { id: "p", name: "P", provider: "codex", featureValues: { fast_mode: true } }, + ], + }); + + expect(store.get().agentProfiles).toEqual([ + { + id: "p", + name: "P", + provider: "codex", + options: { sandbox_mode: "read-only", plan_mode: false, service_tier: "fast" }, }, ]); }); @@ -360,7 +379,7 @@ describe("DaemonConfigStore", () => { await store.patch({ agentProfiles: [ - { id: "p", name: "P", provider: "codex", featureValues: { fast_mode: null } }, + { id: "p", name: "P", provider: "codex", featureValues: { plan_mode: null } }, ], }); diff --git a/packages/server/src/server/hub/execution-controller.ts b/packages/server/src/server/hub/execution-controller.ts index 23343b4e61..4f0843c8a1 100644 --- a/packages/server/src/server/hub/execution-controller.ts +++ b/packages/server/src/server/hub/execution-controller.ts @@ -169,7 +169,8 @@ export class HubExecutionController { modeId: message.modeId, thinkingOptionId: message.thinkingOptionId, // COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01. - providerOptions: foldFeatureValues(message).providerOptions, + providerOptions: foldFeatureValues({ config: message, provider: message.provider }) + .providerOptions, toolPolicy: message.toolPolicy, env: message.env, mcpServers: message.mcpServers, diff --git a/packages/server/src/server/schedule/service.ts b/packages/server/src/server/schedule/service.ts index c8c9db2263..c167a4839a 100644 --- a/packages/server/src/server/schedule/service.ts +++ b/packages/server/src/server/schedule/service.ts @@ -1099,7 +1099,7 @@ function buildScheduleAgentConfig( thinkingOptionId: config.thinkingOptionId, title: config.title, // COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01. - providerOptions: foldFeatureValues(config).providerOptions, + providerOptions: foldFeatureValues({ config, provider: config.provider }).providerOptions, systemPrompt: config.systemPrompt, mcpServers: config.mcpServers as AgentSessionConfig["mcpServers"], }; diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 7c4f3405a2..26f6cd5614 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -1067,6 +1067,7 @@ export class Session { (await setAgentModeCommand({ agentManager }, { agentId, modeId })).notice, setModel: (agentId, modelId) => agentManager.setAgentModel(agentId, modelId), setOptions: (agentId, patch) => agentManager.setAgentOptions(agentId, patch), + setFeatureValues: (agentId, values) => agentManager.setAgentFeatureValues(agentId, values), setThinking: (agentId, thinkingOptionId) => agentManager.setAgentThinkingOption(agentId, thinkingOptionId), }, diff --git a/packages/server/src/server/session/agent-config/agent-config-session.test.ts b/packages/server/src/server/session/agent-config/agent-config-session.test.ts index d6589683a7..084cfcbe45 100644 --- a/packages/server/src/server/session/agent-config/agent-config-session.test.ts +++ b/packages/server/src/server/session/agent-config/agent-config-session.test.ts @@ -13,6 +13,7 @@ class FakeAgentConfigOperations implements AgentConfigOperations { readonly modeCalls: Array<{ agentId: string; modeId: string }> = []; readonly modelCalls: Array<{ agentId: string; modelId: string | null }> = []; readonly optionsCalls: Array<{ agentId: string; patch: Record }> = []; + readonly featureValueCalls: Array<{ agentId: string; values: Record }> = []; readonly thinkingCalls: Array<{ agentId: string; thinkingOptionId: string | null }> = []; /** Cross-operation ordering, which the per-operation arrays above cannot show. */ readonly callLog: string[] = []; @@ -45,6 +46,12 @@ class FakeAgentConfigOperations implements AgentConfigOperations { if (this.failWith) throw this.failWith; } + async setFeatureValues(agentId: string, values: Record): Promise { + this.featureValueCalls.push({ agentId, values }); + this.callLog.push("featureValues"); + if (this.failWith) throw this.failWith; + } + async setThinking( agentId: string, thinkingOptionId: string | null, @@ -217,7 +224,9 @@ describe("AgentConfigSession", () => { requestId: "req-1", }); - expect(operations.optionsCalls).toEqual([{ agentId: "agent-1", patch: { web_search: true } }]); + expect(operations.featureValueCalls).toEqual([ + { agentId: "agent-1", values: { web_search: true } }, + ]); expect(emitted).toEqual([ { type: "set_agent_feature_response", @@ -357,7 +366,7 @@ describe("AgentConfigSession", () => { ]); }); - test("apply: folds legacy featureValues into the options patch, options winning", async () => { + test("apply: legacy featureValues are applied first and options after, so options win", async () => { const { subsystem, operations } = makeSubsystem(); await subsystem.handleAgentConfigApplyRequest({ @@ -370,9 +379,11 @@ describe("AgentConfigSession", () => { requestId: "req-1", }); - expect(operations.optionsCalls).toEqual([ - { agentId: "agent-1", patch: { fast_mode: false, service_tier: "fast" } }, + expect(operations.callLog).toEqual(["featureValues", "options"]); + expect(operations.featureValueCalls).toEqual([ + { agentId: "agent-1", values: { fast_mode: true, service_tier: "fast" } }, ]); + expect(operations.optionsCalls).toEqual([{ agentId: "agent-1", patch: { fast_mode: false } }]); }); test("apply: leaves omitted fields alone", async () => { diff --git a/packages/server/src/server/session/agent-config/agent-config-session.ts b/packages/server/src/server/session/agent-config/agent-config-session.ts index b7ba697670..9f857a1103 100644 --- a/packages/server/src/server/session/agent-config/agent-config-session.ts +++ b/packages/server/src/server/session/agent-config/agent-config-session.ts @@ -30,6 +30,8 @@ export interface AgentConfigOperations { setMode(agentId: string, modeId: string): Promise; setModel(agentId: string, modelId: string | null): Promise; setOptions(agentId: string, patch: ProviderOptionsPatch): Promise; + /** COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01. Older clients' values. */ + setFeatureValues(agentId: string, values: Record): Promise; setThinking( agentId: string, thinkingOptionId: string | null, @@ -115,7 +117,7 @@ export class AgentConfigSession { failureText: "Failed to set agent feature", // COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01. A feature is a one-key options patch. run: async () => { - await this.operations.setOptions(agentId, { [featureId]: value }); + await this.operations.setFeatureValues(agentId, { [featureId]: value }); return undefined; }, emitResponse: (payload) => this.host.emit({ type: "set_agent_feature_response", payload }), @@ -192,9 +194,11 @@ export class AgentConfigSession { notice ??= thinkingNotice; } // COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01. `featureValues` is an options patch. - const optionsPatch = { ...config.featureValues, ...config.options }; - if (Object.keys(optionsPatch).length > 0) { - await this.operations.setOptions(agentId, optionsPatch); + if (config.featureValues && Object.keys(config.featureValues).length > 0) { + await this.operations.setFeatureValues(agentId, config.featureValues); + } + if (config.options && Object.keys(config.options).length > 0) { + await this.operations.setOptions(agentId, config.options); } return notice; diff --git a/packages/server/src/server/session/provider/provider-catalog-session.ts b/packages/server/src/server/session/provider/provider-catalog-session.ts index 041d7182fc..ee4f46b3c6 100644 --- a/packages/server/src/server/session/provider/provider-catalog-session.ts +++ b/packages/server/src/server/session/provider/provider-catalog-session.ts @@ -347,7 +347,10 @@ export class ProviderCatalogSession { providerOptions?: ProviderOptions; }): AgentSessionConfig { // COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01. - const { providerOptions } = foldFeatureValues(draftConfig); + const { providerOptions } = foldFeatureValues({ + config: draftConfig, + provider: draftConfig.provider, + }); return { provider: draftConfig.provider, cwd: expandTilde(draftConfig.cwd),