refactor(server): the agent manager converts Codex's legacy fast_mode for the adapter behind the provider id

The rename is adapter-specific, so it lives where the provider id resolves to its adapter:
AgentManager converts an agent's own options on create, resume, import, reload, apply and
draft controls, following derivedFromProviderId, so a custom provider extending codex keeps
working with a saved or requested fast_mode. Storage, MCP, schedules, hub and profiles pass
options through. The featureValues-to-options fold stays provider-agnostic at the legacy edges.
This commit is contained in:
Mohamed Boudra
2026-11-01 19:47:11 +01:00
parent f783c46301
commit 43f7dc4ad1
19 changed files with 224 additions and 274 deletions
+12 -7
View File
@@ -59,13 +59,18 @@ 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.
- **Old data converts in the agent manager.** Codex used to take `fast_mode`; it takes
`service_tier` now. `AgentManager` runs `convertLegacyOptions` (`option-controls.ts`) on an
agent's own options when it creates, resumes, imports, reloads or applies to it, and on draft
controls, before defaults merge and before anything is stored (`true` is `"fast"`, `false` is
`"default"`, and a `service_tier` in the same input wins). The manager converts for the adapter
behind the provider id, following `derivedFromProviderId`, so a custom provider with
`extends: "codex"` is converted too. Configured defaults convert in the provider registry. A
stored record keeps `fast_mode` until the agent is next opened or applied to, and is stored
converted from then on. Nothing past the manager 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. The `featureValues` to `options` fold is separate
and provider-agnostic; it stays at the protocol and legacy edges.
- **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.
@@ -2448,7 +2448,7 @@ test("listDraftControls uses explicit model config without default model fetchin
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-draft-features-"));
const storagePath = join(workdir, "agents");
const storage = new AgentStorage(storagePath, logger);
const draftControl = createControl({ key: "fast_mode", label: "Fast mode" });
const draftControl = createControl({ key: "web_search", label: "Fast mode" });
class DraftFeatureClient extends TestAgentClient {
fetchCatalogCalls = 0;
createSessionCalls = 0;
@@ -10061,7 +10061,7 @@ test("respondToPermission applies provider option changes and refreshes runtime
get controls(): AgentOptionControl[] {
return [
createControl({ key: "fast_mode", label: "Fast" }),
createControl({ key: "web_search", label: "Fast" }),
createControl({ key: "plan_mode", label: "Plan" }),
];
}
@@ -10115,7 +10115,7 @@ test("respondToPermission applies provider option changes and refreshes runtime
{
provider: "codex",
cwd: workdir,
providerOptions: { fast_mode: true, plan_mode: true },
providerOptions: { web_search: true, plan_mode: true },
},
undefined,
{ workspaceId: undefined },
@@ -10140,14 +10140,14 @@ test("respondToPermission applies provider option changes and refreshes runtime
const updated = manager.getAgent(snapshot.id);
expect(updated?.pendingPermissions.size).toBe(0);
expect(updated?.effectiveOptions).toEqual({ fast_mode: true, plan_mode: false });
expect(updated?.effectiveOptions).toEqual({ web_search: true, plan_mode: false });
expect(updated?.runtimeInfo).toMatchObject({
model: "gpt-5.4",
extra: { collaborationMode: "Code" },
});
const persisted = await storage.get(snapshot.id);
expect(persisted?.config?.providerOptions).toEqual({ fast_mode: true, plan_mode: false });
expect(persisted?.config?.providerOptions).toEqual({ web_search: true, plan_mode: false });
});
test("respondToPermission emits refreshed state before permission_resolved", async () => {
@@ -10168,7 +10168,7 @@ test("respondToPermission emits refreshed state before permission_resolved", asy
];
get controls(): AgentOptionControl[] {
return [createControl({ key: "fast_mode", label: "Fast" })];
return [createControl({ key: "web_search", label: "Fast" })];
}
override async getRuntimeInfo() {
@@ -10192,7 +10192,7 @@ test("respondToPermission emits refreshed state before permission_resolved", asy
this.pushEvent({
type: "options_changed",
provider: this.provider,
patch: { fast_mode: false },
patch: { web_search: false },
});
this.pushEvent({
type: "permission_resolved",
@@ -10224,7 +10224,7 @@ test("respondToPermission emits refreshed state before permission_resolved", asy
{
provider: "codex",
cwd: workdir,
providerOptions: { fast_mode: true },
providerOptions: { web_search: true },
},
undefined,
{ workspaceId: undefined },
@@ -10237,7 +10237,7 @@ test("respondToPermission emits refreshed state before permission_resolved", asy
}
if (event.type === "agent_state" && event.agent.id === snapshot.id) {
seen.push(
`state:${event.agent.currentModeId}:${String(event.agent.effectiveOptions?.fast_mode)}`,
`state:${event.agent.currentModeId}:${String(event.agent.effectiveOptions?.web_search)}`,
);
return;
}
@@ -12802,26 +12802,26 @@ describe("agent options", () => {
}
test("config.json defaults show as effective options without entering the agent's own options", async () => {
const { manager, agentId } = await createOptionsAgent({ fast_mode: true });
const { manager, agentId } = await createOptionsAgent({ web_search: true });
const agent = manager.getAgent(agentId);
expect(agent?.config.providerOptions).toEqual({ fast_mode: true });
expect(agent?.effectiveOptions).toEqual({ service_tier: "default", fast_mode: true });
expect(toAgentPayload(agent!).options).toEqual({ service_tier: "default", fast_mode: true });
expect(agent?.config.providerOptions).toEqual({ web_search: true });
expect(agent?.effectiveOptions).toEqual({ service_tier: "default", web_search: true });
expect(toAgentPayload(agent!).options).toEqual({ service_tier: "default", web_search: true });
});
test("setAgentOptions hands the provider only the keys the patch sets", async () => {
const { manager, client, storage, agentId } = await createOptionsAgent({ fast_mode: true });
const { manager, client, storage, agentId } = await createOptionsAgent({ web_search: true });
await manager.setAgentOptions(agentId, { service_tier: "fast" });
expect(client.session?.applied).toEqual([{ service_tier: "fast" }]);
const agent = manager.getAgent(agentId);
expect(agent?.config.providerOptions).toEqual({ fast_mode: true, service_tier: "fast" });
expect(agent?.effectiveOptions).toEqual({ service_tier: "fast", fast_mode: true });
expect(agent?.config.providerOptions).toEqual({ web_search: true, service_tier: "fast" });
expect(agent?.effectiveOptions).toEqual({ service_tier: "fast", web_search: true });
await manager.flush();
expect((await storage.get(agentId))?.config?.providerOptions).toEqual({
fast_mode: true,
web_search: true,
service_tier: "fast",
});
});
@@ -12879,23 +12879,23 @@ describe("agent options", () => {
});
test("a removal with no default is sent as null", async () => {
const { manager, client, agentId } = await createOptionsAgent({ fast_mode: true });
const { manager, client, agentId } = await createOptionsAgent({ web_search: true });
await manager.setAgentOptions(agentId, { fast_mode: null });
await manager.setAgentOptions(agentId, { web_search: null });
expect(client.session?.applied).toEqual([{ fast_mode: null }]);
expect(client.session?.applied).toEqual([{ web_search: null }]);
expect(manager.getAgent(agentId)?.config.providerOptions).toBeUndefined();
});
test("a value the provider kept through a removal is stored", async () => {
const { manager, client, agentId } = await createOptionsAgent({ fast_mode: true });
client.session!.ended = { fast_mode: true };
const { manager, client, agentId } = await createOptionsAgent({ web_search: true });
client.session!.ended = { web_search: true };
await manager.setAgentOptions(agentId, { fast_mode: null });
await manager.setAgentOptions(agentId, { web_search: null });
const agent = manager.getAgent(agentId);
expect(agent?.config.providerOptions).toEqual({ fast_mode: true });
expect(agent?.effectiveOptions).toEqual({ service_tier: "default", fast_mode: true });
expect(agent?.config.providerOptions).toEqual({ web_search: true });
expect(agent?.effectiveOptions).toEqual({ service_tier: "default", web_search: true });
});
test("what the provider ended up with is stored together with the patch", async () => {
@@ -12913,7 +12913,7 @@ describe("agent options", () => {
test("the difference a session opened with is stored before the agent is published", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-options-"));
const client = new OptionsClient();
client.openOptionsPatch = { service_tier: "slow", fast_mode: null };
client.openOptionsPatch = { service_tier: "slow", web_search: null };
const manager = new AgentManager({
clients: { codex: client },
registry: new AgentStorage(join(workdir, "agents"), logger),
@@ -12925,7 +12925,7 @@ describe("agent options", () => {
});
const agent = await manager.createAgent(
{ provider: "codex", cwd: workdir, providerOptions: { fast_mode: true } },
{ provider: "codex", cwd: workdir, providerOptions: { web_search: true } },
undefined,
{ workspaceId: undefined },
);
@@ -12996,18 +12996,18 @@ describe("agent options", () => {
const slow = manager.setAgentOptions(agentId, { sandbox_mode: "read-only" });
client.session!.applyGate = null;
await manager.setAgentOptions(agentId, { fast_mode: true });
await manager.setAgentOptions(agentId, { web_search: true });
release();
await slow;
expect(manager.getAgent(agentId)?.config.providerOptions).toEqual({
sandbox_mode: "read-only",
fast_mode: true,
web_search: true,
});
});
test("a stored agent that is not loaded still reports its effective options", async () => {
const { manager, agentId } = await createOptionsAgent({ fast_mode: true });
const { manager, agentId } = await createOptionsAgent({ web_search: true });
await manager.closeAgent(agentId);
const states: ManagedAgent[] = [];
manager.subscribe(
@@ -13019,14 +13019,14 @@ describe("agent options", () => {
await manager.markAgentUnread(agentId);
expect(states.at(-1)?.effectiveOptions).toEqual({ service_tier: "default", fast_mode: true });
expect(states.at(-1)?.effectiveOptions).toEqual({ service_tier: "default", web_search: true });
});
test("editing a provider default does not change the options of a loaded agent", async () => {
const { manager, client, agentId } = await createOptionsAgent({ fast_mode: true });
const { manager, client, agentId } = await createOptionsAgent({ web_search: true });
expect(manager.getAgent(agentId)?.effectiveOptions).toEqual({
service_tier: "default",
fast_mode: true,
web_search: true,
});
// The user edits the default in config.json while the agent is running.
@@ -13034,19 +13034,19 @@ describe("agent options", () => {
await manager.markAgentUnread(agentId);
const agent = manager.getAgent(agentId);
expect(agent?.effectiveOptions).toEqual({ service_tier: "default", fast_mode: true });
expect(toAgentPayload(agent!).options).toEqual({ service_tier: "default", fast_mode: true });
expect(agent?.effectiveOptions).toEqual({ service_tier: "default", web_search: true });
expect(toAgentPayload(agent!).options).toEqual({ service_tier: "default", web_search: true });
});
test("a provider that rejects an option leaves the stored options unchanged", async () => {
const { manager, client, agentId } = await createOptionsAgent({ fast_mode: false });
const { manager, client, agentId } = await createOptionsAgent({ web_search: false });
client.session!.rejectWith = new Error("fast mode unavailable");
await expect(manager.setAgentOptions(agentId, { fast_mode: true })).rejects.toThrow(
await expect(manager.setAgentOptions(agentId, { web_search: true })).rejects.toThrow(
"fast mode unavailable",
);
expect(manager.getAgent(agentId)?.config.providerOptions).toEqual({ fast_mode: false });
expect(manager.getAgent(agentId)?.config.providerOptions).toEqual({ web_search: false });
});
test("a provider whose session cannot apply options rejects a patch and stores nothing", async () => {
@@ -13069,16 +13069,16 @@ describe("agent options", () => {
logger,
});
const agent = await manager.createAgent(
{ provider: "codex", cwd: workdir, providerOptions: { fast_mode: false } },
{ provider: "codex", cwd: workdir, providerOptions: { web_search: false } },
undefined,
{ workspaceId: undefined },
);
await expect(manager.setAgentOptions(agent.id, { fast_mode: true })).rejects.toThrow(
/fast_mode.*cannot change on a running session/,
await expect(manager.setAgentOptions(agent.id, { web_search: true })).rejects.toThrow(
/web_search.*cannot change on a running session/,
);
expect(manager.getAgent(agent.id)?.config.providerOptions).toEqual({ fast_mode: false });
expect(manager.getAgent(agent.id)?.config.providerOptions).toEqual({ web_search: false });
});
test("agent.create.before hook featureValues fold into providerOptions", async () => {
@@ -1256,7 +1256,10 @@ export class AgentManager {
): Promise<{ controls: AgentOptionControl[]; options: ProviderOptions }> {
const normalizedConfig = await this.normalizeConfig(config, { resolveDefaultModel: false });
const client = this.requireClient(normalizedConfig.provider);
const options = this.effectiveOptionsFor(normalizedConfig.provider, config.providerOptions);
const options = this.effectiveOptionsFor(
normalizedConfig.provider,
this.convertLegacyOptions(normalizedConfig.provider, config.providerOptions),
);
if (!normalizedConfig.model && !client.listControls) {
return { controls: [], options };
}
@@ -1371,6 +1374,7 @@ export class AgentManager {
): Promise<ManagedAgent> {
this.assertAcceptingAgentRegistrations();
const resolvedAgentId = validateAgentId(agentId ?? this.idFactory(), "createAgent");
config = this.withConvertedOptions(config);
if (this.pluginLifecycle && !config.internal) {
const sent = exposeFeatureValuesToHook(config);
const request = await this.pluginLifecycle.before("agent.create.before", {
@@ -1380,7 +1384,11 @@ export class AgentManager {
// COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01. A plugin written
// against the previous SDK edits `config.featureValues`.
config = {
...foldHookFeatureValues({ returned: request.config, sent }),
...foldHookFeatureValues({
returned: request.config,
sent,
convert: (values) => this.convertLegacyOptions(request.config.provider, values),
}),
internal: config.internal,
};
options = { ...options, env: request.env };
@@ -2290,8 +2298,9 @@ export class AgentManager {
* one step once the provider accepts, over the options as they are at that moment, so concurrent
* writes to one agent resolve by arrival order.
*/
async setAgentOptions(agentId: string, patch: ProviderOptionsPatch): Promise<void> {
async setAgentOptions(agentId: string, requested: ProviderOptionsPatch): Promise<void> {
const agent = this.requireAgent(agentId);
const patch = this.convertLegacyOptions(agent.provider, requested);
let ended: ProviderOptionsPatch = {};
let sent: ProviderOptionsPatch = {};
if (Object.keys(patch).length > 0) {
@@ -2318,25 +2327,6 @@ 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(input: {
agentId: string;
values: Record<string, unknown>;
/** Options sent with the values; they win over a value of the same name. */
options?: Record<string, unknown>;
}): Promise<void> {
const { agentId, values, options } = input;
const provider = this.requireAgent(agentId).provider;
await this.setAgentOptions(agentId, {
...convertLegacyOptions({ provider, options: values }),
...options,
});
}
/** The config as the opened session left it: its own difference from the options it was given. */
private withOpenedOptions(input: {
config: AgentSessionConfig;
@@ -5699,15 +5689,46 @@ export class AgentManager {
}
}
/** The adapter a provider id runs on: a custom provider is named by the one it extends. */
private adapterOf(provider: AgentProvider): AgentProvider {
const seen = new Set<AgentProvider>();
let current = provider;
for (;;) {
seen.add(current);
const base = this.providerDefinitions.get(current)?.derivedFromProviderId;
if (!base || seen.has(base)) return current;
current = base;
}
}
/**
* Options older data carries are converted for the adapter the provider runs on, before
* defaults merge and before anything is stored; nothing past the manager sees the old keys.
*/
private convertLegacyOptions<T extends ProviderOptions | undefined>(
provider: AgentProvider,
options: T,
): T {
return convertLegacyOptions({ provider: this.adapterOf(provider), options }) as T;
}
private withConvertedOptions(config: AgentSessionConfig): AgentSessionConfig {
const providerOptions = this.convertLegacyOptions(config.provider, config.providerOptions);
return providerOptions === config.providerOptions ? config : { ...config, providerOptions };
}
private async prepareSessionConfig(
config: AgentSessionConfig,
agentId: string,
options: { env?: Record<string, string>; purpose?: AgentResumePurpose } = {},
): Promise<PreparedSessionConfig> {
const storedConfig = await this.normalizeConfig(stripInternalPaseoMcpServer(config), {
env: options.env,
purpose: options.purpose,
});
const storedConfig = await this.normalizeConfig(
this.withConvertedOptions(stripInternalPaseoMcpServer(config)),
{
env: options.env,
purpose: options.purpose,
},
);
const paseoToolPolicy = this.paseoToolsEnabled
? this.resolvePaseoToolPolicy(storedConfig.provider)
: { enabled: false };
@@ -218,20 +218,6 @@ 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, provider: record.provider }) } : { config }),
...(config ? { config: foldFeatureValues(config) } : { config }),
...((controls ?? features) ? { controls: controls ?? controlsFromFeatures(features) } : {}),
};
}
@@ -221,10 +221,7 @@ async function resolveSessionCreateAgent(
input: CreateAgentFromSessionInput,
): Promise<ResolvedCreateAgent> {
const trimmedPrompt = input.initialPrompt?.trim();
const builtSessionConfig = foldFeatureValues({
config: input.config,
provider: input.config.provider,
});
const builtSessionConfig = foldFeatureValues(input.config);
const resolvedCreateConfig = await dependencies.providerSnapshotManager.resolveCreateConfig({
cwd: builtSessionConfig.cwd,
provider: builtSessionConfig.provider,
@@ -258,7 +258,6 @@ 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),
@@ -1835,7 +1834,7 @@ describe("create_agent MCP tool", () => {
expect.objectContaining({
provider: "codex",
model: "gpt-5.4",
providerOptions: { service_tier: "fast" },
providerOptions: { fast_mode: true },
}),
undefined,
{ workspaceId: "workspace-created" },
@@ -3073,7 +3072,7 @@ describe("create_agent MCP tool", () => {
expect.objectContaining({
provider: "codex",
model: "gpt-5.4",
providerOptions: { service_tier: "fast" },
providerOptions: { fast_mode: true },
}),
undefined,
{
@@ -4718,10 +4717,7 @@ 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.setAgentFeatureValues).toHaveBeenCalledWith({
agentId: "agent-1",
values: { fast_mode: true },
});
expect(spies.agentManager.setAgentOptions).toHaveBeenCalledWith("agent-1", { fast_mode: true });
expect(spies.agentManager.updateAgentMetadata).toHaveBeenCalledWith("agent-1", {
title: "Updated agent",
labels: { role: "worker" },
@@ -4746,12 +4742,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.setAgentFeatureValues).not.toHaveBeenCalled();
expect(spies.agentManager.setAgentOptions).not.toHaveBeenCalled();
});
it("does not update metadata when runtime settings fail", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
spies.agentManager.setAgentFeatureValues.mockRejectedValue(new Error("unsupported feature"));
spies.agentManager.setAgentOptions.mockRejectedValue(new Error("unsupported feature"));
const server = await createAgentMcpServer({
agentManager,
agentStorage,
@@ -5994,7 +5990,7 @@ describe("provider MCP tools", () => {
modeId: "full-access",
model: "gpt-5.4",
thinkingOptionId: "high",
providerOptions: { service_tier: "fast" },
providerOptions: { fast_mode: true },
});
expect(response.structuredContent).toEqual({
provider: "codex",
@@ -1,9 +1,5 @@
import { describe, expect, test } from "vitest";
import {
convertLegacyOptions,
foldFeatureValues,
foldHookFeatureValues,
} from "./option-controls.js";
import { convertLegacyOptions, foldHookFeatureValues } from "./option-controls.js";
describe("convertLegacyOptions", () => {
test.each([
@@ -31,34 +27,27 @@ describe("convertLegacyOptions", () => {
});
});
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" });
});
describe("foldHookFeatureValues", () => {
const convert = (options: Record<string, unknown>) =>
convertLegacyOptions({ provider: "codex", options });
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 } };
test("a hook that edits featureValues.fast_mode sets service_tier", () => {
const sent = { providerOptions: {}, featureValues: {} };
const returned = { providerOptions: {}, featureValues: { fast_mode: true } };
expect(foldHookFeatureValues({ returned, sent }).providerOptions).toEqual({
expect(foldHookFeatureValues({ returned, sent, convert }).providerOptions).toEqual({
service_tier: "fast",
});
});
test("a hook that replaces the speed it was sent with fast_mode selects Fast", () => {
const sent = {
provider: "codex",
providerOptions: { service_tier: "default" },
featureValues: { service_tier: "default" },
};
const returned = { ...sent, featureValues: { fast_mode: true } };
expect(foldHookFeatureValues({ returned, sent }).providerOptions).toEqual({
expect(foldHookFeatureValues({ returned, sent, convert }).providerOptions).toEqual({
service_tier: "fast",
});
});
@@ -73,14 +73,10 @@ export function foldFeatureValues<
providerOptions?: ProviderOptions | null;
featureValues?: Record<string, unknown> | null;
},
>(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 };
>(config: T): Omit<T, "featureValues"> {
const { featureValues, ...rest } = config;
if (!featureValues || Object.keys(featureValues).length === 0) return rest;
return { ...rest, providerOptions: { ...featureValues, ...rest.providerOptions } };
}
/**
@@ -99,21 +95,25 @@ export function exposeFeatureValuesToHook<
export function foldHookFeatureValues<
T extends {
provider?: string;
providerOptions?: ProviderOptions | null;
featureValues?: Record<string, unknown>;
},
>(input: { returned: T; sent: T }): Omit<T, "featureValues"> {
const { returned, sent } = input;
>(input: {
returned: T;
sent: T;
/** Brings what the hook returned to the form of the copy it was sent. */
convert: (options: Record<string, unknown>) => Record<string, unknown>;
}): Omit<T, "featureValues"> {
const { returned, sent, convert } = 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 foldFeatureValues({ config: returned, provider: returned.provider });
return foldFeatureValues(returned);
}
// The copy the hook was sent is canonical; what it returns may name the old key, so both
// sides are compared in the same form and a replaced speed is not read as a deletion.
const after = convertLegacyOptions({ provider: returned.provider, options: featureValues ?? {} });
// A hook may return an older key where it was sent the current one; comparing in the same
// form keeps a replaced value from reading as a deletion.
const after = convert(featureValues ?? {});
const patch: ProviderOptionsPatch = {};
for (const key of new Set([...Object.keys(before), ...Object.keys(after)])) {
if (isDeepStrictEqual(after[key], before[key])) continue;
@@ -122,10 +122,7 @@ export function foldHookFeatureValues<
return {
...rest,
providerOptions: applyProviderOptionsPatch({
options: convertLegacyOptions({
provider: returned.provider,
options: rest.providerOptions ?? undefined,
}),
options: rest.providerOptions ?? undefined,
patch,
}),
};
@@ -155,8 +152,7 @@ export function foldProfileFeatureValues<T extends ProfileOptionFields>(input: {
source: "stored" | "client write";
}): Omit<T, "featureValues"> {
const { profile, stored, source } = input;
const { featureValues: legacyValues, ...rest } = profile;
const featureValues = convertLegacyOptions({ provider: profile.provider, options: legacyValues });
const { featureValues, ...rest } = profile;
if (source === "stored") {
return featureValues ? { ...rest, options: { ...featureValues, ...rest.options } } : rest;
}
@@ -509,7 +509,6 @@ 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();
@@ -797,13 +796,12 @@ 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",
});
expect(client.effectiveOptions?.(saved)).toEqual({ service_tier: "fast", plan_mode: false });
// The manager hands the registry a pre-v0.10 record's `fast_mode` already converted.
expect(client.effectiveOptions?.({ service_tier: "fast" })).toEqual({
service_tier: "fast",
plan_mode: false,
});
expect(client.effectiveOptions?.({})).toEqual({ service_tier: "default", plan_mode: false });
});
@@ -7,6 +7,7 @@ import { describe, expect, test, vi } from "vitest";
import { AgentManager } from "../agent-manager.js";
import { toAgentPayload } from "../agent-projections.js";
import { AgentStorage } from "../agent-storage.js";
import { convertLegacyOptions } from "../option-controls.js";
import type {
AgentClient,
AgentSession,
@@ -654,8 +655,16 @@ describe("Codex app-server provider controls", () => {
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({ serviceTier: "default" });
});
describe("Fast chosen as `fast`", () => {
function createLegacyFastHarness(configDefaults: Record<string, unknown> = {}) {
describe("legacy Fast (fast_mode) saved or requested before service_tier", () => {
function createLegacyFastHarness(
rawDefaults: Record<string, unknown> = {},
providerId = CODEX_PROVIDER,
) {
// The provider registry converts configured defaults before it merges them.
const configDefaults = convertLegacyOptions({
provider: CODEX_PROVIDER,
options: rawDefaults,
});
const appServer = createFakeCodexAppServer({
"collaborationMode/list": () => ({ data: TEST_COLLABORATION_MODES }),
"model/list": () => ({ data: TEST_SPEED_MODELS }),
@@ -680,7 +689,7 @@ describe("Codex app-server provider controls", () => {
return session;
};
const client: AgentClient = {
provider: CODEX_PROVIDER,
provider: providerId,
capabilities: {
supportsStreaming: true,
supportsSessionPersistence: true,
@@ -702,7 +711,16 @@ describe("Codex app-server provider controls", () => {
};
const workdir = mkdtempSync(join(tmpdir(), "codex-legacy-fast-"));
const storage = new AgentStorage(join(workdir, "agents"), logger);
const manager = new AgentManager({ clients: { codex: client }, registry: storage, logger });
const manager = new AgentManager({
clients: { [providerId]: client },
// A custom provider that extends Codex runs on the Codex adapter.
providerDefinitions:
providerId === CODEX_PROVIDER
? {}
: { [providerId]: { enabled: true, derivedFromProviderId: CODEX_PROVIDER } },
registry: storage,
logger,
});
const turnStarts = () =>
appServer
.requests()
@@ -712,7 +730,7 @@ describe("Codex app-server provider controls", () => {
async function create(providerOptions?: Record<string, unknown>) {
const agent = await manager.createAgent(
{
provider: CODEX_PROVIDER,
provider: providerId,
cwd: workdir,
model: "gpt-5.4",
...(providerOptions ? { providerOptions } : {}),
@@ -724,7 +742,7 @@ describe("Codex app-server provider controls", () => {
}
async function resume(providerOptions: Record<string, unknown>) {
const agent = await manager.resumeAgentFromPersistence({
provider: CODEX_PROVIDER,
provider: providerId,
sessionId: "thread-1",
metadata: { cwd: workdir, model: "gpt-5.4", providerOptions },
});
@@ -762,9 +780,9 @@ describe("Codex app-server provider controls", () => {
const FAST = { selected: "priority", feature: "priority" };
test("opens as the model's Fast tier and is stored as that tier", async () => {
test("a saved fast_mode opens as the model's Fast tier and is stored as that tier", async () => {
const { manager, create, shownSpeed, runTurn } = createLegacyFastHarness();
const agentId = await create({ service_tier: "fast" });
const agentId = await create({ fast_mode: true });
await manager.flush();
expect(shownSpeed(agentId)).toMatchObject({
@@ -777,7 +795,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({ service_tier: "fast" });
const agentId = await resume({ fast_mode: true });
await manager.flush();
expect(shownSpeed(agentId)).toMatchObject({ own: { service_tier: "priority" }, ...FAST });
@@ -785,19 +803,50 @@ describe("Codex app-server provider controls", () => {
});
test("from config.json defaults runs Fast and is shown as Fast", async () => {
const { create, shownSpeed, runTurn } = createLegacyFastHarness({ service_tier: "fast" });
const { create, shownSpeed, runTurn } = createLegacyFastHarness({ fast_mode: true });
const agentId = await create();
expect(shownSpeed(agentId)).toMatchObject(FAST);
await runTurn(agentId, "priority");
});
test("a saved fast_mode beats a configured service_tier default", async () => {
const { manager, resume, shownSpeed, runTurn } = createLegacyFastHarness({
service_tier: "default",
});
const agentId = await resume({ fast_mode: true });
await manager.flush();
expect(shownSpeed(agentId)).toMatchObject({ own: { service_tier: "priority" }, ...FAST });
await runTurn(agentId, "priority");
});
test("a provider extending Codex opens a saved fast_mode as Fast", async () => {
const { manager, resume, shownSpeed, runTurn } = createLegacyFastHarness({}, "my-codex");
const agentId = await resume({ fast_mode: true });
await manager.flush();
expect(shownSpeed(agentId)).toMatchObject({ own: { service_tier: "priority" }, ...FAST });
await runTurn(agentId, "priority");
});
test("a provider extending Codex creates with a requested fast_mode and applies a later one", async () => {
const { manager, create, shownSpeed, runTurn } = createLegacyFastHarness({}, "my-codex");
const agentId = await create({ fast_mode: false });
expect(shownSpeed(agentId)).toMatchObject({ own: { service_tier: "default" } });
await manager.setAgentOptions(agentId, { fast_mode: true });
expect(shownSpeed(agentId)).toMatchObject({ own: { service_tier: "priority" }, ...FAST });
await runTurn(agentId, "priority");
});
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.setAgentFeatureValues({ agentId, values: { fast_mode: true } });
await manager.setAgentOptions(agentId, { fast_mode: true });
expect(shownSpeed(agentId)).toMatchObject({ own: { service_tier: "priority" }, ...FAST });
await runTurn(agentId, "priority");
@@ -818,24 +867,7 @@ describe("Codex app-server provider controls", () => {
const { manager, create, shownSpeed, runTurn } = createLegacyFastHarness();
const agentId = await create({ service_tier: "priority" });
await manager.setAgentFeatureValues({
agentId,
values: { service_tier: "default", fast_mode: true },
});
expect(shownSpeed(agentId)).toMatchObject({ own: { service_tier: "default" } });
await runTurn(agentId, "default");
});
test("options sent with the older values replace them before anything is applied", async () => {
const { manager, create, shownSpeed, runTurn } = createLegacyFastHarness();
const agentId = await create({ service_tier: "priority" });
await manager.setAgentFeatureValues({
agentId,
values: { service_tier: "retired-tier" },
options: { service_tier: "default" },
});
await manager.setAgentOptions(agentId, { service_tier: "default", fast_mode: true });
expect(shownSpeed(agentId)).toMatchObject({ own: { service_tier: "default" } });
await runTurn(agentId, "default");
@@ -845,7 +877,7 @@ describe("Codex app-server provider controls", () => {
const { manager, create, shownSpeed, runTurn } = createLegacyFastHarness();
const agentId = await create({ service_tier: "priority" });
await manager.setAgentFeatureValues({ agentId, values: { fast_mode: false } });
await manager.setAgentOptions(agentId, { fast_mode: false });
expect(shownSpeed(agentId)).toMatchObject({ own: { service_tier: "default" } });
await runTurn(agentId, "default");
@@ -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 { convertLegacyOptions, featuresFromControls } from "../option-controls.js";
import { featuresFromControls } from "../option-controls.js";
import {
AgentFeatureSchema,
AgentPermissionRequestPayloadSchema,
@@ -1636,11 +1636,7 @@ function createToolCatalog(
cwd: resolvedArgs.cwd,
workspaceId: resolvedArgs.workspaceId,
thinking: parsedArgs.settings?.thinkingOptionId,
// 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,
}),
providerOptions: parsedArgs.settings?.features,
labels: {
...parsedArgs.labels,
...(callerAgentId && !resolvedArgs.detached && options.serverId
@@ -2292,7 +2288,7 @@ function createToolCatalog(
await agentManager.setAgentThinkingOption(agentId, settings.thinkingOptionId);
}
if (settings?.features) {
await agentManager.setAgentFeatureValues({ agentId, values: settings.features });
await agentManager.setAgentOptions(agentId, settings.features);
}
await updateAgentCommand({ agentManager }, { agentId, name, labels });
@@ -3130,14 +3126,7 @@ function createToolCatalog(
...(settings?.modeId ? { modeId: settings.modeId } : {}),
...(selectedModel ? { model: selectedModel } : {}),
...(settings?.thinkingOptionId ? { thinkingOptionId: settings.thinkingOptionId } : {}),
...(settings?.features
? {
providerOptions: convertLegacyOptions({
provider: providerId,
options: settings.features,
}),
}
: {}),
...(settings?.features ? { providerOptions: settings.features } : {}),
});
return {
content: [],
@@ -268,7 +268,7 @@ describe("DaemonConfigStore", () => {
id: "p",
name: "P",
provider: "codex",
options: { sandbox_mode: "read-only", plan_mode: false },
options: { sandbox_mode: "read-only", fast_mode: false },
},
],
});
@@ -279,7 +279,7 @@ describe("DaemonConfigStore", () => {
await store.patch({
agentProfiles: [
{ id: "p", name: "Renamed", provider: "codex", featureValues: { plan_mode: true } },
{ id: "p", name: "Renamed", provider: "codex", featureValues: { fast_mode: true } },
],
});
@@ -288,7 +288,7 @@ describe("DaemonConfigStore", () => {
id: "p",
name: "Renamed",
provider: "codex",
options: { sandbox_mode: "read-only", plan_mode: true },
options: { sandbox_mode: "read-only", fast_mode: true },
},
]);
});
@@ -303,7 +303,7 @@ describe("DaemonConfigStore", () => {
id: "p",
name: "Renamed",
provider: "codex",
options: { sandbox_mode: "read-only", plan_mode: false },
options: { sandbox_mode: "read-only", fast_mode: false },
},
]);
});
@@ -340,7 +340,7 @@ describe("DaemonConfigStore", () => {
name: "P",
provider: "codex",
options: { sandbox_mode: "workspace-write" },
featureValues: { plan_mode: true },
featureValues: { fast_mode: true },
},
],
});
@@ -350,26 +350,7 @@ describe("DaemonConfigStore", () => {
id: "p",
name: "P",
provider: "codex",
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" },
options: { sandbox_mode: "workspace-write", fast_mode: true },
},
]);
});
@@ -379,7 +360,7 @@ describe("DaemonConfigStore", () => {
await store.patch({
agentProfiles: [
{ id: "p", name: "P", provider: "codex", featureValues: { plan_mode: null } },
{ id: "p", name: "P", provider: "codex", featureValues: { fast_mode: null } },
],
});
@@ -169,8 +169,7 @@ export class HubExecutionController {
modeId: message.modeId,
thinkingOptionId: message.thinkingOptionId,
// COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01.
providerOptions: foldFeatureValues({ config: message, provider: message.provider })
.providerOptions,
providerOptions: foldFeatureValues(message).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, provider: config.provider }).providerOptions,
providerOptions: foldFeatureValues(config).providerOptions,
systemPrompt: config.systemPrompt,
mcpServers: config.mcpServers as AgentSessionConfig["mcpServers"],
};
-1
View File
@@ -1067,7 +1067,6 @@ export class Session {
(await setAgentModeCommand({ agentManager }, { agentId, modeId })).notice,
setModel: (agentId, modelId) => agentManager.setAgentModel(agentId, modelId),
setOptions: (agentId, patch) => agentManager.setAgentOptions(agentId, patch),
setFeatureValues: (input) => agentManager.setAgentFeatureValues(input),
setThinking: (agentId, thinkingOptionId) =>
agentManager.setAgentThinkingOption(agentId, thinkingOptionId),
},
@@ -13,11 +13,6 @@ 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>;
options?: 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[] = [];
@@ -50,16 +45,6 @@ class FakeAgentConfigOperations implements AgentConfigOperations {
if (this.failWith) throw this.failWith;
}
async setFeatureValues(input: {
agentId: string;
values: Record<string, unknown>;
options?: Record<string, unknown>;
}): Promise<void> {
this.featureValueCalls.push(input);
this.callLog.push("featureValues");
if (this.failWith) throw this.failWith;
}
async setThinking(
agentId: string,
thinkingOptionId: string | null,
@@ -232,9 +217,7 @@ describe("AgentConfigSession", () => {
requestId: "req-1",
});
expect(operations.featureValueCalls).toEqual([
{ agentId: "agent-1", values: { web_search: true } },
]);
expect(operations.optionsCalls).toEqual([{ agentId: "agent-1", patch: { web_search: true } }]);
expect(emitted).toEqual([
{
type: "set_agent_feature_response",
@@ -374,7 +357,7 @@ describe("AgentConfigSession", () => {
]);
});
test("apply: legacy featureValues and options go in one call, options winning", async () => {
test("apply: folds legacy featureValues into the options patch, options winning", async () => {
const { subsystem, operations } = makeSubsystem();
await subsystem.handleAgentConfigApplyRequest({
@@ -387,13 +370,8 @@ describe("AgentConfigSession", () => {
requestId: "req-1",
});
expect(operations.callLog).toEqual(["featureValues"]);
expect(operations.featureValueCalls).toEqual([
{
agentId: "agent-1",
values: { fast_mode: true, service_tier: "fast" },
options: { fast_mode: false },
},
expect(operations.optionsCalls).toEqual([
{ agentId: "agent-1", patch: { fast_mode: false, service_tier: "fast" } },
]);
});
@@ -30,12 +30,6 @@ 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(input: {
agentId: string;
values: Record<string, unknown>;
options?: ProviderOptionsPatch;
}): Promise<void>;
setThinking(
agentId: string,
thinkingOptionId: string | null,
@@ -121,7 +115,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.setFeatureValues({ agentId, values: { [featureId]: value } });
await this.operations.setOptions(agentId, { [featureId]: value });
return undefined;
},
emitResponse: (payload) => this.host.emit({ type: "set_agent_feature_response", payload }),
@@ -198,16 +192,9 @@ export class AgentConfigSession {
notice ??= thinkingNotice;
}
// COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01. `featureValues` is an options patch.
const hasValues = config.featureValues && Object.keys(config.featureValues).length > 0;
const hasOptions = config.options && Object.keys(config.options).length > 0;
if (hasValues) {
await this.operations.setFeatureValues({
agentId,
values: config.featureValues ?? {},
options: config.options,
});
} else if (hasOptions) {
await this.operations.setOptions(agentId, config.options ?? {});
const optionsPatch = { ...config.featureValues, ...config.options };
if (Object.keys(optionsPatch).length > 0) {
await this.operations.setOptions(agentId, optionsPatch);
}
return notice;
@@ -347,10 +347,7 @@ export class ProviderCatalogSession {
providerOptions?: ProviderOptions;
}): AgentSessionConfig {
// COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01.
const { providerOptions } = foldFeatureValues({
config: draftConfig,
provider: draftConfig.provider,
});
const { providerOptions } = foldFeatureValues(draftConfig);
return {
provider: draftConfig.provider,
cwd: expandTilde(draftConfig.cwd),