From 88a7aec1b89ede815387d3d378e01470d170c1ae Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Sat, 17 Oct 2026 03:54:00 +0200 Subject: [PATCH] fix(claude): keep active turns running through settings changes (#7179) A pending thinking change retired the live query when mode, model, or command controls requested it. Reuse the query owned by the current turn and apply pending launch settings after the turn finishes. Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- .../claude/agent.live-settings.test.ts | 157 ++++++++++++++++++ .../server/agent/providers/claude/agent.ts | 8 +- .../claude-settings-mid-turn.real.e2e.test.ts | 60 +++++++ 3 files changed, 222 insertions(+), 3 deletions(-) create mode 100644 packages/server/src/server/agent/providers/claude/agent.live-settings.test.ts create mode 100644 packages/server/src/server/daemon-e2e/claude-settings-mid-turn.real.e2e.test.ts diff --git a/packages/server/src/server/agent/providers/claude/agent.live-settings.test.ts b/packages/server/src/server/agent/providers/claude/agent.live-settings.test.ts new file mode 100644 index 0000000000..cb272ebecb --- /dev/null +++ b/packages/server/src/server/agent/providers/claude/agent.live-settings.test.ts @@ -0,0 +1,157 @@ +import { expect, test } from "vitest"; +import type { Query, SDKMessage } from "@anthropic-ai/claude-agent-sdk"; +import { createTestLogger } from "../../../../test-utils/test-logger.js"; +import type { AgentStreamEvent } from "../../agent-sdk-types.js"; +import { ClaudeAgentClient } from "./agent.js"; +import type { ClaudeQueryInput } from "./query.js"; + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +// The injected SDK adapter consumes real session input and holds a command open +// until the test releases it. Closing the query interrupts that command. +class ForegroundQuery { + readonly work = deferred(); + readonly closed = deferred(); + private stopped = false; + private readonly output: AsyncGenerator; + + constructor(input: ClaudeQueryInput) { + this.output = this.events(input); + } + + private async *events(input: ClaudeQueryInput): AsyncGenerator { + if (typeof input.prompt === "string") throw new Error("Expected streaming input"); + for await (const _prompt of input.prompt) { + yield { + type: "system", + subtype: "init", + session_id: "settings-session", + model: "claude-haiku-5-5", + permissionMode: "auto", + } as SDKMessage; + yield { + type: "assistant", + message: { + content: [ + { type: "tool_use", id: "tool_sleep", name: "Bash", input: { command: "sleep 20" } }, + ], + }, + } as SDKMessage; + await this.work.promise; + if (this.stopped) return; + yield { type: "assistant", message: { content: "DONE" } } as SDKMessage; + yield { + type: "result", + subtype: "success", + usage: { input_tokens: 1, cache_read_input_tokens: 0, output_tokens: 1 }, + total_cost_usd: 0, + } as SDKMessage; + } + } + + next() { + return this.output.next(); + } + return() { + this.close(); + return this.output.return(); + } + close() { + this.stopped = true; + this.closed.resolve(); + this.work.resolve(); + } + async interrupt() { + this.close(); + } + async setPermissionMode() {} + async setModel() {} + async supportedCommands() { + return []; + } + [Symbol.asyncIterator]() { + return this; + } +} + +test.each(["mode", "model", "commands"] as const)( + "a pending thinking change preserves the current turn during a %s request", + async (control) => { + const queries: ForegroundQuery[] = []; + const efforts: ClaudeQueryInput["options"]["effort"][] = []; + const client = new ClaudeAgentClient({ + logger: createTestLogger(), + resolveBinary: async () => "/test/claude", + queryFactory: (input) => { + const query = new ForegroundQuery(input); + queries.push(query); + efforts.push(input.options.effort); + return query as unknown as Query; + }, + }); + const session = await client.createSession({ + provider: "claude", + cwd: process.cwd(), + model: "claude-haiku-5-5", + modeId: "auto", + thinkingOptionId: "low", + }); + const running = deferred(); + let terminal = deferred(); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => { + events.push(event); + if ( + event.type === "timeline" && + event.item.type === "tool_call" && + event.item.status === "running" + ) + running.resolve(); + if ( + event.type === "turn_completed" || + event.type === "turn_failed" || + event.type === "turn_canceled" + ) + terminal.resolve(event.type); + }); + try { + await session.startTurn("Run the foreground command"); + await running.promise; + expect(await session.setThinkingOption?.("medium")).toMatchObject({ + message: "Thinking level applies next turn", + }); + if (control === "mode") await session.setMode("acceptEdits"); + else if (control === "model") await session.setModel("claude-sonnet-5-6"); + else await session.listCommands(); + queries[0].work.resolve(); + expect( + await Promise.race([ + terminal.promise, + queries[0].closed.promise.then(() => "query closed before turn finished"), + ]), + ).toBe("turn_completed"); + expect( + events.some( + (event) => + event.type === "timeline" && + event.item.type === "assistant_message" && + event.item.text === "DONE", + ), + ).toBe(true); + + terminal = deferred(); + await session.startTurn("Run another command with the new thinking level"); + queries[1].work.resolve(); + expect(await terminal.promise).toBe("turn_completed"); + expect(efforts).toEqual(["low", "medium"]); + } finally { + await session.close(); + } + }, +); diff --git a/packages/server/src/server/agent/providers/claude/agent.ts b/packages/server/src/server/agent/providers/claude/agent.ts index e88ae34662..7517a87029 100644 --- a/packages/server/src/server/agent/providers/claude/agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -2610,13 +2610,13 @@ class ClaudeAgentSession implements AgentSession { const normalized = isPermissionMode(modeId) ? modeId : "default"; assertClaudeModeCanRun(normalized, this.harnessEnvironment); const previousMode = this.currentMode; - const launchesQuery = !this.query || this.queryRestartNeeded; + const previousQuery = this.query; const activeQuery = await this.ensureQuery(normalized); try { await activeQuery.setPermissionMode(normalized); } catch (error) { // The query was launched in the rejected mode; relaunch in the current mode next time. - if (launchesQuery) this.queryRestartNeeded = true; + if (activeQuery !== previousQuery) this.queryRestartNeeded = true; throw error; } if (normalized === "plan") { @@ -3314,7 +3314,9 @@ class ClaudeAgentSession implements AgentSession { } private async ensureQuery(launchMode: PermissionMode = this.currentMode): Promise { - if (this.query && !this.queryRestartNeeded) { + // Controls use the running turn's query. Pending launch settings take effect + // after that turn finishes, when its active query handle has been cleared. + if (this.query && (!this.queryRestartNeeded || this.activeForegroundQuery === this.query)) { return this.query; } diff --git a/packages/server/src/server/daemon-e2e/claude-settings-mid-turn.real.e2e.test.ts b/packages/server/src/server/daemon-e2e/claude-settings-mid-turn.real.e2e.test.ts new file mode 100644 index 0000000000..5211ff2a5a --- /dev/null +++ b/packages/server/src/server/daemon-e2e/claude-settings-mid-turn.real.e2e.test.ts @@ -0,0 +1,60 @@ +import { expect, test } from "vitest"; +import { mkdtemp, rm } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import pino from "pino"; +import { ClaudeAgentClient } from "../agent/providers/claude/agent.js"; +import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js"; +import { DaemonClient } from "../test-utils/daemon-client.js"; + +test("changing thinking then mode preserves a foreground Claude command", async () => { + const logger = pino({ level: "silent" }); + const cwd = await mkdtemp(path.join(tmpdir(), "claude-settings-mid-turn-")); + const daemon = await createTestPaseoDaemon({ + logger, + agentClients: { claude: new ClaudeAgentClient({ logger }) }, + }); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` }); + try { + await client.connect(); + await client.fetchAgents({ subscribe: {} }); + const agent = await client.createAgent({ + provider: "claude", + model: "claude-haiku-5-5", + modeId: "auto", + thinkingOptionId: "low", + cwd, + }); + await client.subscribeAgentTimeline(agent.id, () => {}).ready; + await client.sendMessage( + agent.id, + "Run exactly these three Bash commands in the foreground, one per tool call, in order: python3 -c \"from pathlib import Path; import time; Path('started').write_text('started'); time.sleep(20); print(1)\", then the same with print(2), then print(3). Then reply DONE.", + ); + // A tool-call row is emitted before Claude's permission check finishes. + // Wait for the command itself, so changing to acceptEdits cannot gate its start. + await expect.poll(() => existsSync(path.join(cwd, "started")), { timeout: 90_000 }).toBe(true); + const notice = await client.setAgentThinkingOption(agent.id, "medium"); + expect(notice).toEqual({ type: "warning", message: "Thinking level applies next turn" }); + await client.setAgentMode(agent.id, "acceptEdits"); + await expect + .poll( + async () => { + const timeline = await client.fetchAgentTimeline(agent.id, { limit: 100 }); + return timeline.entries.some( + ({ item }) => + item.type === "tool_call" && + item.detail.type === "shell" && + item.detail.command.includes("print(1)") && + item.status === "completed", + ); + }, + { timeout: 45_000, interval: 100 }, + ) + .toBe(true); + } finally { + await client.close(); + await daemon.close(); + await rm(cwd, { recursive: true, force: true }); + } +}, 180_000);