From 13ad4a13ff8ae209bd487b9ec7ff904a15c5e6a8 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 1 Nov 2026 15:10:35 +0100 Subject: [PATCH] fix(server): providers validate live option patches and Claude reports Fast dropped by a model switch Live patches go through the schema used at creation. A Claude model switch that cannot keep Fast reports fast_mode as removed so the stored options match the session. --- .../agent/providers/claude/agent.test.ts | 26 +++++++++++++++++++ .../server/agent/providers/claude/agent.ts | 22 ++++++++++------ .../agent/providers/codex-app-server-agent.ts | 13 +++++----- .../server/agent/providers/omp/agent.test.ts | 10 +++++++ .../src/server/agent/providers/omp/agent.ts | 11 ++++++-- .../opencode-agent.full-access.test.ts | 21 +++++++++++++++ .../server/agent/providers/opencode-agent.ts | 8 +++++- .../agent/providers/opencode/v2/agent.test.ts | 13 ++++++++++ .../agent/providers/opencode/v2/session.ts | 11 ++++++-- 9 files changed, 116 insertions(+), 19 deletions(-) diff --git a/packages/server/src/server/agent/providers/claude/agent.test.ts b/packages/server/src/server/agent/providers/claude/agent.test.ts index 645898bdf4..284bd2bc85 100644 --- a/packages/server/src/server/agent/providers/claude/agent.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.test.ts @@ -1528,6 +1528,32 @@ describe("ClaudeAgentSession features", () => { await session.close(); }); + test("switching to a model without Fast turns it off, reports that, and later patches still apply", async () => { + const { queryFactory, queryMock } = createQueryMock(); + const client = new ClaudeAgentClient({ + logger, + queryFactory, + resolveBinary: async () => "/test/claude/bin", + }); + const session = await client.createSession({ + provider: "claude", + cwd: process.cwd(), + model: "claude-opus-4-8", + providerOptions: { fast_mode: true }, + }); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + + await session.setModel?.("claude-fable-5"); + + expect(queryMock.applyFlagSettings).toHaveBeenLastCalledWith({ fastMode: false }); + expect(events.filter((event) => event.type === "options_changed")).toEqual([ + { type: "options_changed", provider: "claude", patch: { fast_mode: null } }, + ]); + await expect(session.applyOptions?.({ allowedTools: ["Read"] })).resolves.toBeUndefined(); + await session.close(); + }); + test("removing fast_mode turns Fast off", async () => { const { queryFactory, queryMock } = createQueryMock(); const client = new ClaudeAgentClient({ diff --git a/packages/server/src/server/agent/providers/claude/agent.ts b/packages/server/src/server/agent/providers/claude/agent.ts index a7f890ff26..39e51848db 100644 --- a/packages/server/src/server/agent/providers/claude/agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -1,6 +1,7 @@ import { setImmediate } from "node:timers/promises"; import { ClaudeRuntimeSafety } from "./runtime-safety.js"; import type { ProviderOptions } from "@getpaseo/protocol/agent-types"; +import { applyProviderOptionsPatch } from "../../option-controls.js"; import { validateProviderOptions } from "../../provider-options.js"; import type { ChildProcess } from "node:child_process"; import { randomUUID } from "node:crypto"; @@ -2743,8 +2744,14 @@ class ClaudeAgentSession implements AgentSession { this.config.model = normalizedModelId ?? undefined; this.reconcileThinkingOptionForModel(normalizedModelId); if (this.config.providerOptions.fast_mode) { - // The option is the user's intent; the runtime follows it only on models that support it. - await activeQuery.applyFlagSettings({ fastMode: this.resolveFastModeSetting() === true }); + const supported = claudeModelSupportsFastMode(this.config.model); + await activeQuery.applyFlagSettings({ fastMode: supported }); + if (!supported) { + // Fast cannot run on this model, so the session drops the option and says so. + const { fast_mode: _fastMode, ...rest } = this.config.providerOptions; + this.config.providerOptions = rest; + this.pushEvent({ type: "options_changed", provider: "claude", patch: { fast_mode: null } }); + } this.cachedRuntimeInfo = null; } this.contextUsage.setInitialContextWindowMaxTokens( @@ -2798,13 +2805,12 @@ class ClaudeAgentSession implements AgentSession { } async applyOptions(options: ProviderOptions): Promise { - const merged: Record = { ...this.config.providerOptions }; - for (const [key, value] of Object.entries(options)) { - if (value === null) delete merged[key]; - else merged[key] = value; - } const providerOptions = - validateProviderOptions("claude", ClaudeProviderOptionsSchema, merged) ?? {}; + validateProviderOptions( + "claude", + ClaudeProviderOptionsSchema, + applyProviderOptionsPatch(this.config.providerOptions, options), + ) ?? {}; if (providerOptions.fast_mode === true && !claudeModelSupportsFastMode(this.config.model)) { throw new Error( `Claude fast mode is not available for model '${this.config.model ?? "default"}'`, 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 3845d70c93..16ae88914c 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 @@ -1,4 +1,5 @@ import type { ProviderOptions } from "@getpaseo/protocol/agent-types"; +import { applyProviderOptionsPatch } from "../option-controls.js"; import { validateProviderOptions } from "../provider-options.js"; import { getAgentStreamEventTurnId, @@ -4678,12 +4679,12 @@ export class CodexAppServerAgentSession implements AgentSession { /** Applies only the options the patch sets; the rest of the running options stay as they are. */ async applyOptions(options: ProviderOptions): Promise> { const { fast_mode: _legacyFast, ...running } = this.providerOptions; - const next: Record = { ...running }; - for (const [key, value] of Object.entries(options)) { - if (value === null) delete next[key]; - else next[key] = value; - } - const valid = validateProviderOptions("codex", CodexProviderOptionsSchema, next) ?? {}; + const valid = + validateProviderOptions( + "codex", + CodexProviderOptionsSchema, + applyProviderOptionsPatch(running, 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. 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 9c7961b061..2ed4846eac 100644 --- a/packages/server/src/server/agent/providers/omp/agent.test.ts +++ b/packages/server/src/server/agent/providers/omp/agent.test.ts @@ -651,6 +651,16 @@ describe("OMP agent client and session", () => { expect(omp.runtime().setFastModeRequests).toEqual([true]); }); + test("rejects a live option the creation schema would reject, and sends nothing", async () => { + const omp = new OmpHarness(); + await omp.start(); + const session = omp.requireSession(); + + await expect(session.applyOptions?.({ sandbox_mode: "read-only" })).rejects.toThrow(/omp/i); + await expect(session.applyOptions?.({ fast_mode: "yes" })).rejects.toThrow(/omp/i); + expect(omp.runtime().setFastModeRequests).toEqual([]); + }); + test("a removal turns Fast off, and a Fast the runtime keeps is returned", async () => { const omp = new OmpHarness(); await omp.start(); diff --git a/packages/server/src/server/agent/providers/omp/agent.ts b/packages/server/src/server/agent/providers/omp/agent.ts index 646bbb7cf2..70ad64787f 100644 --- a/packages/server/src/server/agent/providers/omp/agent.ts +++ b/packages/server/src/server/agent/providers/omp/agent.ts @@ -67,9 +67,12 @@ import { resolveOmpLaunchMode, resolveOmpProviderOptions, OMP_MODES, + OmpProviderOptionsSchema, type OmpRuntimeOptions, } from "./provider-config.js"; export { formatOmpVersionSupport, resolveOmpDiagnosticPaths } from "./provider-config.js"; +import { applyProviderOptionsPatch } from "../../option-controls.js"; +import { validateProviderOptions } from "../../provider-options.js"; import { OmpSubagentCardTracker } from "./subagent-card-tracker.js"; import { ompSkillPromptUserText, shouldDisplayOmpCustomMessage } from "./custom-message.js"; import { getUserMessageText } from "./message-history.js"; @@ -822,9 +825,13 @@ export class OmpAgentSession implements AgentSession { } async applyOptions(options: ProviderOptions): Promise> { + const next = validateProviderOptions( + "omp", + OmpProviderOptionsSchema, + applyProviderOptionsPatch(this.config.providerOptions, options), + ); if (options.fast_mode === undefined) return {}; - const requested = options.fast_mode ?? false; - if (typeof requested !== "boolean") throw new Error("OMP fast mode requires a boolean"); + const requested = next?.fast_mode ?? false; if (!hasOmpFastMode(this.state)) throw new Error("OMP fast mode is unavailable"); if (requested !== this.state.fastModeEnabled) { const result = await this.runtimeSession.setFastMode(requested); diff --git a/packages/server/src/server/agent/providers/opencode-agent.full-access.test.ts b/packages/server/src/server/agent/providers/opencode-agent.full-access.test.ts index 1e6587389a..6cd2972206 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.full-access.test.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.full-access.test.ts @@ -290,6 +290,27 @@ describe("OpenCode auto_accept feature", () => { await session.close(); }); + test("rejects a live auto_accept the creation schema would reject, and keeps approving", async () => { + const { openCodeClient, runtime } = mockOpenCodeClient({ + events: [toolPermissionEvent(), idleEvent()], + }); + const client = new OpenCodeAgentClient(createTestLogger(), undefined, { + serverManager: runtime, + createClient: runtime.createClient, + }); + const session = await client.createSession({ + provider: "opencode", + cwd: "/tmp/project", + providerOptions: { auto_accept: true }, + }); + + await expect(session.applyOptions?.({ auto_accept: "yes" })).rejects.toThrow(/auto_accept/); + await session.run("Run verification"); + + expect(openCodeClient.calls.permissionReply).toHaveLength(1); + await session.close(); + }); + test("stops auto-approving once auto_accept is removed", async () => { const { openCodeClient, runtime } = mockOpenCodeClient({ events: [toolPermissionEvent(), idleEvent()], diff --git a/packages/server/src/server/agent/providers/opencode-agent.ts b/packages/server/src/server/agent/providers/opencode-agent.ts index d67a2f877d..520bab0d8b 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.ts @@ -1,4 +1,5 @@ import type { ProviderOptions } from "@getpaseo/protocol/agent-types"; +import { applyProviderOptionsPatch } from "../option-controls.js"; import { validateProviderOptions } from "../provider-options.js"; import { createOpencodeClient, @@ -4964,8 +4965,13 @@ class OpenCodeAgentSession implements AgentSession { } async applyOptions(options: ProviderOptions): Promise { + const next = validateProviderOptions( + "opencode", + OpenCodeProviderOptionsSchema, + applyProviderOptionsPatch(this.config.providerOptions, options), + ); if (OPENCODE_AUTO_ACCEPT_FEATURE_ID in options) { - this.setAutoAccept(options[OPENCODE_AUTO_ACCEPT_FEATURE_ID] === true); + this.setAutoAccept(next?.[OPENCODE_AUTO_ACCEPT_FEATURE_ID] === 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 e8188730f5..721b38d1ad 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 @@ -37,6 +37,19 @@ describe("OpenCode v2 session lifecycle", () => { expect(refreshed).toEqual(["/tmp/project"]); await client.shutdown(); }); + test("rejects a live auto_accept the creation schema would reject", async () => { + const harness = new V2Harness(); + const client = new OpenCodeV2AgentClient({ + logger: createTestLogger(), + runtime: harness.runtime, + }); + const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" }); + + await expect(session.applyOptions?.({ auto_accept: "yes" })).rejects.toThrow(/auto_accept/); + await expect(session.applyOptions?.({ auto_accept: true })).resolves.toBeUndefined(); + await session.close(); + }); + test("completes from execution events without a long-lived HTTP wait", async () => { const harness = new V2Harness(); harness.prompt = async (input) => { 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 cf5c1c2b7a..1a63a6e99e 100644 --- a/packages/server/src/server/agent/providers/opencode/v2/session.ts +++ b/packages/server/src/server/agent/providers/opencode/v2/session.ts @@ -1,4 +1,7 @@ import type { ProviderOptions } from "@getpaseo/protocol/agent-types"; +import { applyProviderOptionsPatch } from "../../../option-controls.js"; +import { validateProviderOptions } from "../../../provider-options.js"; +import { OpenCodeProviderOptionsSchema } from "../options.js"; import { SessionChildren } from "./children.js"; import { SessionTurns } from "./turns.js"; import { V2Timeline } from "./timeline.js"; @@ -360,9 +363,13 @@ export class OpenCodeV2Session implements AgentSession { this.emit({ type: "thinking_option_changed", provider: "opencode", thinkingOptionId: variant }); } async applyOptions(options: ProviderOptions) { + const next = validateProviderOptions( + "opencode", + OpenCodeProviderOptionsSchema, + applyProviderOptionsPatch(this.config.providerOptions, options), + ); if (!("auto_accept" in options)) return; - const value = options["auto_accept"] ?? false; - if (typeof value !== "boolean") throw new Error("OpenCode auto_accept requires a boolean"); + const value = next?.auto_accept === true; this.config.providerOptions = { ...this.config.providerOptions, auto_accept: value }; if (value && !this.config.toolPolicy) for (const request of this.permissions.list())