diff --git a/packages/server/src/server/agent/plugin-provider.test.ts b/packages/server/src/server/agent/plugin-provider.test.ts index 043058692c..fd15cadafb 100644 --- a/packages/server/src/server/agent/plugin-provider.test.ts +++ b/packages/server/src/server/agent/plugin-provider.test.ts @@ -1136,6 +1136,18 @@ describe("PluginAgentClientRegistry", () => { await session.close(); }); + test("rejects a key the plugin does not report as a setting, and configures none of a mixed patch", async () => { + const { harness, session } = await openStubbornVoiceSession({ launch_key: "original" }); + const configureBefore = lastConfigure(harness.inputs); + + await expect(session.applyOptions?.({ loud: true, launch_key: "changed" })).rejects.toThrow( + /launch_key.*cannot change on a running session/, + ); + + expect(lastConfigure(harness.inputs)).toBe(configureBefore); + await session.close(); + }); + test("applying options returns the settings that ended up different, and emits nothing", async () => { const { session, events } = await openStubbornVoiceSession({}); diff --git a/packages/server/src/server/agent/plugin-provider.ts b/packages/server/src/server/agent/plugin-provider.ts index f2a09991f3..d21ce06fbb 100644 --- a/packages/server/src/server/agent/plugin-provider.ts +++ b/packages/server/src/server/agent/plugin-provider.ts @@ -64,6 +64,7 @@ import { createProviderEnv, type ProviderRuntimeSettings, } from "./provider-launch-config.js"; +import { ProviderOptionsValidationError } from "./provider-options.js"; import type { RegisteredProviderDefinition } from "./provider-registry.js"; import { runProviderTurn } from "./providers/provider-runner.js"; import { StaleProviderSessionError } from "./stale-provider-session-error.js"; @@ -1437,11 +1438,20 @@ class PluginAgentSession implements AgentSession { async applyOptions(options: ProviderOptions): Promise> { this.assertReplayComplete(); const before = this.currentSettings(); + // A key the plugin does not report as a setting is one it only reads when the session opens. + const unsupported = Object.keys(options).filter((key) => !(key in before)); + if (unsupported.length > 0) { + throw new ProviderOptionsValidationError( + this.provider, + unsupported.map((key) => ({ + path: [key], + message: "cannot change on a running session; it applies when the agent is opened", + })), + ); + } const settings: Record = {}; - for (const setting of this.bridge.config.settings) { - const value = options[setting.id]; - if (value === undefined) continue; - settings[setting.id] = toJsonValue(value, `setting '${setting.id}'`); + for (const [id, value] of Object.entries(options)) { + settings[id] = toJsonValue(value, `setting '${id}'`); } if (Object.keys(settings).length === 0) return {}; this.applyingOptions = true; diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.features.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.features.test.ts index 6b39b7fde2..6fe71f9616 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.features.test.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.features.test.ts @@ -558,6 +558,21 @@ describe("Codex app-server provider controls", () => { }); }); + test("opening on a model without the saved tier runs Normal and the open patch says so", async () => { + const models = [ + { id: "plain", isDefault: true, defaultReasoningEffort: "medium", serviceTiers: [] }, + ]; + const { session, appServer } = await createConnectedSession( + { model: "plain", providerOptions: { service_tier: "priority" } }, + { models }, + ); + + expect(session.openOptionsPatch).toEqual({ service_tier: "default", fast_mode: null }); + + await session.startTurn("hello"); + await expect(appServer.waitForTurnStart()).resolves.toMatchObject({ serviceTier: "default" }); + }); + test("a saved service_tier: fast runs as the model's Fast tier and the open patch names that tier", async () => { const { session, appServer } = await createConnectedSession({ providerOptions: { service_tier: "fast" }, diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.ts index dbe67f4fbc..412fbcbc7d 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.ts @@ -3873,11 +3873,15 @@ export class CodexAppServerAgentSession implements AgentSession { const requested = this.requestedServiceTier; const resolved = this.resolveServiceTier(requested); const legacy = this.providerOptions.fast_mode !== undefined || requested !== resolved; - if (!legacy || resolved === null) return; + 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: resolved }; - this.requestedServiceTier = resolved; - this.openOptionsPatch = { service_tier: resolved, fast_mode: null }; + this.providerOptions = { ...rest, service_tier: runs }; + this.requestedServiceTier = runs; + this.openOptionsPatch = { service_tier: runs, fast_mode: null }; } private get serviceTier(): string | null {