mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-02 06:33:05 +00:00
fix(server): a live patch is rejected for an option the session only reads at start, and opening reports every option it ended up with
A stored option has to describe what runs. OpenCode, OMP and ACP now reject a live change to a key they apply only when the agent opens; Claude applies such a change from the next turn and rejects it while a turn is running. Opening reports every select or setting the session came up with, including ones nobody set, so a native default (Cursor opening with Fast on) is stored as option state like any other.
This commit is contained in:
+9
-3
@@ -37,12 +37,18 @@ option and read `options`; they hold no value of their own. Any option can be re
|
||||
or without a control.
|
||||
|
||||
- **Create and resume.** The provider opens with the options it is given and emits nothing about
|
||||
them while opening. If the session ended up different, it sets `openOptionsPatch` before
|
||||
`createSession`/`resumeSession` resolves. The daemon stores it before the agent is published.
|
||||
them while opening. `openOptionsPatch` carries every option the session ended up with that
|
||||
differs from what it was given, including a setting nobody asked for (a native default such as
|
||||
Cursor opening with Fast on). It is set before `createSession`/`resumeSession` resolves and the
|
||||
daemon stores it before the agent is published. A reopened agent resumes that state, so a
|
||||
native default the provider later changes does not follow.
|
||||
- **Live apply.** `applyOptions` receives the keys the patch touches, resolved against the
|
||||
`config.json` defaults. A removal arrives as the default the key falls back to, or `null` when
|
||||
there is none; the provider returns the setting to its own default if it can, or throws to
|
||||
reject. It returns the options it ended up with when they differ from the request. The daemon
|
||||
reject. A key the running session cannot apply (one the provider reads only when it opens) is
|
||||
rejected too, so a stored option always describes what runs. Claude reads most options when it
|
||||
starts a query: it applies them from the next turn and rejects them while a turn is running. It
|
||||
returns the options it ended up with when they differ from the request. The daemon
|
||||
stores the patch and that result in one step after the call resolves, onto the agent's options
|
||||
as they are at that moment, so the last write wins. Throwing rejects the whole patch and
|
||||
none of it is stored. Emit nothing from inside the call. If the provider's own state moved
|
||||
|
||||
@@ -732,7 +732,9 @@ export interface AgentSession {
|
||||
* Apply an options patch. `options` holds only the keys the patch touches, resolved against
|
||||
* the `config.json` defaults; `null` means nothing is set for the key, so return it to the
|
||||
* session's own default if that can be done. Leave every other setting alone. Throw to reject a
|
||||
* value; the daemon stores the patch only when this resolves, so the call is all-or-nothing.
|
||||
* value, or a key the running session cannot apply (one the provider reads only when it
|
||||
* opens): the daemon stores the patch only when this resolves, so a stored option always
|
||||
* describes what runs, and the call is all-or-nothing.
|
||||
* Emit nothing from here. Return the options the session ended up with when they differ from
|
||||
* the request (a patch, `null` removes a key), for example a value it kept because it could not
|
||||
* be reverted; the daemon stores it together with the patch. When a throw leaves the session's
|
||||
|
||||
@@ -974,7 +974,7 @@ describe("PluginAgentClientRegistry", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("stores only agent options while the plugin receives merged defaults", async () => {
|
||||
test("stores the agent options and the settings the plugin opened with, not the merged config defaults", async () => {
|
||||
const logger = createTestLogger();
|
||||
const harness = createProviderHarness();
|
||||
const plugins = new PluginAgentClientRegistry(logger);
|
||||
@@ -996,7 +996,8 @@ describe("PluginAgentClientRegistry", () => {
|
||||
undefined,
|
||||
{ workspaceId: undefined },
|
||||
);
|
||||
expect(toStoredAgentRecord(agent).config?.providerOptions).toEqual(own);
|
||||
// `nested.base` comes from `config.json` and is not stored; `voice` is the plugin's own opening state.
|
||||
expect(toStoredAgentRecord(agent).config?.providerOptions).toEqual({ ...own, voice: "direct" });
|
||||
expect(harness.inputs.find((input) => input.type === "session.open")).toMatchObject({
|
||||
config: { providerOptions: { nested: { base: true, replace: "agent" } } },
|
||||
});
|
||||
@@ -1114,6 +1115,14 @@ describe("PluginAgentClientRegistry", () => {
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("settings the plugin opened with that nobody set are in the open patch", async () => {
|
||||
const { session, events } = await openStubbornVoiceSession({});
|
||||
|
||||
expect(session.openOptionsPatch).toEqual({ voice: "direct", loud: false });
|
||||
expect(eventsOfType(events, "options_changed")).toEqual([]);
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("applying options configures only the settings it is given", async () => {
|
||||
const { harness, session } = await openStubbornVoiceSession({});
|
||||
|
||||
|
||||
@@ -1231,9 +1231,8 @@ class PluginAgentSession implements AgentSession {
|
||||
givenSettings: Readonly<Record<string, JsonValue>>,
|
||||
) {
|
||||
this.subagentIdsBySession.set(bridge.id, null);
|
||||
this.openOptionsPatch = Object.fromEntries(
|
||||
Object.entries(this.settingDifferences(givenSettings)).filter(([id]) => id in givenSettings),
|
||||
);
|
||||
// Every setting the session opened with that was not given, including ones nobody set.
|
||||
this.openOptionsPatch = this.settingDifferences(givenSettings);
|
||||
this.knownSettings = this.currentSettings();
|
||||
for (const event of bridge.history) this.accept(event, false);
|
||||
// Opening notices arrive before the manager subscribes to the session.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ProviderOptions } from "@getpaseo/protocol/agent-types";
|
||||
import { isDeepStrictEqual } from "node:util";
|
||||
import type { z } from "zod";
|
||||
|
||||
export interface ProviderOptionIssue {
|
||||
@@ -103,3 +104,26 @@ function isPlainObject(value: unknown): value is ProviderOptions {
|
||||
const prototype = Object.getPrototypeOf(value);
|
||||
return prototype === Object.prototype || prototype === null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rejects a live patch that changes a key the running session cannot apply (one the provider
|
||||
* reads only when it opens). A key whose value already matches the session's is not a change.
|
||||
*/
|
||||
export function assertChangesAreLive(input: {
|
||||
provider: string;
|
||||
patch: ProviderOptions;
|
||||
current: ProviderOptions | undefined;
|
||||
live: readonly string[];
|
||||
}): void {
|
||||
const issues = Object.entries(input.patch)
|
||||
.filter(
|
||||
([key, value]) =>
|
||||
!input.live.includes(key) &&
|
||||
!isDeepStrictEqual(input.current?.[key] ?? null, value ?? null),
|
||||
)
|
||||
.map(([key]) => ({
|
||||
path: [key],
|
||||
message: "cannot change on a running session; it applies when the agent is opened",
|
||||
}));
|
||||
if (issues.length > 0) throw new ProviderOptionsValidationError(input.provider, issues);
|
||||
}
|
||||
|
||||
@@ -1883,6 +1883,13 @@ describe("ACPAgentSession options against an ACP agent", () => {
|
||||
expect(optionEvents).toEqual([]);
|
||||
});
|
||||
|
||||
test("a select the agent opened with that nobody set is in the open patch", async () => {
|
||||
const { session, optionEvents } = await openSession({ agent: "Probe Agent" });
|
||||
|
||||
expect(session.openOptionsPatch).toEqual({ agent: "Probe Agent" });
|
||||
expect(optionEvents).toEqual([]);
|
||||
});
|
||||
|
||||
test("a notification that arrives before the open write is answered is not an event", async () => {
|
||||
const { session, optionEvents } = await openSession({
|
||||
providerOptions: { agent: "Probe Agent" },
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ProviderOptions } from "@getpaseo/protocol/agent-types";
|
||||
import { ACPProviderOptionsSchema } from "./acp-options.js";
|
||||
import { applyProviderOptionsPatch } from "../option-controls.js";
|
||||
import { assertChangesAreLive } from "../provider-options.js";
|
||||
import { type ChildProcess, type ChildProcessWithoutNullStreams } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
@@ -2406,6 +2407,12 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
* rejected, what the provider moved is reported as an event, since nothing is stored.
|
||||
*/
|
||||
async applyOptions(options: ProviderOptions): Promise<Record<string, unknown>> {
|
||||
assertChangesAreLive({
|
||||
provider: this.config.provider,
|
||||
patch: options,
|
||||
current: this.config.providerOptions,
|
||||
live: [ACP_AUTO_ACCEPT_FEATURE_ID, ...this.configFeatureOptions.map(({ id }) => id)],
|
||||
});
|
||||
ACPProviderOptionsSchema.parse(applyProviderOptionsPatch(this.config.providerOptions, options));
|
||||
const before = this.featureValues();
|
||||
const requested: Record<string, unknown> = {};
|
||||
@@ -3119,7 +3126,13 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
);
|
||||
}
|
||||
}
|
||||
this.openOptionsPatch = this.featureDifferences(configuredFeatureValues);
|
||||
// The options are the state, so every select the session opened with is reported, including
|
||||
// ones nobody set (a native default is stored like any other value).
|
||||
this.openOptionsPatch = Object.fromEntries(
|
||||
Object.entries(this.featureValues()).filter(
|
||||
([id, value]) => configuredFeatureValues[id] !== value,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1589,6 +1589,56 @@ describe("ClaudeAgentSession features", () => {
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("an option Claude reads when it starts applies from the next query", async () => {
|
||||
const { queryFactory } = 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",
|
||||
providerOptions: { disallowedTools: ["Bash"] },
|
||||
});
|
||||
const ensureQuery = () =>
|
||||
(session as unknown as { ensureQuery(): Promise<unknown> }).ensureQuery();
|
||||
await ensureQuery();
|
||||
|
||||
await session.applyOptions?.({ disallowedTools: ["Bash", "Write"] });
|
||||
await ensureQuery();
|
||||
|
||||
expect(queryFactory).toHaveBeenCalledTimes(2);
|
||||
expect(queryFactory.mock.calls[1]?.[0].options).toMatchObject({
|
||||
disallowedTools: ["Bash", "Write"],
|
||||
});
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("rejects a change to an option Claude reads at start while a turn is running", async () => {
|
||||
const { queryFactory } = 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",
|
||||
providerOptions: { disallowedTools: ["Bash"] },
|
||||
});
|
||||
await session.startTurn("hello");
|
||||
|
||||
await expect(session.applyOptions?.({ disallowedTools: [] })).rejects.toThrow(
|
||||
/disallowedTools.*cannot change while a turn is running/,
|
||||
);
|
||||
// The same value is not a change.
|
||||
await expect(session.applyOptions?.({ disallowedTools: ["Bash"] })).resolves.toBeUndefined();
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("switching to a model without Fast turns it off, reports that, and later patches still apply", async () => {
|
||||
const { queryFactory, queryMock } = createQueryMock();
|
||||
const client = new ClaudeAgentClient({
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { setImmediate } from "node:timers/promises";
|
||||
import { isDeepStrictEqual } from "node:util";
|
||||
import { ClaudeRuntimeSafety } from "./runtime-safety.js";
|
||||
import type { ProviderOptions } from "@getpaseo/protocol/agent-types";
|
||||
import { applyProviderOptionsPatch } from "../../option-controls.js";
|
||||
import { validateProviderOptions } from "../../provider-options.js";
|
||||
import { ProviderOptionsValidationError, validateProviderOptions } from "../../provider-options.js";
|
||||
import type { ChildProcess } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
@@ -2820,12 +2821,28 @@ class ClaudeAgentSession implements AgentSession {
|
||||
`Claude fast mode is not available for model '${this.config.model ?? "default"}'`,
|
||||
);
|
||||
}
|
||||
const current: ProviderOptions = this.config.providerOptions;
|
||||
const next: ProviderOptions = providerOptions;
|
||||
const changedLaunchKeys = Object.keys(options).filter(
|
||||
(key) => key !== "fast_mode" && !isDeepStrictEqual(current[key] ?? null, next[key] ?? null),
|
||||
);
|
||||
if (changedLaunchKeys.length > 0 && (this.activeForegroundTurnId || this.autonomousTurn)) {
|
||||
throw new ProviderOptionsValidationError(
|
||||
"claude",
|
||||
changedLaunchKeys.map((key) => ({
|
||||
path: [key],
|
||||
message: "cannot change while a turn is running; Claude reads it when a turn starts",
|
||||
})),
|
||||
);
|
||||
}
|
||||
const fastModeChanged =
|
||||
(providerOptions.fast_mode === true) !== (this.config.providerOptions.fast_mode === true);
|
||||
if (fastModeChanged) {
|
||||
await this.query?.applyFlagSettings({ fastMode: providerOptions.fast_mode === true });
|
||||
this.cachedRuntimeInfo = null;
|
||||
}
|
||||
// Every other option is read when the query is built, so it applies from the next turn.
|
||||
if (changedLaunchKeys.length > 0) this.queryRestartNeeded = true;
|
||||
// Held until the runtime accepts, so a rejected change is retried instead of skipped. Only
|
||||
// this call's keys are applied, over the options as they are now: another patch may have
|
||||
// landed while the runtime was answering.
|
||||
|
||||
@@ -72,7 +72,7 @@ import {
|
||||
} from "./provider-config.js";
|
||||
export { formatOmpVersionSupport, resolveOmpDiagnosticPaths } from "./provider-config.js";
|
||||
import { applyProviderOptionsPatch } from "../../option-controls.js";
|
||||
import { validateProviderOptions } from "../../provider-options.js";
|
||||
import { assertChangesAreLive, validateProviderOptions } from "../../provider-options.js";
|
||||
import { OmpSubagentCardTracker } from "./subagent-card-tracker.js";
|
||||
import { ompSkillPromptUserText, shouldDisplayOmpCustomMessage } from "./custom-message.js";
|
||||
import { getUserMessageText } from "./message-history.js";
|
||||
@@ -825,6 +825,12 @@ export class OmpAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
async applyOptions(options: ProviderOptions): Promise<Record<string, unknown>> {
|
||||
assertChangesAreLive({
|
||||
provider: "omp",
|
||||
patch: options,
|
||||
current: this.config.providerOptions,
|
||||
live: ["fast_mode"],
|
||||
});
|
||||
const next = validateProviderOptions(
|
||||
"omp",
|
||||
OmpProviderOptionsSchema,
|
||||
|
||||
@@ -311,6 +311,27 @@ describe("OpenCode auto_accept feature", () => {
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("rejects a live permission change the running session cannot apply", async () => {
|
||||
const { runtime } = mockOpenCodeClient({ events: [idleEvent()] });
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
|
||||
serverManager: runtime,
|
||||
createClient: runtime.createClient,
|
||||
});
|
||||
const session = await client.createSession({
|
||||
provider: "opencode",
|
||||
cwd: "/tmp/project",
|
||||
providerOptions: { permission: "allow" },
|
||||
});
|
||||
|
||||
await expect(session.applyOptions?.({ permission: "deny" })).rejects.toThrow(
|
||||
/permission.*cannot change on a running session/,
|
||||
);
|
||||
await expect(
|
||||
session.applyOptions?.({ permission: "allow", auto_accept: true }),
|
||||
).resolves.toBeUndefined();
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("stops auto-approving once auto_accept is removed", async () => {
|
||||
const { openCodeClient, runtime } = mockOpenCodeClient({
|
||||
events: [toolPermissionEvent(), idleEvent()],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ProviderOptions } from "@getpaseo/protocol/agent-types";
|
||||
import { applyProviderOptionsPatch } from "../option-controls.js";
|
||||
import { validateProviderOptions } from "../provider-options.js";
|
||||
import { assertChangesAreLive, validateProviderOptions } from "../provider-options.js";
|
||||
import {
|
||||
createOpencodeClient,
|
||||
type AssistantMessage as OpenCodeAssistantMessage,
|
||||
@@ -4965,6 +4965,12 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
async applyOptions(options: ProviderOptions): Promise<void> {
|
||||
assertChangesAreLive({
|
||||
provider: "opencode",
|
||||
patch: options,
|
||||
current: this.config.providerOptions,
|
||||
live: [OPENCODE_AUTO_ACCEPT_FEATURE_ID],
|
||||
});
|
||||
const next = validateProviderOptions(
|
||||
"opencode",
|
||||
OpenCodeProviderOptionsSchema,
|
||||
|
||||
@@ -50,6 +50,27 @@ describe("OpenCode v2 session lifecycle", () => {
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("rejects a live permission change the running session cannot apply", async () => {
|
||||
const harness = new V2Harness();
|
||||
const client = new OpenCodeV2AgentClient({
|
||||
logger: createTestLogger(),
|
||||
runtime: harness.runtime,
|
||||
});
|
||||
const session = await client.createSession({
|
||||
provider: "opencode",
|
||||
cwd: "/tmp/project",
|
||||
providerOptions: { permission: "allow" },
|
||||
});
|
||||
|
||||
await expect(session.applyOptions?.({ permission: "deny" })).rejects.toThrow(
|
||||
/permission.*cannot change on a running session/,
|
||||
);
|
||||
await expect(
|
||||
session.applyOptions?.({ permission: "allow", auto_accept: true }),
|
||||
).resolves.toBeUndefined();
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("completes from execution events without a long-lived HTTP wait", async () => {
|
||||
const harness = new V2Harness();
|
||||
harness.prompt = async (input) => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ProviderOptions } from "@getpaseo/protocol/agent-types";
|
||||
import { applyProviderOptionsPatch } from "../../../option-controls.js";
|
||||
import { validateProviderOptions } from "../../../provider-options.js";
|
||||
import { assertChangesAreLive, validateProviderOptions } from "../../../provider-options.js";
|
||||
import { OpenCodeProviderOptionsSchema } from "../options.js";
|
||||
import { SessionChildren } from "./children.js";
|
||||
import { SessionTurns } from "./turns.js";
|
||||
@@ -363,6 +363,12 @@ export class OpenCodeV2Session implements AgentSession {
|
||||
this.emit({ type: "thinking_option_changed", provider: "opencode", thinkingOptionId: variant });
|
||||
}
|
||||
async applyOptions(options: ProviderOptions) {
|
||||
assertChangesAreLive({
|
||||
provider: "opencode",
|
||||
patch: options,
|
||||
current: this.config.providerOptions,
|
||||
live: ["auto_accept"],
|
||||
});
|
||||
const next = validateProviderOptions(
|
||||
"opencode",
|
||||
OpenCodeProviderOptionsSchema,
|
||||
|
||||
Reference in New Issue
Block a user