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.
This commit is contained in:
Mohamed Boudra
2026-11-01 15:10:35 +01:00
parent 9de8cbf2b8
commit 13ad4a13ff
9 changed files with 116 additions and 19 deletions
@@ -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({
@@ -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<void> {
const merged: Record<string, unknown> = { ...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"}'`,
@@ -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<Record<string, unknown>> {
const { fast_mode: _legacyFast, ...running } = this.providerOptions;
const next: Record<string, unknown> = { ...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.
@@ -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();
@@ -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<Record<string, unknown>> {
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);
@@ -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()],
@@ -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<void> {
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);
}
}
@@ -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) => {
@@ -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())