fix(server): a live patch is rejected for an option the session only reads at start, and opening reports every option it ended up with

A stored option has to describe what runs. OpenCode, OMP and ACP now reject a live change
to a key they apply only when the agent opens; Claude applies such a change from the next
turn and rejects it while a turn is running. Opening reports every select or setting the
session came up with, including ones nobody set, so a native default (Cursor opening with
Fast on) is stored as option state like any other.
This commit is contained in:
Mohamed Boudra
2026-11-01 15:44:24 +01:00
parent fd2315495f
commit 85edc9da9c
14 changed files with 201 additions and 14 deletions
+9 -3
View File
@@ -37,12 +37,18 @@ option and read `options`; they hold no value of their own. Any option can be re
or without a control.
- **Create and resume.** The provider opens with the options it is given and emits nothing about
them while opening. If the session ended up different, it sets `openOptionsPatch` before
`createSession`/`resumeSession` resolves. The daemon stores it before the agent is published.
them while opening. `openOptionsPatch` carries every option the session ended up with that
differs from what it was given, including a setting nobody asked for (a native default such as
Cursor opening with Fast on). It is set before `createSession`/`resumeSession` resolves and the
daemon stores it before the agent is published. A reopened agent resumes that state, so a
native default the provider later changes does not follow.
- **Live apply.** `applyOptions` receives the keys the patch touches, resolved against the
`config.json` defaults. A removal arrives as the default the key falls back to, or `null` when
there is none; the provider returns the setting to its own default if it can, or throws to
reject. It returns the options it ended up with when they differ from the request. The daemon
reject. A key the running session cannot apply (one the provider reads only when it opens) is
rejected too, so a stored option always describes what runs. Claude reads most options when it
starts a query: it applies them from the next turn and rejects them while a turn is running. It
returns the options it ended up with when they differ from the request. The daemon
stores the patch and that result in one step after the call resolves, onto the agent's options
as they are at that moment, so the last write wins. Throwing rejects the whole patch and
none of it is stored. Emit nothing from inside the call. If the provider's own state moved
@@ -732,7 +732,9 @@ export interface AgentSession {
* Apply an options patch. `options` holds only the keys the patch touches, resolved against
* the `config.json` defaults; `null` means nothing is set for the key, so return it to the
* session's own default if that can be done. Leave every other setting alone. Throw to reject a
* value; the daemon stores the patch only when this resolves, so the call is all-or-nothing.
* value, or a key the running session cannot apply (one the provider reads only when it
* opens): the daemon stores the patch only when this resolves, so a stored option always
* describes what runs, and the call is all-or-nothing.
* Emit nothing from here. Return the options the session ended up with when they differ from
* the request (a patch, `null` removes a key), for example a value it kept because it could not
* be reverted; the daemon stores it together with the patch. When a throw leaves the session's
@@ -974,7 +974,7 @@ describe("PluginAgentClientRegistry", () => {
}
});
test("stores only agent options while the plugin receives merged defaults", async () => {
test("stores the agent options and the settings the plugin opened with, not the merged config defaults", async () => {
const logger = createTestLogger();
const harness = createProviderHarness();
const plugins = new PluginAgentClientRegistry(logger);
@@ -996,7 +996,8 @@ describe("PluginAgentClientRegistry", () => {
undefined,
{ workspaceId: undefined },
);
expect(toStoredAgentRecord(agent).config?.providerOptions).toEqual(own);
// `nested.base` comes from `config.json` and is not stored; `voice` is the plugin's own opening state.
expect(toStoredAgentRecord(agent).config?.providerOptions).toEqual({ ...own, voice: "direct" });
expect(harness.inputs.find((input) => input.type === "session.open")).toMatchObject({
config: { providerOptions: { nested: { base: true, replace: "agent" } } },
});
@@ -1114,6 +1115,14 @@ describe("PluginAgentClientRegistry", () => {
await session.close();
});
test("settings the plugin opened with that nobody set are in the open patch", async () => {
const { session, events } = await openStubbornVoiceSession({});
expect(session.openOptionsPatch).toEqual({ voice: "direct", loud: false });
expect(eventsOfType(events, "options_changed")).toEqual([]);
await session.close();
});
test("applying options configures only the settings it is given", async () => {
const { harness, session } = await openStubbornVoiceSession({});
@@ -1231,9 +1231,8 @@ class PluginAgentSession implements AgentSession {
givenSettings: Readonly<Record<string, JsonValue>>,
) {
this.subagentIdsBySession.set(bridge.id, null);
this.openOptionsPatch = Object.fromEntries(
Object.entries(this.settingDifferences(givenSettings)).filter(([id]) => id in givenSettings),
);
// Every setting the session opened with that was not given, including ones nobody set.
this.openOptionsPatch = this.settingDifferences(givenSettings);
this.knownSettings = this.currentSettings();
for (const event of bridge.history) this.accept(event, false);
// Opening notices arrive before the manager subscribes to the session.
@@ -1,4 +1,5 @@
import type { ProviderOptions } from "@getpaseo/protocol/agent-types";
import { isDeepStrictEqual } from "node:util";
import type { z } from "zod";
export interface ProviderOptionIssue {
@@ -103,3 +104,26 @@ function isPlainObject(value: unknown): value is ProviderOptions {
const prototype = Object.getPrototypeOf(value);
return prototype === Object.prototype || prototype === null;
}
/**
* Rejects a live patch that changes a key the running session cannot apply (one the provider
* reads only when it opens). A key whose value already matches the session's is not a change.
*/
export function assertChangesAreLive(input: {
provider: string;
patch: ProviderOptions;
current: ProviderOptions | undefined;
live: readonly string[];
}): void {
const issues = Object.entries(input.patch)
.filter(
([key, value]) =>
!input.live.includes(key) &&
!isDeepStrictEqual(input.current?.[key] ?? null, value ?? null),
)
.map(([key]) => ({
path: [key],
message: "cannot change on a running session; it applies when the agent is opened",
}));
if (issues.length > 0) throw new ProviderOptionsValidationError(input.provider, issues);
}
@@ -1883,6 +1883,13 @@ describe("ACPAgentSession options against an ACP agent", () => {
expect(optionEvents).toEqual([]);
});
test("a select the agent opened with that nobody set is in the open patch", async () => {
const { session, optionEvents } = await openSession({ agent: "Probe Agent" });
expect(session.openOptionsPatch).toEqual({ agent: "Probe Agent" });
expect(optionEvents).toEqual([]);
});
test("a notification that arrives before the open write is answered is not an event", async () => {
const { session, optionEvents } = await openSession({
providerOptions: { agent: "Probe Agent" },
@@ -1,6 +1,7 @@
import type { ProviderOptions } from "@getpaseo/protocol/agent-types";
import { ACPProviderOptionsSchema } from "./acp-options.js";
import { applyProviderOptionsPatch } from "../option-controls.js";
import { assertChangesAreLive } from "../provider-options.js";
import { type ChildProcess, type ChildProcessWithoutNullStreams } from "node:child_process";
import { randomUUID } from "node:crypto";
import fs from "node:fs/promises";
@@ -2406,6 +2407,12 @@ export class ACPAgentSession implements AgentSession, ACPClient {
* rejected, what the provider moved is reported as an event, since nothing is stored.
*/
async applyOptions(options: ProviderOptions): Promise<Record<string, unknown>> {
assertChangesAreLive({
provider: this.config.provider,
patch: options,
current: this.config.providerOptions,
live: [ACP_AUTO_ACCEPT_FEATURE_ID, ...this.configFeatureOptions.map(({ id }) => id)],
});
ACPProviderOptionsSchema.parse(applyProviderOptionsPatch(this.config.providerOptions, options));
const before = this.featureValues();
const requested: Record<string, unknown> = {};
@@ -3119,7 +3126,13 @@ export class ACPAgentSession implements AgentSession, ACPClient {
);
}
}
this.openOptionsPatch = this.featureDifferences(configuredFeatureValues);
// The options are the state, so every select the session opened with is reported, including
// ones nobody set (a native default is stored like any other value).
this.openOptionsPatch = Object.fromEntries(
Object.entries(this.featureValues()).filter(
([id, value]) => configuredFeatureValues[id] !== value,
),
);
}
/**
@@ -1589,6 +1589,56 @@ describe("ClaudeAgentSession features", () => {
await session.close();
});
test("an option Claude reads when it starts applies from the next query", async () => {
const { queryFactory } = 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: { disallowedTools: ["Bash"] },
});
const ensureQuery = () =>
(session as unknown as { ensureQuery(): Promise<unknown> }).ensureQuery();
await ensureQuery();
await session.applyOptions?.({ disallowedTools: ["Bash", "Write"] });
await ensureQuery();
expect(queryFactory).toHaveBeenCalledTimes(2);
expect(queryFactory.mock.calls[1]?.[0].options).toMatchObject({
disallowedTools: ["Bash", "Write"],
});
await session.close();
});
test("rejects a change to an option Claude reads at start while a turn is running", async () => {
const { queryFactory } = 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: { disallowedTools: ["Bash"] },
});
await session.startTurn("hello");
await expect(session.applyOptions?.({ disallowedTools: [] })).rejects.toThrow(
/disallowedTools.*cannot change while a turn is running/,
);
// The same value is not a change.
await expect(session.applyOptions?.({ disallowedTools: ["Bash"] })).resolves.toBeUndefined();
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({
@@ -1,8 +1,9 @@
import { setImmediate } from "node:timers/promises";
import { isDeepStrictEqual } from "node:util";
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 { ProviderOptionsValidationError, validateProviderOptions } from "../../provider-options.js";
import type { ChildProcess } from "node:child_process";
import { randomUUID } from "node:crypto";
import fs from "node:fs";
@@ -2820,12 +2821,28 @@ class ClaudeAgentSession implements AgentSession {
`Claude fast mode is not available for model '${this.config.model ?? "default"}'`,
);
}
const current: ProviderOptions = this.config.providerOptions;
const next: ProviderOptions = providerOptions;
const changedLaunchKeys = Object.keys(options).filter(
(key) => key !== "fast_mode" && !isDeepStrictEqual(current[key] ?? null, next[key] ?? null),
);
if (changedLaunchKeys.length > 0 && (this.activeForegroundTurnId || this.autonomousTurn)) {
throw new ProviderOptionsValidationError(
"claude",
changedLaunchKeys.map((key) => ({
path: [key],
message: "cannot change while a turn is running; Claude reads it when a turn starts",
})),
);
}
const fastModeChanged =
(providerOptions.fast_mode === true) !== (this.config.providerOptions.fast_mode === true);
if (fastModeChanged) {
await this.query?.applyFlagSettings({ fastMode: providerOptions.fast_mode === true });
this.cachedRuntimeInfo = null;
}
// Every other option is read when the query is built, so it applies from the next turn.
if (changedLaunchKeys.length > 0) this.queryRestartNeeded = true;
// Held until the runtime accepts, so a rejected change is retried instead of skipped. Only
// this call's keys are applied, over the options as they are now: another patch may have
// landed while the runtime was answering.
@@ -72,7 +72,7 @@ import {
} from "./provider-config.js";
export { formatOmpVersionSupport, resolveOmpDiagnosticPaths } from "./provider-config.js";
import { applyProviderOptionsPatch } from "../../option-controls.js";
import { validateProviderOptions } from "../../provider-options.js";
import { assertChangesAreLive, 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";
@@ -825,6 +825,12 @@ export class OmpAgentSession implements AgentSession {
}
async applyOptions(options: ProviderOptions): Promise<Record<string, unknown>> {
assertChangesAreLive({
provider: "omp",
patch: options,
current: this.config.providerOptions,
live: ["fast_mode"],
});
const next = validateProviderOptions(
"omp",
OmpProviderOptionsSchema,
@@ -311,6 +311,27 @@ describe("OpenCode auto_accept feature", () => {
await session.close();
});
test("rejects a live permission change the running session cannot apply", async () => {
const { runtime } = mockOpenCodeClient({ events: [idleEvent()] });
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
serverManager: runtime,
createClient: runtime.createClient,
});
const session = await client.createSession({
provider: "opencode",
cwd: "/tmp/project",
providerOptions: { permission: "allow" },
});
await expect(session.applyOptions?.({ permission: "deny" })).rejects.toThrow(
/permission.*cannot change on a running session/,
);
await expect(
session.applyOptions?.({ permission: "allow", auto_accept: true }),
).resolves.toBeUndefined();
await session.close();
});
test("stops auto-approving once auto_accept is removed", async () => {
const { openCodeClient, runtime } = mockOpenCodeClient({
events: [toolPermissionEvent(), idleEvent()],
@@ -1,6 +1,6 @@
import type { ProviderOptions } from "@getpaseo/protocol/agent-types";
import { applyProviderOptionsPatch } from "../option-controls.js";
import { validateProviderOptions } from "../provider-options.js";
import { assertChangesAreLive, validateProviderOptions } from "../provider-options.js";
import {
createOpencodeClient,
type AssistantMessage as OpenCodeAssistantMessage,
@@ -4965,6 +4965,12 @@ class OpenCodeAgentSession implements AgentSession {
}
async applyOptions(options: ProviderOptions): Promise<void> {
assertChangesAreLive({
provider: "opencode",
patch: options,
current: this.config.providerOptions,
live: [OPENCODE_AUTO_ACCEPT_FEATURE_ID],
});
const next = validateProviderOptions(
"opencode",
OpenCodeProviderOptionsSchema,
@@ -50,6 +50,27 @@ describe("OpenCode v2 session lifecycle", () => {
await session.close();
});
test("rejects a live permission change the running session cannot apply", async () => {
const harness = new V2Harness();
const client = new OpenCodeV2AgentClient({
logger: createTestLogger(),
runtime: harness.runtime,
});
const session = await client.createSession({
provider: "opencode",
cwd: "/tmp/project",
providerOptions: { permission: "allow" },
});
await expect(session.applyOptions?.({ permission: "deny" })).rejects.toThrow(
/permission.*cannot change on a running session/,
);
await expect(
session.applyOptions?.({ permission: "allow", 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,6 +1,6 @@
import type { ProviderOptions } from "@getpaseo/protocol/agent-types";
import { applyProviderOptionsPatch } from "../../../option-controls.js";
import { validateProviderOptions } from "../../../provider-options.js";
import { assertChangesAreLive, validateProviderOptions } from "../../../provider-options.js";
import { OpenCodeProviderOptionsSchema } from "../options.js";
import { SessionChildren } from "./children.js";
import { SessionTurns } from "./turns.js";
@@ -363,6 +363,12 @@ export class OpenCodeV2Session implements AgentSession {
this.emit({ type: "thinking_option_changed", provider: "opencode", thinkingOptionId: variant });
}
async applyOptions(options: ProviderOptions) {
assertChangesAreLive({
provider: "opencode",
patch: options,
current: this.config.providerOptions,
live: ["auto_accept"],
});
const next = validateProviderOptions(
"opencode",
OpenCodeProviderOptionsSchema,