diff --git a/docs/providers.md b/docs/providers.md index d71b4e8e1d..8fcf0833cb 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -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 diff --git a/packages/server/src/server/agent/agent-sdk-types.ts b/packages/server/src/server/agent/agent-sdk-types.ts index efb0f8a815..7e11b50c2c 100644 --- a/packages/server/src/server/agent/agent-sdk-types.ts +++ b/packages/server/src/server/agent/agent-sdk-types.ts @@ -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 diff --git a/packages/server/src/server/agent/plugin-provider.test.ts b/packages/server/src/server/agent/plugin-provider.test.ts index c7d5b82972..043058692c 100644 --- a/packages/server/src/server/agent/plugin-provider.test.ts +++ b/packages/server/src/server/agent/plugin-provider.test.ts @@ -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({}); diff --git a/packages/server/src/server/agent/plugin-provider.ts b/packages/server/src/server/agent/plugin-provider.ts index 2cac5c8a2b..f2a09991f3 100644 --- a/packages/server/src/server/agent/plugin-provider.ts +++ b/packages/server/src/server/agent/plugin-provider.ts @@ -1231,9 +1231,8 @@ class PluginAgentSession implements AgentSession { givenSettings: Readonly>, ) { 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. diff --git a/packages/server/src/server/agent/provider-options.ts b/packages/server/src/server/agent/provider-options.ts index 8a7d0b16af..76a9d638b6 100644 --- a/packages/server/src/server/agent/provider-options.ts +++ b/packages/server/src/server/agent/provider-options.ts @@ -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); +} diff --git a/packages/server/src/server/agent/providers/acp-agent.test.ts b/packages/server/src/server/agent/providers/acp-agent.test.ts index cd0f4f9b0b..c8a24085f9 100644 --- a/packages/server/src/server/agent/providers/acp-agent.test.ts +++ b/packages/server/src/server/agent/providers/acp-agent.test.ts @@ -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" }, diff --git a/packages/server/src/server/agent/providers/acp-agent.ts b/packages/server/src/server/agent/providers/acp-agent.ts index 5c2e83fc8f..5383def8c1 100644 --- a/packages/server/src/server/agent/providers/acp-agent.ts +++ b/packages/server/src/server/agent/providers/acp-agent.ts @@ -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> { + 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 = {}; @@ -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, + ), + ); } /** 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 84994220d8..0068f448a3 100644 --- a/packages/server/src/server/agent/providers/claude/agent.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.test.ts @@ -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 }).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({ diff --git a/packages/server/src/server/agent/providers/claude/agent.ts b/packages/server/src/server/agent/providers/claude/agent.ts index 8b2743438d..2ec9b9035b 100644 --- a/packages/server/src/server/agent/providers/claude/agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -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. diff --git a/packages/server/src/server/agent/providers/omp/agent.ts b/packages/server/src/server/agent/providers/omp/agent.ts index 70ad64787f..8c32b01d65 100644 --- a/packages/server/src/server/agent/providers/omp/agent.ts +++ b/packages/server/src/server/agent/providers/omp/agent.ts @@ -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> { + assertChangesAreLive({ + provider: "omp", + patch: options, + current: this.config.providerOptions, + live: ["fast_mode"], + }); const next = validateProviderOptions( "omp", OmpProviderOptionsSchema, 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 6cd2972206..8bceae77c9 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 @@ -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()], diff --git a/packages/server/src/server/agent/providers/opencode-agent.ts b/packages/server/src/server/agent/providers/opencode-agent.ts index 520bab0d8b..cdfe6dba66 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.ts @@ -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 { + assertChangesAreLive({ + provider: "opencode", + patch: options, + current: this.config.providerOptions, + live: [OPENCODE_AUTO_ACCEPT_FEATURE_ID], + }); const next = validateProviderOptions( "opencode", OpenCodeProviderOptionsSchema, 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 721b38d1ad..9cf1b34cb3 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 @@ -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) => { 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 1a63a6e99e..27f5d7fb52 100644 --- a/packages/server/src/server/agent/providers/opencode/v2/session.ts +++ b/packages/server/src/server/agent/providers/opencode/v2/session.ts @@ -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,