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));