mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-25 18:52:39 +00:00
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>
This commit is contained in:
@@ -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<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((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<void>();
|
||||
readonly closed = deferred<void>();
|
||||
private stopped = false;
|
||||
private readonly output: AsyncGenerator<SDKMessage>;
|
||||
|
||||
constructor(input: ClaudeQueryInput) {
|
||||
this.output = this.events(input);
|
||||
}
|
||||
|
||||
private async *events(input: ClaudeQueryInput): AsyncGenerator<SDKMessage> {
|
||||
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<void>();
|
||||
let terminal = deferred<string>();
|
||||
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<string>();
|
||||
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();
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -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<Query> {
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user