mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-01 14:12:51 +00:00
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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user