From d8d9f197bedb1f3df681e1f2ee77317cab76e49e Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 1 Nov 2026 09:25:34 +0100 Subject: [PATCH] fix(server): reset plugin settings supplied at open and follow the plugin's committed selection A plugin only reports the value a setting has after it opens, so overrides passed in the open request left no default for a later option removal to return to. The session now opens without setting overrides and applies them afterwards, which records the default. A plugin that completes a configure request but keeps its previous value reported nothing new, so the requested option was stored anyway. Config reports are now compared with the options the agent holds, not with the previous report. --- .../src/server/agent/plugin-provider.test.ts | 139 ++++++++++++++++++ .../src/server/agent/plugin-provider.ts | 38 +++-- 2 files changed, 164 insertions(+), 13 deletions(-) diff --git a/packages/server/src/server/agent/plugin-provider.test.ts b/packages/server/src/server/agent/plugin-provider.test.ts index 343a9a6ec5..0b2b432e37 100644 --- a/packages/server/src/server/agent/plugin-provider.test.ts +++ b/packages/server/src/server/agent/plugin-provider.test.ts @@ -876,6 +876,83 @@ function expectNestedChildren(events: AgentStreamEvent[]) { } } +const VOICES = [ + { label: "Direct", value: "direct" }, + { label: "Soft", value: "soft" }, +]; + +function voiceConfig(voice: string) { + return { + model: "plugin-model", + mode: "build", + models: [{ id: "plugin-model", label: "Plugin model" }], + modes: [{ id: "build", label: "Build" }], + thinkingOptions: [], + settings: [ + { type: "select" as const, id: "voice", label: "Voice", value: voice, options: VOICES }, + ], + }; +} + +/** + * A plugin whose `voice` setting defaults to "direct". With `acceptsVoice: false` it completes a + * configure request but keeps its voice. + */ +function startVoicePlugin(input: { acceptsVoice: boolean }) { + const harness = createProviderHarness({ + async handleInput(request, emit) { + if (request.type === "session.open") { + emit({ + type: "session.opened", + requestId: request.requestId, + sessionId: request.sessionId, + capabilities: CAPABILITIES, + restoration: "core", + persistence: { version: 1, data: { token: "root" } }, + cwd: request.config.cwd, + }); + emit({ + type: "session.config", + sessionId: request.sessionId, + config: voiceConfig(String(request.config.settings?.voice ?? "direct")), + }); + emit({ type: "session.ready", requestId: request.requestId, sessionId: request.sessionId }); + return true; + } + if (request.type !== "session.configure") return false; + const requested = request.changes.settings?.voice; + emit({ + type: "session.config", + sessionId: request.sessionId, + config: voiceConfig( + input.acceptsVoice && typeof requested === "string" ? requested : "direct", + ), + }); + emit({ type: "request.completed", requestId: request.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(), + }); + return { harness, manager, registry }; +} + +function configuredSettings(harness: ReturnType) { + return harness.inputs.flatMap((input) => + input.type === "session.configure" ? [input.changes.settings] : [], + ); +} + +function voiceFeature(payload: ReturnType) { + return payload.features?.find((feature) => feature.id === "voice"); +} + describe("PluginAgentClientRegistry", () => { test("history keeps the tool's first position when completion follows later text", async () => { const harness = createProviderHarness(); @@ -1612,6 +1689,68 @@ describe("PluginAgentClientRegistry", () => { } }); + test("removing an option the agent was created with returns the setting to the plugin's default", async () => { + const { harness, manager, registry } = startVoicePlugin({ acceptsVoice: true }); + try { + const agent = await manager.createAgent( + { provider: harness.registration.id, cwd: tmpdir(), providerOptions: { voice: "soft" } }, + undefined, + { workspaceId: undefined }, + ); + expect(toAgentPayload(manager.getAgent(agent.id)!).options).toEqual({ voice: "soft" }); + + await manager.setAgentOptions(agent.id, { voice: null }); + + expect(configuredSettings(harness)).toEqual([{ voice: "soft" }, { voice: "direct" }]); + const payload = toAgentPayload(manager.getAgent(agent.id)!); + expect(payload.options).toEqual({}); + expect(voiceFeature(payload)).toMatchObject({ value: "direct" }); + } finally { + await registry.shutdown(); + } + }); + + test("removing an option a resumed agent held returns the setting to the plugin's default", async () => { + const { harness, registry } = startVoicePlugin({ acceptsVoice: true }); + try { + const client = registry.clients()[harness.registration.id]!; + const first = await client.createSession({ + provider: harness.registration.id, + cwd: tmpdir(), + }); + const resumed = await client.resumeSession(first.describePersistence()!, { + cwd: tmpdir(), + providerOptions: { voice: "soft" }, + }); + + await resumed.applyOptions!({}); + + expect(configuredSettings(harness)).toEqual([{ voice: "soft" }, { voice: "direct" }]); + await resumed.close(); + } finally { + await registry.shutdown(); + } + }); + + test("a selection the plugin keeps on its previous value replaces the requested option", async () => { + const { harness, manager, registry } = startVoicePlugin({ acceptsVoice: false }); + try { + const agent = await manager.createAgent( + { provider: harness.registration.id, cwd: tmpdir(), providerOptions: { voice: "direct" } }, + undefined, + { workspaceId: undefined }, + ); + + await manager.setAgentOptions(agent.id, { voice: "soft" }); + + const payload = toAgentPayload(manager.getAgent(agent.id)!); + expect(payload.options).toEqual({ voice: "direct" }); + expect(voiceFeature(payload)).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 3599642e23..746898b6a4 100644 --- a/packages/server/src/server/agent/plugin-provider.ts +++ b/packages/server/src/server/agent/plugin-provider.ts @@ -1096,9 +1096,14 @@ class PluginAgentClient implements AgentClient { persist: boolean; }): Promise { const sessionId = randomUUID(); + // The plugin reports its own setting values once open, and those are what a later option + // removal returns to. So the session opens without setting overrides and applies them after. const bridge = await this.runtime.openSession({ sessionId, - config: mapSessionConfig(input.config, input.launchContext, input.persist), + config: { + ...mapSessionConfig(input.config, input.launchContext, input.persist), + settings: {}, + }, persistence: input.persistence, history: input.history, }); @@ -1137,6 +1142,12 @@ class PluginAgentClient implements AgentClient { input.config.providerOptions ?? {}, ); bind(bridge); + try { + await session.applyOptions(input.config.providerOptions ?? {}); + } catch (error) { + await session.close(); + throw error; + } return session; } @@ -1213,8 +1224,6 @@ class PluginAgentSession implements AgentSession { closed: boolean; } | null = null; 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(); @@ -1230,7 +1239,6 @@ class PluginAgentSession implements AgentSession { private heldOptions: ProviderOptions, ) { this.subagentIdsBySession.set(bridge.id, null); - this.reportedSettings = settingValues(bridge.config.settings); for (const event of bridge.history) this.accept(event, false); // Opening notices arrive before the manager subscribes to the session. // Commit them on registration and replay the same timestamps from history. @@ -1529,24 +1537,29 @@ class PluginAgentSession implements AgentSession { this.history.push(next); if (live) this.emit(next); } - if (event.type !== "session.config") return; - // A setting the plugin changed on its own is an option change, so the stored option + if (!live || event.type !== "session.config") return; + // A setting that is not what the agent holds is an option change, so the stored option // follows what the plugin reports. const patch = this.changedSettings(event.config.settings); - if (live && patch) this.emit({ type: "options_changed", provider: this.provider, patch }); + if (patch) this.emit({ type: "options_changed", provider: this.provider, patch }); } + /** + * The settings whose reported value differs from the option the agent holds. A plugin that + * changed a setting on its own, or kept its previous value when asked for another, reports the + * value that is in effect. + */ private changedSettings(settings: readonly ProviderSetting[]): Record | null { - const next = settingValues(settings); const changed: Record = {}; - for (const [id, value] of next) { + for (const [id, value] of settingValues(settings)) { // 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; + if (this.heldOptions[id] !== value) changed[id] = value; } - this.reportedSettings = next; - return Object.keys(changed).length > 0 ? changed : null; + if (Object.keys(changed).length === 0) return null; + this.heldOptions = { ...this.heldOptions, ...changed }; + return changed; } private translate(event: ProviderEvent): AgentStreamEvent[] { @@ -1813,7 +1826,6 @@ class PluginAgentSession implements AgentSession { const bridge = await this.reopen(replay.persistence, replay.config); 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));