From 133481d75ce85a6c6f51f177cc63c38f0f7e2166 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 1 Nov 2026 19:59:10 +0100 Subject: [PATCH] fix(server): legacy Fast reaches draft discovery and saved profiles, and overlapping or replaced options keep the state they committed Draft controls and commands convert the config they hand to the provider, so inspecting a legacy Fast preference no longer fails Codex's strict options. A profile naming codex converts its saved fast_mode for the app's controls. OpenCode serializes auto_accept applies so a failed one restores exactly what it changed, and a replacement OMP runtime starts from the Fast the session opened with. --- .../server/src/server/agent/agent-manager.ts | 15 +++---- .../src/server/agent/option-controls.ts | 5 ++- .../codex-app-server-agent.features.test.ts | 17 ++++++++ .../server/agent/providers/omp/agent.test.ts | 13 ++++++ .../src/server/agent/providers/omp/agent.ts | 3 ++ .../agent/providers/opencode/v2/agent.test.ts | 41 +++++++++++++++++++ .../agent/providers/opencode/v2/session.ts | 9 +++- .../src/server/daemon-config-store.test.ts | 33 +++++++++++---- 8 files changed, 118 insertions(+), 18 deletions(-) diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index 4a7ab77c33..41b7ff784e 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -1258,7 +1258,7 @@ export class AgentManager { const client = this.requireClient(normalizedConfig.provider); const options = this.effectiveOptionsFor( normalizedConfig.provider, - this.convertLegacyOptions(normalizedConfig.provider, config.providerOptions), + normalizedConfig.providerOptions, ); if (!normalizedConfig.model && !client.listControls) { return { controls: [], options }; @@ -5617,7 +5617,7 @@ export class AgentManager { config: AgentSessionConfig, options: NormalizeConfigOptions = {}, ): Promise { - const normalized: AgentSessionConfig = { ...config }; + const normalized: AgentSessionConfig = { ...this.withConvertedOptions(config) }; // Always resolve cwd to absolute path for consistent history file lookup if (normalized.cwd) { @@ -5722,13 +5722,10 @@ export class AgentManager { agentId: string, options: { env?: Record; purpose?: AgentResumePurpose } = {}, ): Promise { - const storedConfig = await this.normalizeConfig( - this.withConvertedOptions(stripInternalPaseoMcpServer(config)), - { - env: options.env, - purpose: options.purpose, - }, - ); + const storedConfig = await this.normalizeConfig(stripInternalPaseoMcpServer(config), { + env: options.env, + purpose: options.purpose, + }); const paseoToolPolicy = this.paseoToolsEnabled ? this.resolvePaseoToolPolicy(storedConfig.provider) : { enabled: false }; diff --git a/packages/server/src/server/agent/option-controls.ts b/packages/server/src/server/agent/option-controls.ts index 7d9e81d418..87b8d4253b 100644 --- a/packages/server/src/server/agent/option-controls.ts +++ b/packages/server/src/server/agent/option-controls.ts @@ -152,7 +152,10 @@ export function foldProfileFeatureValues(input: { source: "stored" | "client write"; }): Omit { const { profile, stored, source } = input; - const { featureValues, ...rest } = profile; + const { featureValues: legacyValues, ...rest } = profile; + // The app reads a profile's speed from its controls, so the old key is converted here for the + // provider id the profile names; a profile on a custom provider is converted when it is applied. + const featureValues = convertLegacyOptions({ provider: profile.provider, options: legacyValues }); if (source === "stored") { return featureValues ? { ...rest, options: { ...featureValues, ...rest.options } } : rest; } diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.features.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.features.test.ts index 35e4c2113d..c862e34f3d 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 @@ -873,6 +873,23 @@ describe("Codex app-server provider controls", () => { await runTurn(agentId, "default"); }); + test.each([CODEX_PROVIDER, "my-codex"])( + "draft controls for %s accept a legacy fast_mode", + async (providerId) => { + const { manager } = createLegacyFastHarness({}, providerId); + + const draft = await manager.listDraftControls({ + provider: providerId, + cwd: tmpdir(), + model: "gpt-5.4", + providerOptions: { fast_mode: true }, + }); + + expect(draft.options).toEqual({ service_tier: "fast" }); + expect(draft.controls).toContainEqual(expect.objectContaining({ key: "service_tier" })); + }, + ); + test("an older client turning fast_mode off selects Normal", async () => { const { manager, create, shownSpeed, runTurn } = createLegacyFastHarness(); const agentId = await create({ service_tier: "priority" }); diff --git a/packages/server/src/server/agent/providers/omp/agent.test.ts b/packages/server/src/server/agent/providers/omp/agent.test.ts index 74c6558022..38a54294b2 100644 --- a/packages/server/src/server/agent/providers/omp/agent.test.ts +++ b/packages/server/src/server/agent/providers/omp/agent.test.ts @@ -767,6 +767,19 @@ describe("OMP agent client and session", () => { expect(omp.runtime().setFastModeRequests).toEqual([]); }); + test("a replacement runtime starts with the Fast the session opened with, not the one it was asked for", async () => { + const omp = new OmpHarness(); + // The fake runtime answers the open-time Fast request with Fast off. + await omp.start({ modeId: "full", providerOptions: { fast_mode: true } }); + expect(omp.requireSession().openOptionsPatch).toEqual({ fast_mode: false }); + omp.runtime().fastModeResult = { enabled: true, active: true }; + + await omp.setMode("ask"); + + expect(omp.runtimeLaunches()).toHaveLength(2); + expect(omp.runtime().setFastModeRequests).toEqual([]); + }); + test("restores Fast from the initial OMP state on create and resume", async () => { const created = new OmpHarness(); await created.start({ providerOptions: { fast_mode: true } }); diff --git a/packages/server/src/server/agent/providers/omp/agent.ts b/packages/server/src/server/agent/providers/omp/agent.ts index c0e0905089..7fe3e517fa 100644 --- a/packages/server/src/server/agent/providers/omp/agent.ts +++ b/packages/server/src/server/agent/providers/omp/agent.ts @@ -755,6 +755,9 @@ export class OmpAgentSession implements AgentSession { this.config = options.config; this.state = options.initialState; this.openOptionsPatch = this.fastModeDifference(options.config.providerOptions?.fast_mode); + // A replacement runtime starts from the Fast this one opened with, not the one it was asked for. + if (typeof this.openOptionsPatch.fast_mode === "boolean") + this.rememberFastMode(this.openOptionsPatch.fast_mode); this.currentModeId = options.currentModeId ?? null; this.logger = options.logger; this.live = options.live ?? true; diff --git a/packages/server/src/server/agent/providers/opencode/v2/agent.test.ts b/packages/server/src/server/agent/providers/opencode/v2/agent.test.ts index 8435dccd90..044314b005 100644 --- a/packages/server/src/server/agent/providers/opencode/v2/agent.test.ts +++ b/packages/server/src/server/agent/providers/opencode/v2/agent.test.ts @@ -88,6 +88,47 @@ describe("OpenCode v2 session lifecycle", () => { } }); + test("overlapping auto_accept applies do not undo each other when one fails", async () => { + const harness = new V2Harness(); + const permissions = [ + { id: "approval", sessionID: "session", action: "shell", resources: ["pwd"] }, + ]; + harness.api.permission.list = async () => permissions; + let failFirst!: () => void; + let calls = 0; + harness.api.permission.reply = async () => { + calls += 1; + if (calls > 1) return; + await new Promise((_, reject) => { + failFirst = () => reject(new Error("upstream refused")); + }); + }; + const client = new OpenCodeV2AgentClient({ + logger: createTestLogger(), + runtime: harness.runtime, + }); + const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" }); + try { + const first = session.applyOptions?.({ auto_accept: true }); + const second = session.applyOptions?.({ auto_accept: true }); + await expect.poll(() => calls).toBe(1); + failFirst(); + + await expect(first).rejects.toThrow("upstream refused"); + await expect(second).resolves.toBeUndefined(); + + // The accepted second apply is in force: the next permission is approved, not asked. + const next = { id: "next", sessionID: "session", action: "shell", resources: ["ls"] }; + permissions.push(next); + harness.push({ id: "asked", created: 2, type: "permission.asked", data: next }); + await expect.poll(() => calls).toBeGreaterThan(2); + const pendingIds = () => session.getPendingPermissions().map(({ id }) => id); + expect(pendingIds()).not.toContain("next"); + } finally { + await session.close(); + } + }); + test("rejects a live permission change the running session cannot apply", async () => { const harness = new V2Harness(); const client = new OpenCodeV2AgentClient({ diff --git a/packages/server/src/server/agent/providers/opencode/v2/session.ts b/packages/server/src/server/agent/providers/opencode/v2/session.ts index a86f0c3619..8771d618f7 100644 --- a/packages/server/src/server/agent/providers/opencode/v2/session.ts +++ b/packages/server/src/server/agent/providers/opencode/v2/session.ts @@ -362,7 +362,14 @@ export class OpenCodeV2Session implements AgentSession { this.info = await this.client.session.get({ sessionID: this.id }); this.emit({ type: "thinking_option_changed", provider: "opencode", thinkingOptionId: variant }); } - async applyOptions(options: ProviderOptions) { + /** Overlapping applies run one after another, so a failed one restores exactly what it changed. */ + private applying: Promise = Promise.resolve(); + applyOptions(options: ProviderOptions): Promise { + const run = this.applying.then(() => this.applyOptionsNow(options)); + this.applying = run.catch(() => undefined); + return run; + } + private async applyOptionsNow(options: ProviderOptions): Promise { assertChangesAreLive({ provider: "opencode", patch: options, diff --git a/packages/server/src/server/daemon-config-store.test.ts b/packages/server/src/server/daemon-config-store.test.ts index 02e64236fd..7a7f567850 100644 --- a/packages/server/src/server/daemon-config-store.test.ts +++ b/packages/server/src/server/daemon-config-store.test.ts @@ -268,7 +268,7 @@ describe("DaemonConfigStore", () => { id: "p", name: "P", provider: "codex", - options: { sandbox_mode: "read-only", fast_mode: false }, + options: { sandbox_mode: "read-only", plan_mode: false }, }, ], }); @@ -279,7 +279,7 @@ describe("DaemonConfigStore", () => { await store.patch({ agentProfiles: [ - { id: "p", name: "Renamed", provider: "codex", featureValues: { fast_mode: true } }, + { id: "p", name: "Renamed", provider: "codex", featureValues: { plan_mode: true } }, ], }); @@ -288,7 +288,7 @@ describe("DaemonConfigStore", () => { id: "p", name: "Renamed", provider: "codex", - options: { sandbox_mode: "read-only", fast_mode: true }, + options: { sandbox_mode: "read-only", plan_mode: true }, }, ]); }); @@ -303,7 +303,7 @@ describe("DaemonConfigStore", () => { id: "p", name: "Renamed", provider: "codex", - options: { sandbox_mode: "read-only", fast_mode: false }, + options: { sandbox_mode: "read-only", plan_mode: false }, }, ]); }); @@ -340,7 +340,7 @@ describe("DaemonConfigStore", () => { name: "P", provider: "codex", options: { sandbox_mode: "workspace-write" }, - featureValues: { fast_mode: true }, + featureValues: { plan_mode: true }, }, ], }); @@ -350,7 +350,26 @@ describe("DaemonConfigStore", () => { id: "p", name: "P", provider: "codex", - options: { sandbox_mode: "workspace-write", fast_mode: true }, + options: { sandbox_mode: "workspace-write", plan_mode: true }, + }, + ]); + }); + + test("converts a Codex profile's legacy fast_mode to service_tier", async () => { + const store = storeWithProfile(); + + await store.patch({ + agentProfiles: [ + { id: "p", name: "P", provider: "codex", featureValues: { fast_mode: true } }, + ], + }); + + expect(store.get().agentProfiles).toEqual([ + { + id: "p", + name: "P", + provider: "codex", + options: { sandbox_mode: "read-only", plan_mode: false, service_tier: "fast" }, }, ]); }); @@ -360,7 +379,7 @@ describe("DaemonConfigStore", () => { await store.patch({ agentProfiles: [ - { id: "p", name: "P", provider: "codex", featureValues: { fast_mode: null } }, + { id: "p", name: "P", provider: "codex", featureValues: { plan_mode: null } }, ], });