refactor(server): Codex's legacy fast_mode converts to service_tier where old data enters

Stored records, config.json defaults, profiles, older clients' requests, MCP settings and
plugin hooks go through convertLegacyOptions as they come in. Past that point the defaults
merge, the manager and the Codex session never see fast_mode for Codex, so the alias branch
in the defaults merge and Codex's fast_mode handling are deleted.
This commit is contained in:
Mohamed Boudra
2026-11-01 19:33:45 +01:00
parent 593773a381
commit 2fddf0b98c
25 changed files with 325 additions and 229 deletions
+8 -1
View File
@@ -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.
@@ -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<string, unknown> | 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 },
);
@@ -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<string, unknown>): Promise<void> {
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;
@@ -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");
@@ -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) } : {}),
};
}
@@ -221,7 +221,10 @@ async function resolveSessionCreateAgent(
input: CreateAgentFromSessionInput,
): Promise<ResolvedCreateAgent> {
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,
@@ -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",
@@ -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",
});
});
});
@@ -33,6 +33,35 @@ export interface LegacyFeatureValues {
featureValues?: Record<string, unknown>;
}
/**
* 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<O> {
provider: string | undefined;
options: O;
}
export function convertLegacyOptions(
input: LegacyOptionsInput<Record<string, unknown>>,
): Record<string, unknown>;
export function convertLegacyOptions(
input: LegacyOptionsInput<Record<string, unknown> | undefined>,
): Record<string, unknown> | undefined;
export function convertLegacyOptions(
input: LegacyOptionsInput<Record<string, unknown> | undefined>,
): Record<string, unknown> | 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<string, unknown> | null;
},
>(input: T): Omit<T, "featureValues"> {
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<T, "featureValues"> {
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<string, unknown> },
T extends {
provider?: string;
providerOptions?: ProviderOptions | null;
featureValues?: Record<string, unknown>;
},
>(input: { returned: T; sent: T }): Omit<T, "featureValues"> {
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<T extends ProfileOptionFields>(input: {
source: "stored" | "client write";
}): Omit<T, "featureValues"> {
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;
}
@@ -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<string, { canonical: string; value: (legacy: unknown) => 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);
@@ -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", () => {
@@ -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,
@@ -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<string, unknown> = {}) {
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 },
);
@@ -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 = [
{
@@ -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<string, unknown> | null {
const innerConfig: Record<string, unknown> = {};
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);
@@ -51,7 +51,5 @@ export function buildCodexControls(input: {
export function readCodexServiceTier(options: Record<string, unknown> | 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;
}
@@ -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
@@ -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: [],
@@ -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 } },
],
});
@@ -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,
@@ -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"],
};
+1
View File
@@ -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),
},
@@ -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<string, unknown> }> = [];
readonly featureValueCalls: Array<{ agentId: string; values: Record<string, unknown> }> = [];
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<string, unknown>): Promise<void> {
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 () => {
@@ -30,6 +30,8 @@ export interface AgentConfigOperations {
setMode(agentId: string, modeId: string): Promise<AgentProviderNotice | null>;
setModel(agentId: string, modelId: string | null): Promise<void>;
setOptions(agentId: string, patch: ProviderOptionsPatch): Promise<void>;
/** COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01. Older clients' values. */
setFeatureValues(agentId: string, values: Record<string, unknown>): Promise<void>;
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;
@@ -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),