mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-02 22:52:58 +00:00
fix(server): Claude rejects a live change to an option it reads when the query starts
Replacing a started query to apply the option would end its background shells and tasks. Once the query exists only Fast can change; before the first prompt every option still applies, because it is read when the query is built.
This commit is contained in:
+2
-1
@@ -47,7 +47,8 @@ or without a control.
|
||||
there is none; the provider returns the setting to its own default if it can, or throws to
|
||||
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
|
||||
starts its query, and replacing a started query would end its background work, so after the
|
||||
first prompt only Fast can change. 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
|
||||
|
||||
@@ -1507,61 +1507,6 @@ describe("ClaudeAgentSession features", () => {
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("a patch that does not set fast_mode leaves Fast on", 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 as unknown as { ensureQuery(): Promise<unknown> }).ensureQuery();
|
||||
await session.applyOptions?.({ fast_mode: true });
|
||||
queryMock.applyFlagSettings.mockClear();
|
||||
|
||||
// Setting another option sends only the keys set: Fast stays on.
|
||||
await session.applyOptions?.({ allowedTools: ["Read"] });
|
||||
|
||||
expect(queryMock.applyFlagSettings).not.toHaveBeenCalled();
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("a patch that lands while Fast is being applied is not overwritten by it", 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 as unknown as { ensureQuery(): Promise<unknown> }).ensureQuery();
|
||||
let finishFast!: () => void;
|
||||
queryMock.applyFlagSettings.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
finishFast = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
const fast = session.applyOptions?.({ fast_mode: true });
|
||||
await session.applyOptions?.({ allowedTools: ["Read"] });
|
||||
finishFast();
|
||||
await fast;
|
||||
|
||||
expect(
|
||||
(session as unknown as { config: { providerOptions: unknown } }).config.providerOptions,
|
||||
).toEqual({ fast_mode: true, allowedTools: ["Read"] });
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("a patch before the first prompt keeps the restrictions the session opened with", async () => {
|
||||
const { queryFactory } = createQueryMock();
|
||||
const client = new ClaudeAgentClient({
|
||||
@@ -1589,7 +1534,7 @@ describe("ClaudeAgentSession features", () => {
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("an option Claude reads when it starts applies from the next query", async () => {
|
||||
test("an option Claude reads when it starts changes before the query exists and is rejected after", async () => {
|
||||
const { queryFactory } = createQueryMock();
|
||||
const client = new ClaudeAgentClient({
|
||||
logger,
|
||||
@@ -1602,40 +1547,22 @@ describe("ClaudeAgentSession features", () => {
|
||||
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({
|
||||
await (session as unknown as { ensureQuery(): Promise<unknown> }).ensureQuery();
|
||||
expect(queryFactory.mock.calls[0]?.[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");
|
||||
|
||||
// The query now runs with these; replacing it would end its background work.
|
||||
await expect(session.applyOptions?.({ disallowedTools: [] })).rejects.toThrow(
|
||||
/disallowedTools.*cannot change while a turn is running/,
|
||||
/disallowedTools.*cannot change on a running session/,
|
||||
);
|
||||
// The same value is not a change.
|
||||
await expect(session.applyOptions?.({ disallowedTools: ["Bash"] })).resolves.toBeUndefined();
|
||||
// The same value is not a change, and Fast still applies.
|
||||
await expect(
|
||||
session.applyOptions?.({ disallowedTools: ["Bash", "Write"], fast_mode: true }),
|
||||
).resolves.toBeUndefined();
|
||||
expect(queryFactory).toHaveBeenCalledTimes(1);
|
||||
await session.close();
|
||||
});
|
||||
|
||||
@@ -1666,7 +1593,7 @@ describe("ClaudeAgentSession features", () => {
|
||||
// still read Fast as off: a removed key would let the default fill `true` back in.
|
||||
const stored = applyProviderOptionsPatch({ fast_mode: true }, reported[0].patch);
|
||||
expect(mergeProviderOptions({ fast_mode: true }, stored)).toEqual({ fast_mode: false });
|
||||
await expect(session.applyOptions?.({ allowedTools: ["Read"] })).resolves.toBeUndefined();
|
||||
await expect(session.applyOptions?.({ fast_mode: null })).resolves.toBeUndefined();
|
||||
await session.close();
|
||||
});
|
||||
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
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 { ProviderOptionsValidationError, validateProviderOptions } from "../../provider-options.js";
|
||||
import { assertChangesAreLive, validateProviderOptions } from "../../provider-options.js";
|
||||
import type { ChildProcess } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
@@ -2810,6 +2809,16 @@ class ClaudeAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
async applyOptions(options: ProviderOptions): Promise<void> {
|
||||
// Every option but Fast is read when the query is built, and replacing a started query would
|
||||
// end its background work, so once it exists only Fast can change.
|
||||
if (this.query) {
|
||||
assertChangesAreLive({
|
||||
provider: "claude",
|
||||
patch: options,
|
||||
current: this.config.providerOptions,
|
||||
live: ["fast_mode"],
|
||||
});
|
||||
}
|
||||
const providerOptions =
|
||||
validateProviderOptions(
|
||||
"claude",
|
||||
@@ -2821,28 +2830,12 @@ 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.
|
||||
|
||||
Reference in New Issue
Block a user