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:
Mohamed Boudra
2026-11-04 04:24:54 +01:00
parent 450854101e
commit 0dfa5a41b2
3 changed files with 24 additions and 103 deletions
@@ -1697,61 +1697,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({
@@ -1779,7 +1724,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,
@@ -1792,40 +1737,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();
});
@@ -1856,7 +1783,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";
@@ -2817,6 +2816,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",
@@ -2828,28 +2837,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.