fix(server): restore plugin settings when an option is removed, keep a dormant Claude Fast preference editable

- A plugin setting returns to the value it had before Paseo first changed it when its option is
  removed. Plugin-reported changes are reported only for settings the agent holds an option for,
  so removing an option does not bring it back.
- Claude accepts an unrelated options patch on a model without Fast while the saved Fast preference
  stays dormant. Turning Fast on for such a model is still refused, and the runtime setting is the
  preference filtered by model support.
This commit is contained in:
Mohamed Boudra
2026-11-01 09:12:37 +01:00
parent ca7a9c14de
commit 7856e7e986
4 changed files with 133 additions and 11 deletions
@@ -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());
@@ -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<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>();
constructor(
readonly provider: string,
@@ -1224,6 +1227,7 @@ class PluginAgentSession implements AgentSession {
state: ProviderConfigState,
) => Promise<ProviderRuntimeSession>,
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<void> {
this.assertReplayComplete();
const settings: Record<string, JsonValue> = {};
const startingValues = new Map<string, JsonValue>();
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<string, JsonValue> = {};
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);
@@ -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");
@@ -2800,15 +2800,19 @@ class ClaudeAgentSession implements AgentSession {
async applyOptions(options: ProviderOptions): Promise<void> {
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.