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);