diff --git a/packages/server/src/server/agent/plugin-provider.test.ts b/packages/server/src/server/agent/plugin-provider.test.ts index 01efca69f4..343a9a6ec5 100644 --- a/packages/server/src/server/agent/plugin-provider.test.ts +++ b/packages/server/src/server/agent/plugin-provider.test.ts @@ -1546,6 +1546,72 @@ describe("PluginAgentClientRegistry", () => { } }); + test("removing an option returns the plugin's setting to the value it started with", async () => { + const voices = [ + { label: "Direct", value: "direct" }, + { label: "Soft", value: "soft" }, + ]; + const harness = createProviderHarness({ + async handleInput(input, emit) { + if (input.type !== "session.configure") return false; + const voice = input.changes.settings?.voice ?? "direct"; + emit({ + type: "session.config", + sessionId: input.sessionId, + config: { + model: "plugin-model", + mode: "build", + models: [{ id: "plugin-model", label: "Plugin model" }], + modes: [{ id: "build", label: "Build" }], + thinkingOptions: [], + settings: [ + { + type: "select", + id: "voice", + label: "Voice", + value: String(voice), + options: voices, + }, + ], + }, + }); + emit({ type: "request.completed", requestId: input.requestId }); + return true; + }, + }); + const logger = createTestLogger(); + const registry = new PluginAgentClientRegistry(logger); + registry.replace([harness.registration]); + const manager = new AgentManager({ + logger, + clients: registry.clients(), + providerDefinitions: registry.definitions(), + }); + try { + const agent = await manager.createAgent( + { provider: harness.registration.id, cwd: tmpdir() }, + undefined, + { workspaceId: undefined }, + ); + const configured = () => + harness.inputs.flatMap((input) => + input.type === "session.configure" ? [input.changes.settings] : [], + ); + + await manager.setAgentOptions(agent.id, { voice: "soft" }); + await manager.setAgentOptions(agent.id, { voice: null }); + + expect(configured()).toEqual([{ voice: "soft" }, { voice: "direct" }]); + const payload = toAgentPayload(manager.getAgent(agent.id)!); + expect(payload.options).toEqual({}); + expect(payload.features?.find((feature) => feature.id === "voice")).toMatchObject({ + value: "direct", + }); + } finally { + await registry.shutdown(); + } + }); + test("adapts callback providers into the existing AgentClient and AgentSession path", async () => { const harness = createProviderHarness(); const registry = new PluginAgentClientRegistry(createTestLogger()); diff --git a/packages/server/src/server/agent/plugin-provider.ts b/packages/server/src/server/agent/plugin-provider.ts index 0be667b78f..3599642e23 100644 --- a/packages/server/src/server/agent/plugin-provider.ts +++ b/packages/server/src/server/agent/plugin-provider.ts @@ -1134,6 +1134,7 @@ class PluginAgentClient implements AgentClient { }); }, bind, + input.config.providerOptions ?? {}, ); bind(bridge); return session; @@ -1214,6 +1215,8 @@ class PluginAgentSession implements AgentSession { private replaying: Promise | null = null; /** The setting values the session last reported, to tell which ones a config event changed. */ private reportedSettings = new Map(); + /** Each setting's value before Paseo first changed it, for an option that is later removed. */ + private startingSettings = new Map(); constructor( readonly provider: string, @@ -1224,6 +1227,7 @@ class PluginAgentSession implements AgentSession { state: ProviderConfigState, ) => Promise, private readonly onBind: (bridge: ProviderRuntimeSession) => void, + private heldOptions: ProviderOptions, ) { this.subagentIdsBySession.set(bridge.id, null); this.reportedSettings = settingValues(bridge.config.settings); @@ -1432,14 +1436,26 @@ class PluginAgentSession implements AgentSession { async applyOptions(options: ProviderOptions): Promise { this.assertReplayComplete(); const settings: Record = {}; + const startingValues = new Map(); for (const setting of this.bridge.config.settings) { - const value = options[setting.id]; - if (value !== undefined && value !== setting.value) { - settings[setting.id] = toJsonValue(value, `setting '${setting.id}'`); - } + // An option removed from the agent returns the setting to the value it had before Paseo + // first changed it. + const value = options[setting.id] ?? this.startingSettings.get(setting.id); + if (value === undefined || value === setting.value) continue; + startingValues.set(setting.id, setting.value); + settings[setting.id] = toJsonValue(value, `setting '${setting.id}'`); } - if (Object.keys(settings).length > 0) { - await this.bridge.configure({ settings }); + // Held before the request, because the plugin's config report arrives while it is pending. + const previous = this.heldOptions; + this.heldOptions = options; + try { + if (Object.keys(settings).length > 0) await this.bridge.configure({ settings }); + } catch (error) { + this.heldOptions = previous; + throw error; + } + for (const [id, value] of startingValues) { + if (!this.startingSettings.has(id)) this.startingSettings.set(id, value); } } @@ -1524,6 +1540,9 @@ class PluginAgentSession implements AgentSession { const next = settingValues(settings); const changed: Record = {}; for (const [id, value] of next) { + // Only a setting the agent holds an option for is reported; the others show through the + // control's default. + if (!Object.hasOwn(this.heldOptions, id)) continue; if (this.reportedSettings.get(id) !== value) changed[id] = value; } this.reportedSettings = next; @@ -1795,6 +1814,7 @@ class PluginAgentSession implements AgentSession { this.bridge = bridge; this.subagentIdsBySession.set(bridge.id, null); this.reportedSettings = settingValues(bridge.config.settings); + this.startingSettings.clear(); for (const event of bridge.history) this.accept(event, false); this.unsubscribe = bridge.onEvent((event) => this.accept(event, true)); this.onBind(bridge); 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 99a8056349..d1fa82f2c2 100644 --- a/packages/server/src/server/agent/providers/claude/agent.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.test.ts @@ -1532,6 +1532,38 @@ describe("ClaudeAgentSession features", () => { await session.close(); }); + test("an unrelated options patch is accepted after switching away from a Fast model", 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", + }); + await session.setModel("claude-opus-4-8"); + await session.applyOptions?.({ fast_mode: true }); + await session.setModel("claude-sonnet-5"); + queryMock.applyFlagSettings.mockClear(); + + // The manager sends the full record, which still holds the dormant Fast preference. + await expect( + session.applyOptions?.({ fast_mode: true, allowedTools: ["Read"] }), + ).resolves.toBeUndefined(); + expect(queryMock.applyFlagSettings).not.toHaveBeenCalled(); + + // Turning Fast on for a model without it is still refused. + await expect(session.applyOptions?.({ allowedTools: ["Read"] })).resolves.toBeUndefined(); + await expect( + session.applyOptions?.({ fast_mode: true, allowedTools: ["Read"] }), + ).rejects.toThrow("Claude fast mode is not available for model 'claude-sonnet-5'"); + + await session.close(); + }); + test("fast mode survives a daemon restart through the stored providerOptions", async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), "claude-fast-restart-")); const storagePath = path.join(root, "agents"); diff --git a/packages/server/src/server/agent/providers/claude/agent.ts b/packages/server/src/server/agent/providers/claude/agent.ts index 9c8f70bd1c..bc779c489f 100644 --- a/packages/server/src/server/agent/providers/claude/agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -2800,15 +2800,19 @@ class ClaudeAgentSession implements AgentSession { async applyOptions(options: ProviderOptions): Promise { const providerOptions = validateProviderOptions("claude", ClaudeProviderOptionsSchema, options) ?? {}; - if (providerOptions.fast_mode === true && !claudeModelSupportsFastMode(this.config.model)) { + const wantsFast = providerOptions.fast_mode === true; + const hadFast = this.config.providerOptions.fast_mode === true; + const modelSupportsFast = claudeModelSupportsFastMode(this.config.model); + if (wantsFast && !hadFast && !modelSupportsFast) { throw new Error( `Claude fast mode is not available for model '${this.config.model ?? "default"}'`, ); } - const fastModeChanged = - (providerOptions.fast_mode === true) !== (this.config.providerOptions.fast_mode === true); - if (fastModeChanged) { - await this.query?.applyFlagSettings({ fastMode: providerOptions.fast_mode === true }); + // A saved Fast preference stays dormant on a model without Fast (see `setModel`), so the + // runtime setting is the preference filtered by model support. + const runtimeFast = wantsFast && modelSupportsFast; + if (runtimeFast !== (hadFast && modelSupportsFast)) { + await this.query?.applyFlagSettings({ fastMode: runtimeFast }); this.cachedRuntimeInfo = null; } // Held until the runtime accepts, so a rejected change is retried instead of skipped.