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.
This commit is contained in:
Mohamed Boudra
2026-11-01 09:25:34 +01:00
parent 7856e7e986
commit d8d9f197be
2 changed files with 164 additions and 13 deletions
@@ -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<typeof createProviderHarness>) {
return harness.inputs.flatMap((input) =>
input.type === "session.configure" ? [input.changes.settings] : [],
);
}
function voiceFeature(payload: ReturnType<typeof toAgentPayload>) {
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());
@@ -1096,9 +1096,14 @@ class PluginAgentClient implements AgentClient {
persist: boolean;
}): Promise<PluginAgentSession> {
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<void> | null = null;
/** The setting values the session last reported, to tell which ones a config event changed. */
private reportedSettings = new Map<string, JsonValue>();
/** Each setting's value before Paseo first changed it, for an option that is later removed. */
private startingSettings = new Map<string, JsonValue>();
@@ -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<string, JsonValue> | null {
const next = settingValues(settings);
const changed: Record<string, JsonValue> = {};
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));