fix(server): discard unread Claude steers on interrupt
A steer pushed into the live SDK query input survived query.interrupt(): the SDK dequeued it afterwards and resumed the turn the user had just stopped. Codex clears pending turn input when it aborts a turn; Claude does not, so its adapter now cancels the SDK messages it queued before interrupting. Interrupt-discards is the deliberate ceiling for steer cancellation: Codex exposes no un-steer operation, so a cross-provider take-back cannot exist. A steer the model never read keeps its transcript row.
This commit is contained in:
+104
-1
@@ -3,7 +3,7 @@ import { afterEach, expect, test, vi } from "vitest";
|
||||
import { createTestLogger } from "../../../../test-utils/test-logger.js";
|
||||
import { ClaudeAgentClient } from "./agent.js";
|
||||
import { streamSession } from "../test-utils/session-stream-adapter.js";
|
||||
import type { AgentStreamEvent } from "../../agent-sdk-types.js";
|
||||
import type { AgentSession, AgentStreamEvent } from "../../agent-sdk-types.js";
|
||||
|
||||
interface QueryMock {
|
||||
next: ReturnType<typeof vi.fn>;
|
||||
@@ -15,6 +15,7 @@ interface QueryMock {
|
||||
supportedModels: ReturnType<typeof vi.fn>;
|
||||
supportedCommands: ReturnType<typeof vi.fn>;
|
||||
rewindFiles: ReturnType<typeof vi.fn>;
|
||||
cancelAsyncMessage: ReturnType<typeof vi.fn>;
|
||||
[Symbol.asyncIterator]: () => AsyncIterator<Record<string, unknown>, void>;
|
||||
}
|
||||
|
||||
@@ -139,6 +140,7 @@ function createScriptedQuery(params: {
|
||||
supportedModels: vi.fn(async () => [{ value: "opus", displayName: "Opus" }]),
|
||||
supportedCommands: vi.fn(async () => []),
|
||||
rewindFiles: vi.fn(async () => ({ canRewind: true })),
|
||||
cancelAsyncMessage: vi.fn(async () => true),
|
||||
emit: (message: Record<string, unknown>) => {
|
||||
output.push(message);
|
||||
},
|
||||
@@ -195,6 +197,18 @@ async function collectUntilTerminal(
|
||||
return events;
|
||||
}
|
||||
|
||||
/** Pulls the stream forward without closing it, so the caller can keep reading afterwards. */
|
||||
async function consumeUntil(
|
||||
stream: AsyncGenerator<AgentStreamEvent>,
|
||||
matches: (event: AgentStreamEvent) => boolean,
|
||||
): Promise<void> {
|
||||
while (true) {
|
||||
const next = await stream.next();
|
||||
if (next.done) throw new Error("Stream ended before the expected event");
|
||||
if (matches(next.value)) return;
|
||||
}
|
||||
}
|
||||
|
||||
function collectAssistantText(events: AgentStreamEvent[]): string {
|
||||
return events
|
||||
.flatMap((event) => {
|
||||
@@ -285,6 +299,95 @@ test("interrupt only calls query.interrupt and leaves the query open", async ()
|
||||
await session.close();
|
||||
});
|
||||
|
||||
async function startSteeredTurn(sessionId: string): Promise<{
|
||||
session: AgentSession;
|
||||
query: () => ScriptedQuery | null;
|
||||
turn: AsyncGenerator<AgentStreamEvent>;
|
||||
}> {
|
||||
let query: ScriptedQuery | null = null;
|
||||
queryFactory.mockImplementation(({ prompt }: { prompt: AsyncIterable<unknown> }) => {
|
||||
query = createScriptedQuery({ prompt, sessionId });
|
||||
return query;
|
||||
});
|
||||
|
||||
const session = await new ClaudeAgentClient({
|
||||
logger: createTestLogger(),
|
||||
queryFactory,
|
||||
resolveBinary: async () => "/test/claude/bin",
|
||||
}).createSession({ provider: "claude", cwd: process.cwd() });
|
||||
|
||||
const turn = streamSession(session, "original running prompt");
|
||||
const start = await turn.next();
|
||||
if (!start.value || start.value.type !== "turn_started" || !start.value.turnId) {
|
||||
throw new Error("Expected the original Claude turn to start");
|
||||
}
|
||||
await waitFor(() => query?.prompts.length === 1);
|
||||
|
||||
const steered = await session.steerActiveTurn!("queued steer", {
|
||||
expectedTurnId: start.value.turnId,
|
||||
clientMessageId: "steer-client",
|
||||
});
|
||||
expect(steered).toEqual({ status: "accepted" });
|
||||
await waitFor(() => query?.prompts.length === 2);
|
||||
|
||||
return { session, query: () => query, turn };
|
||||
}
|
||||
|
||||
test("interrupt discards a queued steer so it cannot resume the stopped turn", async () => {
|
||||
const { session, query, turn } = await startSteeredTurn("queued-steer-discard-session");
|
||||
|
||||
await session.interrupt();
|
||||
await waitFor(() => query()?.interrupt.mock.calls.length === 1);
|
||||
|
||||
expect(query()?.cancelAsyncMessage).toHaveBeenCalledWith(query()?.prompts[1]?.uuid);
|
||||
expect(await collectUntilTerminal(turn)).toContainEqual(
|
||||
expect.objectContaining({ type: "turn_canceled" }),
|
||||
);
|
||||
|
||||
// Nothing is queued any more, so a second interrupt has no steer left to discard.
|
||||
query()?.cancelAsyncMessage.mockClear();
|
||||
await session.interrupt();
|
||||
await waitFor(() => query()?.interrupt.mock.calls.length === 2);
|
||||
expect(query()?.cancelAsyncMessage).not.toHaveBeenCalled();
|
||||
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("interrupt still stops the turn when Claude has already dequeued the steer", async () => {
|
||||
const { session, query, turn } = await startSteeredTurn("queued-steer-declined-session");
|
||||
query()?.cancelAsyncMessage.mockResolvedValue(false);
|
||||
|
||||
await session.interrupt();
|
||||
await waitFor(() => query()?.interrupt.mock.calls.length === 1);
|
||||
|
||||
expect(query()?.cancelAsyncMessage).toHaveBeenCalledWith(query()?.prompts[1]?.uuid);
|
||||
expect(await collectUntilTerminal(turn)).toContainEqual(
|
||||
expect.objectContaining({ type: "turn_canceled" }),
|
||||
);
|
||||
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("a steer Claude has already read is no longer discardable on interrupt", async () => {
|
||||
const { session, query, turn } = await startSteeredTurn("queued-steer-completed-session");
|
||||
|
||||
query()?.emit({
|
||||
type: "command_lifecycle",
|
||||
command_uuid: query()?.prompts[1]?.uuid,
|
||||
state: "completed",
|
||||
});
|
||||
// Frames are translated in order, so the marker landing proves the lifecycle frame was read.
|
||||
query()?.emit({ type: "assistant", message: { content: "STEER_READ" } });
|
||||
await consumeUntil(turn, (event) => collectAssistantText([event]).includes("STEER_READ"));
|
||||
|
||||
await session.interrupt();
|
||||
await waitFor(() => query()?.interrupt.mock.calls.length === 1);
|
||||
|
||||
expect(query()?.cancelAsyncMessage).not.toHaveBeenCalled();
|
||||
await collectUntilTerminal(turn);
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("reuses the existing query after interrupt before starting the next prompt", async () => {
|
||||
const logger = createTestLogger();
|
||||
const queries: ScriptedQuery[] = [];
|
||||
|
||||
@@ -2026,6 +2026,12 @@ class ClaudeAgentSession implements AgentSession {
|
||||
/** The exact SDK query/input pair that owns the current foreground turn. */
|
||||
private activeForegroundQuery: Query | null = null;
|
||||
private activeForegroundInput: AsyncMessageInput<SDKUserMessage> | null = null;
|
||||
/**
|
||||
* Steers pushed into the live SDK input that Claude may not have read yet. Interrupting the turn
|
||||
* has to discard them, or the SDK dequeues one and resumes the turn we just stopped. SDK user
|
||||
* UUIDs are provider-private; never let them escape the adapter boundary.
|
||||
*/
|
||||
private readonly queuedSteerUuids = new Set<string>();
|
||||
private claudeSessionId: string | null;
|
||||
private persistence: AgentPersistenceHandle | null;
|
||||
private currentMode: PermissionMode;
|
||||
@@ -2289,6 +2295,9 @@ class ClaudeAgentSession implements AgentSession {
|
||||
) {
|
||||
return { status: "unavailable" };
|
||||
}
|
||||
if (message.uuid) {
|
||||
this.queuedSteerUuids.add(message.uuid);
|
||||
}
|
||||
input.push(message);
|
||||
return { status: "accepted" };
|
||||
}
|
||||
@@ -3877,6 +3886,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
return;
|
||||
}
|
||||
this.pendingInterruptAbort = true;
|
||||
await this.discardQueuedSteers(queryToInterrupt);
|
||||
try {
|
||||
await this.awaitWithTimeout(
|
||||
queryToInterrupt.interrupt(),
|
||||
@@ -3887,6 +3897,31 @@ class ClaudeAgentSession implements AgentSession {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Interrupt means interrupt: a steer Claude never read dies with the turn instead of resuming it.
|
||||
* A steer already dequeued cannot be recalled, and does not need to be — the interrupt kills it.
|
||||
*/
|
||||
private async discardQueuedSteers(query: Query): Promise<void> {
|
||||
const uuids = [...this.queuedSteerUuids];
|
||||
this.queuedSteerUuids.clear();
|
||||
if (uuids.length === 0) return;
|
||||
// The SDK runtime supports this, but its public Query type has not caught up. Keep the
|
||||
// compatibility escape hatch inside the Claude adapter.
|
||||
const cancelAsyncMessage = (
|
||||
query as Query & {
|
||||
cancelAsyncMessage?: (uuid: string) => Promise<boolean>;
|
||||
}
|
||||
).cancelAsyncMessage;
|
||||
if (!cancelAsyncMessage) return;
|
||||
for (const uuid of uuids) {
|
||||
try {
|
||||
await cancelAsyncMessage.call(query, uuid);
|
||||
} catch (error) {
|
||||
this.logger.warn({ err: error }, "Failed to discard a queued Claude steer");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private translateMessageToEvents(
|
||||
message: SDKMessage,
|
||||
options?: {
|
||||
@@ -3933,6 +3968,8 @@ class ClaudeAgentSession implements AgentSession {
|
||||
}
|
||||
}
|
||||
|
||||
this.forgetReadSteer(message);
|
||||
|
||||
switch (message.type) {
|
||||
case "system":
|
||||
this.appendSystemMessageEvents(message, events);
|
||||
@@ -3965,6 +4002,13 @@ class ClaudeAgentSession implements AgentSession {
|
||||
return events;
|
||||
}
|
||||
|
||||
/** Once Claude has read a steer there is nothing left to discard on interrupt. */
|
||||
private forgetReadSteer(message: unknown): void {
|
||||
const lifecycle = readClaudeCommandLifecycle(message);
|
||||
if (!lifecycle || lifecycle.state === "queued") return;
|
||||
this.queuedSteerUuids.delete(lifecycle.commandUuid);
|
||||
}
|
||||
|
||||
private appendTaskStateEvent(message: SDKMessage, events: AgentStreamEvent[]): void {
|
||||
const item = this.taskState.observe(message);
|
||||
if (item) events.push({ type: "timeline", provider: "claude", item });
|
||||
@@ -6104,3 +6148,24 @@ function extractClaudeUserText(messageRaw: unknown): string | null {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
interface ClaudeCommandLifecycle {
|
||||
commandUuid: string;
|
||||
state: "queued" | "started" | "completed";
|
||||
}
|
||||
|
||||
/** Runtime-only Claude frames are validated here because the SDK's public union omits them. */
|
||||
function readClaudeCommandLifecycle(message: unknown): ClaudeCommandLifecycle | null {
|
||||
const record = toObjectRecord(message);
|
||||
if (record?.type !== "command_lifecycle") return null;
|
||||
if (
|
||||
typeof record.command_uuid !== "string" ||
|
||||
!["queued", "started", "completed"].includes(String(record.state))
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
commandUuid: record.command_uuid,
|
||||
state: record.state as ClaudeCommandLifecycle["state"],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -504,6 +504,77 @@ describe("daemon E2E (real claude) - send message during tool call", () => {
|
||||
}
|
||||
}, 210_000);
|
||||
|
||||
test("Stop cancels a queued Claude steer before it can resume the interrupted turn", async () => {
|
||||
const logger = pino({ level: "silent" });
|
||||
const cwd = tmpCwd();
|
||||
const daemon = await createTestPaseoDaemon({
|
||||
agentClients: { claude: new ClaudeAgentClient({ logger }) },
|
||||
logger,
|
||||
});
|
||||
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
|
||||
const collector = createMessageCollector(client);
|
||||
try {
|
||||
await within("connect queued-steer Stop client", 15_000, client.connect());
|
||||
await within(
|
||||
"subscribe queued-steer Stop client",
|
||||
15_000,
|
||||
client.fetchAgents({ subscribe: { subscriptionId: "queued-steer-stop" } }),
|
||||
);
|
||||
const agent = await within(
|
||||
"create queued-steer Stop agent",
|
||||
30_000,
|
||||
client.createAgent({
|
||||
cwd,
|
||||
title: "claude-queued-steer-stop",
|
||||
...getRealProviderConfig("claude"),
|
||||
}),
|
||||
);
|
||||
await within(
|
||||
"start original Claude sleep turn",
|
||||
30_000,
|
||||
client.sendAgentMessage(
|
||||
agent.id,
|
||||
"Use Bash to run exactly: sleep 5 in the foreground. Do not finish until instructed.",
|
||||
{ messageId: generateClientMessageId() },
|
||||
),
|
||||
);
|
||||
await within(
|
||||
"wait for original Claude tool boundary",
|
||||
90_000,
|
||||
waitForRunningClaudeSleep(client, collector, agent.id, 80_000),
|
||||
);
|
||||
const steerId = generateClientMessageId();
|
||||
await within(
|
||||
"admit queued Claude steer",
|
||||
30_000,
|
||||
client.sendAgentMessage(agent.id, "RESUME_FORBIDDEN", {
|
||||
messageId: steerId,
|
||||
activeTurnBehavior: "steer",
|
||||
}),
|
||||
);
|
||||
const messagesBeforeStop = collector.messages.length;
|
||||
await within("Stop original Claude turn", 30_000, client.cancelAgent(agent.id));
|
||||
await within("wait for stopped Claude turn", 60_000, client.waitForFinish(agent.id, 50_000));
|
||||
const afterStop = collector.messages.slice(messagesBeforeStop);
|
||||
expect(
|
||||
afterStop.filter(
|
||||
(message) =>
|
||||
message.type === "agent_stream" &&
|
||||
message.payload.agentId === agent.id &&
|
||||
message.payload.event.type === "turn_started",
|
||||
),
|
||||
).toHaveLength(0);
|
||||
// The steer's row stays in the transcript even though Claude never read it, matching Codex.
|
||||
expect(getAssistantTexts(afterStop, agent.id).join("\n")).not.toContain("RESUME_FORBIDDEN");
|
||||
} finally {
|
||||
const cleanup = await Promise.allSettled([client.close(), daemon.close()]);
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
expect(
|
||||
cleanup.flatMap((result) => (result.status === "rejected" ? [result.reason] : [])),
|
||||
).toEqual([]);
|
||||
}
|
||||
}, 180_000);
|
||||
|
||||
test("sending a message while a tool call is running replaces the turn without error, idle flash, or autonomous fallback", async () => {
|
||||
const logger = pino({ level: "silent" });
|
||||
const cwd = tmpCwd();
|
||||
|
||||
Reference in New Issue
Block a user