fix(server): the legacy Codex Fast alias folds into one speed on apply and in configured defaults

A patch with both speed keys left the alias in the stored options, so a reload started Fast again after the speed was removed; the apply now reports the folded alias. A configured fast_mode default now supplies the service_tier default, so removing an agent's own speed falls back to the configured Fast like opening does.
This commit is contained in:
Mohamed Boudra
2026-11-01 18:49:11 +01:00
parent 1b5731cebe
commit e4d4ba7489
4 changed files with 52 additions and 14 deletions
@@ -100,28 +100,39 @@ export function mergeProviderOptions(
}
// 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, string>> = {
codex: { fast_mode: "service_tier" },
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. An agent's own legacy alias (a saved `fast_mode`) replaces the default of the key it
* stands for, so a configured `service_tier` does not override the agent's own Fast preference.
* 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 { defaults, own } = input;
const aliases = LEGACY_OPTION_ALIASES[input.provider];
if (!aliases || !defaults || !own) return mergeProviderOptions(defaults, own);
const kept = { ...defaults };
for (const [alias, canonical] of Object.entries(aliases)) {
if (own[alias] !== undefined && own[canonical] === undefined) delete kept[canonical];
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(kept, own);
return mergeProviderOptions(defaults, input.own);
}
function isPlainObject(value: unknown): value is ProviderOptions {
@@ -809,6 +809,20 @@ test("a saved legacy Codex Fast preference wins over a configured service_tier d
expect(client.effectiveOptions?.({})).toEqual({ service_tier: "default", plan_mode: false });
});
test("a configured legacy Codex Fast default supplies the service_tier default", () => {
const registry = buildProviderRegistry(logger, {
providerOverrides: { codex: { options: { fast_mode: true } } },
});
const client = registry.codex.createClient(logger);
// Removing an agent's own speed falls back to the configured Fast.
expect(client.effectiveOptions?.({})).toEqual({ service_tier: "fast" });
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", () => {
const registry = buildProviderRegistry(logger, {
providerOverrides: {
@@ -364,6 +364,17 @@ 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 () => {
const { session, appServer } = await createConnectedSession();
try {
@@ -4738,8 +4738,10 @@ export class CodexAppServerAgentSession implements AgentSession {
this.cachedRuntimeInfo = null;
// COMPAT(codexFastPreference): added in v0.10.0, remove after 2027-03-29 once clients use service_tier.
const tier = this.serviceTier;
if ("fast_mode" in options && !("service_tier" in options) && options.fast_mode !== null) {
return tier === null ? {} : { service_tier: tier, fast_mode: null };
// `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 (
@@ -4748,9 +4750,9 @@ export class CodexAppServerAgentSession implements AgentSession {
requestedTier !== null &&
tier !== requestedTier
) {
return { service_tier: tier };
return { service_tier: tier, ...folded };
}
return {};
return folded;
}
private requestedTierFrom(options: ProviderOptions): string | null {