Compare commits

...

3 Commits

Author SHA1 Message Date
nikita-ashihmin
0c862e2949 fix: answer session/close without waiting for the interrupt reply (#1216)
## Summary

- `teardownSession` sends the interrupt but does not await its reply
- `query.close()` follows at once and ends the CLI process
- `session/cancel` still awaits the interrupt reply

On a fresh session, the background `getContextUsage` request from
`refreshContextWindowInBackground` is still in flight. The SDK
serializes control requests, so the CLI answered the interrupt only
after it. With a real CLI, `session/close` took 2.5 s in an empty
directory and 3.2 s in a large repository. ACP clients with a 3 s close
timeout timed out. With this change, close answers in about 2 ms, and
the CLI process exits.

## Tests

- `npm run test:run -- src/tests/create-session-options.test.ts`
- `npx tsc --noEmit`
- `npm run check`
2026-10-01 18:03:14 +04:00
nikita-ashihmin
4ffa625977 fix: restore background task stops on replay instead of showing them as prompts (#1205)
When a background task stops, the CLI persists a `<task-notification>`
user record. Live, the adapter hides it and handles the SDK task
lifecycle frame; replay previously showed the raw XML as a user prompt.

Replay now hides notification records and removes embedded notification
XML while preserving adjacent user text and delivered prompts with an
origin `subkind`. It restores known non-agent tasks through
`AsyncTaskRuntime`, including `killed` → `stopped` and duplicate
terminal-event suppression. Agent notifications override the launch
result's terminal state. Unknown tasks remain hidden without creating a
task card.

Task launches are recognized in `tool_use`, `server_tool_use`, and
`mcp_tool_use` blocks. Async task updates use the session that owns the
launching tool call, even when another session receives the
notification. History has no `task_started` frame, so tasks without a
persisted stop notification cannot be reconstructed.

The large Write patch test now uses a fixed clock to check patch
correctness independently of CI speed; a separate advancing-clock test
verifies the existing timeout fallback. The production 100 ms budget
remains unchanged.

Validation on current main (SDK 0.3.286):
- `npm run build`: passed.
- `npm run check`: passed.
- `env -u ANTHROPIC_BASE_URL npm run test:run`: 2119 passed, 29 skipped.
The local gateway environment otherwise changes the expected default URL
in four provider tests.
- Regression coverage includes origin/text notifications, adjacent user
text, unknown and repeated tasks, killed tasks, background Agents,
delivered subkind prompts, alternate tool block types, task session
ownership, and patch timeout fallback.
2026-10-01 17:07:49 +04:00
Mark Tkachenko
7f8d5471e2 fix: fail turns with unfinished tools (#1212)
Fixing silent agent turn completion in case of tools failure
2026-10-01 14:51:13 +02:00
5 changed files with 946 additions and 16 deletions

View File

@@ -105,12 +105,14 @@ import {
asSdkSessionNotification,
clientSupportsSubagents,
SubagentAwareSessionCapabilities,
type SubagentState,
} from "./acp-subagents.js";
import {
isNativeSubagentControlTool,
isNativeSubagentControlUpdate,
NativeSubagent,
NativeSubagentRuntime,
nativeSubagentState,
resumedNativeSubagentId,
sendMessageResumePrompt,
} from "./native-subagents.js";
@@ -590,6 +592,10 @@ type Turn = {
/** uuid stamped on the pushed `SDKUserMessage`; the SDK echoes it back so the
* consumer can match the replayed user message to this turn. */
promptUuid: string;
/** Tools surfaced during this turn, awaiting completion or an explicit
* background handoff. Intersect with emittedToolCalls at settlement so
* tool_result counts even when a presentation hook is still pending. */
foregroundToolCallIds?: Set<string>;
/** Local-only slash commands (e.g. `/clear`) return a result without an echo,
* so the consumer can't promote them via the replay; it falls back to
* promoting the queue head when the result arrives. */
@@ -1805,8 +1811,12 @@ const LOCAL_COMMAND_MARKERS = [
// whatever the user typed. Nobody wrote it and no client sees it live — the
// prompt loop's user-message skip covers the whole message — but it is
// persisted alongside that prose, so replay would hand the client a prompt
// with instructions in it the user never gave.
const INJECTED_CONTEXT_MARKERS = ["system-reminder"].map((tag) => ({
// with instructions in it the user never gave. A `task-notification` tells the
// model that a background task stopped; live, the SDK `task_notification`
// frame reports the same stop, so replay restores the task state from it (see
// taskNotificationsOf) instead of showing it.
const TASK_NOTIFICATION_TAG = "task-notification";
const INJECTED_CONTEXT_MARKERS = ["system-reminder", TASK_NOTIFICATION_TAG].map((tag) => ({
open: `<${tag}>`,
close: `</${tag}>`,
}));
@@ -1914,6 +1924,69 @@ export function isLocalCommandMetadata(content: unknown): boolean {
return stripLocalCommandMetadata(content) === null;
}
/** The fields of a persisted `<task-notification>`, in the shape of the SDK
* `task_notification` frame that reported the same stop live. */
type PersistedTaskNotification = {
task_id: string;
status: string;
tool_use_id?: string;
summary?: string;
output_file?: string;
};
/**
* Returns each `<task-notification>` in user-message content (a string or its
* text blocks). The `<result>` body is model context that the live frame does
* not carry, so the scan stops at it and a tag quoted in it cannot match.
*/
function taskNotificationsOf(content: unknown): PersistedTaskNotification[] {
const texts =
typeof content === "string"
? [content]
: Array.isArray(content)
? content.flatMap((block) =>
block?.type === "text" && typeof block.text === "string" ? [block.text as string] : [],
)
: [];
const open = `<${TASK_NOTIFICATION_TAG}>`;
const close = `</${TASK_NOTIFICATION_TAG}>`;
const notifications: PersistedTaskNotification[] = [];
for (const text of texts) {
let start = text.indexOf(open);
while (start !== -1) {
const end = text.indexOf(close, start + open.length);
if (end === -1) break;
const body = text.slice(start + open.length, end).split("<result>", 1)[0];
const field = (tag: string) => markerTagText(body, tag)?.trim() || undefined;
const taskId = field("task-id");
const status = field("status");
if (taskId && status) {
notifications.push({
task_id: taskId,
status,
tool_use_id: field("tool-use-id"),
summary: field("summary"),
output_file: field("output-file"),
});
}
start = text.indexOf(open, end + close.length);
}
}
return notifications;
}
/**
* True for a user record that the CLI wrote to tell the model that a
* background task stopped. A `subkind` marks the delivery of a prompt, for
* example a scheduled routine or a message from another session. Such a
* delivery stays in the transcript like any other prompt.
*/
function isTaskNotificationRecord(message: unknown): boolean {
const origin = (message as { origin?: unknown }).origin as
{ kind?: unknown; subkind?: unknown } | null | undefined;
return origin?.kind === "task-notification" && origin.subkind === undefined;
}
/**
* True for the synthetic assistant message the CLI injects into the transcript
* when a turn fails authentication (e.g. "Not logged in · Please run /login",
@@ -3681,6 +3754,26 @@ export class ClaudeAcpAgent {
}
return;
}
if (
toolCallId &&
(update.sessionUpdate === "tool_call" || update.sessionUpdate === "tool_call_update")
) {
if (
claudeMeta?.parentToolUseId ||
routedNotification.sessionId !== params.sessionId ||
update.status === "completed" ||
update.status === "failed"
) {
// A later stream frame can attribute an eager permission call to a
// child. It must no longer count as this turn's foreground work.
forgetForegroundToolCall(session, toolCallId);
} else if (
update.sessionUpdate === "tool_call" &&
session.emittedToolCalls.has(toolCallId)
) {
recordForegroundToolCall(session, toolCallId);
}
}
if (update.sessionUpdate === "agent_message_chunk") {
if (
!claudeMeta?.parentToolUseId &&
@@ -4095,6 +4188,7 @@ export class ClaudeAcpAgent {
isSubagent: boolean,
) => {
session.liveBackgroundTasks.set(taskId, { parentToolUseId, isSubagent });
if (parentToolUseId) forgetForegroundToolCall(session, parentToolUseId);
session.resumableSubagents?.delete(taskId);
if (isSubagent && session.activeTurn && !session.activeTurn.settled) {
(session.activeTurn.spawnedTaskIds ??= new Set()).add(taskId);
@@ -4186,6 +4280,62 @@ export class ClaudeAcpAgent {
if (!turn || turn.settled || turn.settling) {
return;
}
// Both a result and EOF can end the turn here. Preserve cancellation
// and existing errors instead of replacing them with this check.
if (result.stopReason === "end_turn" && reportReason === "notReported") {
// A confirmed background task is allowed to outlive the turn.
const backgroundTools = new Set(
[...session.liveBackgroundTasks.values()].map((task) => task.parentToolUseId),
);
// Check only this turn's tools. A tool_result removes its id from
// emittedToolCalls even when its PostToolUse hook has not arrived yet.
const unfinished = [...(turn.foregroundToolCallIds ?? [])].filter(
(id) => session.emittedToolCalls.has(id) && !backgroundTools.has(id),
);
if (unfinished.length > 0) {
const message = `Claude ended the turn without returning results for tool calls: ${unfinished.join(", ")}`;
this.logger.error(
`Session ${params.sessionId}, turn ${turn.promptUuid}, stopReason=${result.stopReason}: ${message}`,
);
// Fail every unfinished tool before reporting one error for the prompt.
for (const toolCallId of unfinished) {
// A late hook must not overwrite the failure we are about to send.
unregisterHookCallback(toolCallId);
session.emittedToolCalls.delete(toolCallId);
delete session.toolUseCache[toolCallId];
session.toolCallFields?.delete(toolCallId);
await sendUpdate({
sessionId: params.sessionId,
update: {
sessionUpdate: "tool_call_update",
toolCallId,
status: "failed",
content: [
{
type: "content",
content: {
type: "text",
text: "Claude ended the turn without returning a result for this tool.",
},
},
],
},
});
// Cancellation can arrive while we await the client update.
if (turn.settled || session.activeTurn !== turn) return;
if (session.cancelled) {
await settleActive({ ...result, stopReason: "cancelled" });
return;
}
}
await failActiveWithSessionFailure(
"internal_error",
RequestError.internalError(errorKindData("incomplete_tool_call"), message),
message,
);
return;
}
}
turn.settling = true;
turn.settlingOutcome = result;
if (reportReason === "notReported") {
@@ -6679,6 +6829,18 @@ export class ClaudeAcpAgent {
}
async cancel(params: CancelNotification): Promise<void> {
await this.cancelTurns(params, { awaitInterrupt: true });
}
/** Cancel the session's turns and interrupt the SDK query. With
* `awaitInterrupt: false` the interrupt is sent, but its reply is not
* awaited. `teardownSession` uses that: it closes the query right after, and
* the reply can queue behind a slow control request, such as the background
* `getContextUsage` of a fresh session. */
private async cancelTurns(
params: CancelNotification,
options: { awaitInterrupt: boolean },
): Promise<void> {
this.exitPlan.cancel(params.sessionId);
const session = this.sessions[params.sessionId];
if (!session) {
@@ -6913,7 +7075,14 @@ export class ClaudeAcpAgent {
}, this.forceCancelGraceMs);
}
const receipt = await session.query.interrupt();
const interrupt = session.query.interrupt();
if (!options.awaitInterrupt) {
// The caller closes the query next, which rejects the pending reply.
// The receipt only adjusts orphan accounting of a live session.
Promise.resolve(interrupt).catch(() => {});
return;
}
const receipt = await interrupt;
// On CLIs advertising `interrupt_receipt_v1`, the receipt's `still_queued`
// lists exactly which queued messages survive the interrupt and will still
// run. An orphaned turn whose uuid is absent was dropped by the interrupt
@@ -7019,7 +7188,7 @@ export class ClaudeAcpAgent {
return;
}
try {
await this.cancel({ sessionId });
await this.cancelTurns({ sessionId }, { awaitInterrupt: false });
} catch (error) {
this.logger.error(`Session ${sessionId}: cancellation failed during teardown`, error);
}
@@ -7213,6 +7382,9 @@ export class ClaudeAcpAgent {
reconstructable: boolean;
announced: boolean;
terminalState?: "completed" | "failed" | "cancelled";
/** The state of the last persisted task notification of the child.
* It comes after the launch tool_result, so it wins. */
notifiedState?: SubagentState;
}
>();
@@ -7333,6 +7505,55 @@ export class ClaudeAcpAgent {
? await subagentHistory(sessionId)
: { ids: new Map<string, string>() };
const replayedSubagents = new Set<string>();
// History persists no task_started frame, so a background task gets its
// spawn from the notification of its stop, with the tool call that
// started it. A task still running at the end of the history gets no
// card, because the process that ran it is gone.
// The tool cache forgets a tool use at its result, but a notification
// comes later and needs the tool use that started the task.
const replayToolUses = new Map<string, { name: string; input: unknown; sessionId: string }>();
const replayAsyncTasks = new AsyncTaskRuntime(
clientSupportsAsyncTasks(this.clientCapabilities),
sessionId,
async (notification) => this.client.sessionUpdate(asSdkSessionNotification(notification)),
{
routeOf: (toolCallId) => {
const target = replayToolUses.get(toolCallId)?.sessionId;
return target && target !== sessionId
? (notification) => ({ ...notification, sessionId: target })
: undefined;
},
},
);
// The replay counterpart of the live `task_notification` frame.
const restoreTaskNotification = async (
notification: PersistedTaskNotification,
): Promise<void> => {
const toolUseId = notification.tool_use_id;
if (!toolUseId) return;
const child = replayChildren.get(toolUseId);
const toolUse = replayToolUses.get(toolUseId);
if (child || (toolUse && isNativeSubagentControlTool(toolUse.name))) {
const state = nativeSubagentState(notification.status);
if (child && state) child.notifiedState = state;
return;
}
// Like live, a task that no tool call started stays unknown.
if (!toolUse) return;
const input = toolUse.input as { command?: unknown } | null | undefined;
await replayAsyncTasks.taskBackgrounded({
task_id: notification.task_id,
...(toolUse.name === "Bash"
? { task_type: "local_bash", description: input?.command }
: {}),
is_backgrounded: true,
output_file: notification.output_file,
tool_use_id: toolUseId,
});
await replayAsyncTasks.taskNotification(notification);
};
const replayMessage = async (message: SessionMessage): Promise<void> => {
if (isReplayHiddenMetaMessage(message)) {
return;
@@ -7402,8 +7623,25 @@ export class ClaudeAcpAgent {
}
// @ts-expect-error - untyped in SDK but we handle all of these
if (message.message.role === "user") {
content = stripLocalCommandMetadata(content);
for (const notification of taskNotificationsOf(content)) {
await restoreTaskNotification(notification);
}
// Live, the prompt loop skips this record and the SDK frame reports the stop.
content = isTaskNotificationRecord(message) ? null : stripLocalCommandMetadata(content);
if (content === null) return;
} else if (Array.isArray(content)) {
for (const block of content) {
if (
["tool_use", "server_tool_use", "mcp_tool_use"].includes(block?.type) &&
typeof block.id === "string"
) {
replayToolUses.set(block.id, {
name: String(block.name),
input: block.input,
sessionId: replayTargetSessionId,
});
}
}
}
// Claude persists the retained summary as a user message framed with
@@ -7518,7 +7756,7 @@ export class ClaudeAcpAgent {
sessionUpdate: "subagent_state_update",
subagentSessionId: child.sessionId,
state: child.reconstructable
? (child.terminalState ?? "disconnected")
? (child.notifiedState ?? child.terminalState ?? "disconnected")
: "disconnected",
},
}),
@@ -7643,6 +7881,15 @@ export class ClaudeAcpAgent {
return;
}
session.emittedToolCalls.add(toolCallId);
// SandboxNetworkAccess is a permission-only callback, not an executing
// tool: the SDK does not send a tool_result for its synthetic id.
if (
!parentToolUseId &&
notificationSessionId === sessionId &&
toolName !== "SandboxNetworkAccess"
) {
recordForegroundToolCall(session, toolCallId);
}
(session.eagerToolCallSessions ??= new Map()).set(toolCallId, notificationSessionId);
const update = new AcpToolCallRenderer(this.toolCallCapabilities).toolCall(
{ id: toolCallId, name: toolName, input: toolInput },
@@ -9495,7 +9742,7 @@ function totalTokens(usage: UsageSnapshot): number {
/** Error kinds this adapter invents itself, alongside the SDK's categorical
* `SDKAssistantMessageError` kinds: `no_result` marks a turn the SDK declared
* over without ever emitting its result (issue #825). */
type AgentErrorKind = SDKAssistantMessageError | "no_result";
type AgentErrorKind = SDKAssistantMessageError | "no_result" | "incomplete_tool_call";
/**
* Build the `data` payload attached to a `RequestError.internalError` when we
@@ -10031,6 +10278,17 @@ function shouldEmitToolCall(toolName: string): boolean {
return toolName !== "TodoWrite" && !isTaskTool(toolName);
}
/** Streamed and permission-surfaced tools can precede the SDK's user echo. */
function recordForegroundToolCall(session: Session, toolCallId: string): void {
const turn = session.activeTurn ?? session.turnQueue?.find((queued) => !queued.settled);
if (turn && !turn.settled) (turn.foregroundToolCallIds ??= new Set()).add(toolCallId);
}
function forgetForegroundToolCall(session: Session, toolCallId: string): void {
session.activeTurn?.foregroundToolCallIds?.delete(toolCallId);
for (const turn of session.turnQueue ?? []) turn.foregroundToolCallIds?.delete(toolCallId);
}
/** The tool-call field tracker of a session, created on first use. */
function toolCallFieldsOf(session: {
toolCallFields?: ToolCallFieldTracker;

View File

@@ -1949,6 +1949,17 @@ describe("stripLocalCommandMetadata", () => {
).toBeNull();
});
it("strips a persisted task notification, keeping what the user typed", () => {
expect(
stripLocalCommandMetadata(
"<task-notification>\n<task-id>bg1</task-id>\n</task-notification>\nwhat now?",
),
).toBe("\nwhat now?");
expect(
stripLocalCommandMetadata("<task-notification><task-id>bg1</task-id></task-notification>"),
).toBeNull();
});
it("drops reminder-only blocks from mixed arrays, keeping real blocks", () => {
expect(
stripLocalCommandMetadata([
@@ -2528,6 +2539,293 @@ describe("synthetic login message (issue #863)", () => {
});
});
describe("task notification replay", () => {
type History = Awaited<ReturnType<typeof getSessionMessages>>;
const record = (fields: Record<string, unknown>) =>
({
session_id: "s1",
parent_tool_use_id: null,
parent_agent_id: null,
...fields,
}) as unknown as History[number];
const notificationText = (status: string, toolUseId = "toolu_bash") =>
"<task-notification>\n<task-id>bg1</task-id>\n" +
`<tool-use-id>${toolUseId}</tool-use-id>\n` +
"<output-file>/tmp/tasks/bg1.output</output-file>\n" +
`<status>${status}</status>\n` +
'<summary>Background command "npm test" finished</summary>\n' +
"<note>A task-notification fires each time the task stops.</note>\n" +
"<result><status>failed</status></result>\n</task-notification>";
const notification = (status: string, fields: Record<string, unknown> = {}) =>
record({
type: "user",
uuid: `notification-${status}`,
message: { role: "user", content: notificationText(status) },
origin: { kind: "task-notification" },
...fields,
});
const bashLaunch = [
record({ type: "user", uuid: "u1", message: { role: "user", content: "run the tests" } }),
record({
type: "assistant",
uuid: "a1",
message: {
id: "m1",
role: "assistant",
model: "claude-opus-5",
content: [
{
type: "tool_use",
id: "toolu_bash",
name: "Bash",
input: { command: "npm test", run_in_background: true },
},
],
},
}),
record({
type: "user",
uuid: "u2",
message: {
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "toolu_bash",
content: "Command running in background with ID: bg1.",
},
],
},
}),
];
async function replay(
history: History,
capabilities: ClientCapabilities = {
_meta: { jetbrains: { air: { version: 1, capabilities: ["asyncTasks"] } } },
},
): Promise<AcpSessionNotification[]> {
const updates: AcpSessionNotification[] = [];
const agent = new ClaudeAcpAgent(
{
sessionUpdate: async (update: SessionNotification) =>
updates.push(update as AcpSessionNotification),
} as unknown as AcpClient,
{ log: () => {}, error: () => {} },
);
await initializeClient(agent, capabilities);
vi.mocked(getSessionMessages).mockResolvedValueOnce(history);
await (
agent as unknown as { replaySessionHistory(sessionId: string): Promise<void> }
).replaySessionHistory("s1");
return updates;
}
const userChunks = (updates: AcpSessionNotification[]) =>
updates.flatMap(({ update }) =>
update.sessionUpdate === "user_message_chunk" && update.content.type === "text"
? [update.content.text]
: [],
);
const asyncTaskUpdates = (updates: AcpSessionNotification[]) =>
updates.flatMap(({ update }) =>
update.sessionUpdate.startsWith("async_task_") ? [update] : [],
);
it("hides the notification and restores the task state like the live frame", async () => {
const updates = await replay([...bashLaunch, notification("completed")]);
expect(userChunks(updates)).toEqual(["run the tests"]);
expect(JSON.stringify(updates)).not.toContain("task-notification");
expect(asyncTaskUpdates(updates)).toEqual([
{
sessionUpdate: "async_task_spawned",
asyncTaskId: "bg1",
name: "npm test",
taskType: "shell",
description: "npm test",
showInTranscript: true,
canStop: true,
outputFilePath: "/tmp/tasks/bg1.output",
toolCallId: "toolu_bash",
},
{
sessionUpdate: "async_task_state_update",
asyncTaskId: "bg1",
state: "completed",
summary: 'Background command "npm test" finished',
},
]);
});
it("detects a notification without an origin by its text and maps killed to stopped", async () => {
const updates = await replay([...bashLaunch, notification("killed", { origin: undefined })]);
expect(userChunks(updates)).toEqual(["run the tests"]);
expect(asyncTaskUpdates(updates).at(-1)).toMatchObject({
sessionUpdate: "async_task_state_update",
asyncTaskId: "bg1",
state: "stopped",
});
});
it.each(["server_tool_use", "mcp_tool_use"])(
"restores a background task launched by a %s block",
async (type) => {
const launch = record({
...bashLaunch[1],
message: {
role: "assistant",
content: [{ type, id: "toolu_bash", name: "Bash", input: { command: "npm test" } }],
},
});
const updates = await replay([
bashLaunch[0],
launch,
bashLaunch[2],
notification("completed"),
]);
expect(asyncTaskUpdates(updates).map((update) => update.sessionUpdate)).toEqual([
"async_task_spawned",
"async_task_state_update",
]);
expect(asyncTaskUpdates(updates).at(-1)).toMatchObject({ state: "completed" });
},
);
it("routes a task to the session of its launching tool even when the root receives the stop", async () => {
const updates = await replay(
[
...bashLaunch.map((message, index) =>
index === 0 ? message : record({ ...message, parent_tool_use_id: "toolu_agent" }),
),
notification("completed"),
],
{
subagents: {},
_meta: { jetbrains: { air: { version: 1, capabilities: ["asyncTasks"] } } },
} as ClientCapabilities,
);
const tasks = updates.filter(({ update }) => update.sessionUpdate.startsWith("async_task_"));
expect(tasks).toHaveLength(2);
expect(tasks.map(({ sessionId }) => sessionId)).toEqual([
"s1:replay-subagent:toolu_agent",
"s1:replay-subagent:toolu_agent",
]);
});
it("keeps the user text next to an embedded notification", async () => {
const updates = await replay([
...bashLaunch,
record({
type: "user",
uuid: "u3",
message: {
role: "user",
content: [
{ type: "text", text: notificationText("failed") },
{ type: "text", text: "why did it fail?" },
],
},
}),
]);
expect(userChunks(updates)).toEqual(["run the tests", "why did it fail?"]);
expect(JSON.stringify(updates)).not.toContain("task-notification");
expect(asyncTaskUpdates(updates).at(-1)).toMatchObject({ state: "failed" });
});
it("sends nothing for a task that no tool call in the history started", async () => {
const updates = await replay([
record({ type: "user", uuid: "u1", message: { role: "user", content: "hi" } }),
notification("completed"),
]);
expect(userChunks(updates)).toEqual(["hi"]);
expect(asyncTaskUpdates(updates)).toEqual([]);
});
it("sends one terminal state for a repeated notification", async () => {
const updates = await replay([
...bashLaunch,
notification("completed"),
notification("completed", { uuid: "notification-again" }),
]);
expect(asyncTaskUpdates(updates).map((update) => update.sessionUpdate)).toEqual([
"async_task_spawned",
"async_task_state_update",
]);
});
it("restores the notified state of a background Agent after its launch result", async () => {
const updates = await replay(
[
record({
type: "assistant",
uuid: "a1",
message: {
id: "m1",
role: "assistant",
model: "claude-opus-5",
content: [
{
type: "tool_use",
id: "toolu_agent",
name: "Agent",
input: { description: "Explore", prompt: "Look around", run_in_background: true },
},
],
},
}),
record({
type: "user",
uuid: "u2",
message: {
role: "user",
content: [
{ type: "tool_result", tool_use_id: "toolu_agent", content: "Async agent launched." },
],
},
}),
record({
type: "user",
uuid: "u3",
message: { role: "user", content: notificationText("killed", "toolu_agent") },
origin: { kind: "task-notification" },
}),
],
{
subagents: {},
_meta: { jetbrains: { air: { version: 1, capabilities: ["asyncTasks"] } } },
} as ClientCapabilities,
);
expect(userChunks(updates)).toEqual([]);
expect(asyncTaskUpdates(updates)).toEqual([]);
expect(updates.at(-1)?.update).toMatchObject({
sessionUpdate: "subagent_state_update",
subagentSessionId: "s1:replay-subagent:toolu_agent",
state: "cancelled",
});
});
it("keeps a task-notification delivery with a subkind as a prompt", async () => {
const updates = await replay([
record({
type: "user",
uuid: "u1",
message: { role: "user", content: "Run the nightly report" },
origin: { kind: "task-notification", subkind: "scheduled-trigger" },
}),
]);
expect(userChunks(updates)).toEqual(["Run the nightly report"]);
});
});
describe("meta messages from other agents, sessions and channels", () => {
// The shape SDK 0.3.284 getSessionMessages returns for a host-injected peer
// message (live probe): is_meta plus origin, content is the full framing.

View File

@@ -18,6 +18,8 @@ let sessionMessages: Record<string, unknown>[];
let sessionMessagesResult: () => Promise<Record<string, unknown>[]>;
let initModels: Record<string, unknown>[] | undefined;
let setModelImpl: ((model: string) => Promise<void>) | undefined;
let interruptImpl: (() => Promise<undefined>) | undefined;
let closeSpy: ReturnType<typeof vi.fn<() => void>>;
let mcpServerStatusResult: () => Promise<Array<{ name: string; status: string }>>;
let mcpAuthenticateImpl: (serverName: string) => Promise<{
authUrl?: string;
@@ -34,8 +36,8 @@ vi.mock("@anthropic-ai/claude-agent-sdk", async () => {
query: (args: { prompt: unknown; options: Options }) => {
capturedOptions = args.options;
return makeMockQuery({
interrupt: async () => undefined,
close: () => {},
interrupt: () => (interruptImpl ? interruptImpl() : Promise.resolve(undefined)),
close: () => closeSpy(),
initializationResult: async () => ({
models: initModels ?? [
{
@@ -86,6 +88,8 @@ describe("createSession options merging", () => {
vi.mocked(getSessionMessages).mockClear();
initModels = undefined;
setModelImpl = undefined;
interruptImpl = undefined;
closeSpy = vi.fn<() => void>();
mcpServerStatusResult = async () => [];
mcpAuthenticateImpl = async () => ({
requiresUserAction: false,
@@ -847,6 +851,36 @@ describe("createSession options merging", () => {
});
});
describe("session/close right after session/new", () => {
it("answers at once and closes the query", async () => {
const { sessionId } = await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
await agent.closeSession({ sessionId });
expect(closeSpy).toHaveBeenCalledOnce();
expect((agent as unknown as { sessions: Record<string, unknown> }).sessions[sessionId]).toBe(
undefined,
);
});
it("does not wait for an interrupt queued behind the background getContextUsage", async () => {
// SDK control requests are serialized. On a fresh session the background
// getContextUsage is still in flight, so a real CLI answers an interrupt
// only after it, which took ~3s and timed out the client's close.
contextUsageResult = () => new Promise<never>(() => {});
interruptImpl = () => new Promise<never>(() => {});
const { sessionId } = await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
const closed = await Promise.race([
agent.closeSession({ sessionId }).then(() => "closed"),
new Promise((resolve) => setTimeout(() => resolve("timed out"), 500)),
]);
expect(closed).toBe("closed");
expect(closeSpy).toHaveBeenCalledOnce();
});
});
describe("context window seeding", () => {
function sessionFor(sessionId: string) {
return (agent as unknown as { sessions: Record<string, any> }).sessions[sessionId];

View File

@@ -1,7 +1,7 @@
import { execFileSync } from "node:child_process";
import { ClientCapabilities } from "../tool-calls/client-capabilities.js";
import { applyPatch } from "diff";
import { afterEach, describe, expect, it } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
@@ -260,13 +260,32 @@ describe("approval patch previews", () => {
.join("");
const filePath = await temporaryFile(oldText);
// 200 changed lines. A diff that runs out of its time budget gives no
// patch, and patchText then throws.
const patch = patchText(
await previewPatchContent("Write", { file_path: filePath, content: newText }),
);
// Verify the 200-line patch independently of the runner's speed. The
// timeout fallback is exercised separately with an advancing clock.
const clock = vi.spyOn(Date, "now").mockReturnValue(0);
try {
const patch = patchText(
await previewPatchContent("Write", { file_path: filePath, content: newText }),
);
expect(applyPatch(oldText, patch)).toBe(newText);
} finally {
clock.mockRestore();
}
});
expect(applyPatch(oldText, patch)).toBe(newText);
it("falls back to the standard Write diff when the patch budget expires", async () => {
const oldText = "before\n";
const newText = "after\n";
const filePath = await temporaryFile(oldText);
let now = 0;
const clock = vi.spyOn(Date, "now").mockImplementation(() => (now += 1000));
try {
expect(await previewPatchContent("Write", { file_path: filePath, content: newText })).toEqual(
[{ type: "diff", path: filePath, oldText, newText }],
);
} finally {
clock.mockRestore();
}
});
it("builds patches that turn the old text into the new text", async () => {

View File

@@ -0,0 +1,321 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { ClaudeAcpAgent, type AcpClient } from "../acp-agent.js";
import { clearHookCallbacks, createPostToolUseHook, hasHookCallback } from "../tools.js";
import { Pushable } from "../utils.js";
import {
mockSessionState,
successfulResultMessage,
userEcho,
wrapQuery,
} from "./session-doubles.js";
const sessionId = "test-session";
const toolCallId = "unfinished-bash";
afterEach(() => clearHookCallbacks(sessionId));
describe("incomplete foreground tools", () => {
it.each(["result", "EOF"] as const)(
"fails the tool and prompt at %s even after the input stream closes",
async (ending) => {
const { prompt, updates, logError } = createTestSession((input) =>
unfinishedToolMessages(input, ending),
);
await expect(prompt()).rejects.toMatchObject({ data: { errorKind: "incomplete_tool_call" } });
expect(updates).toContainEqual(
expect.objectContaining({
sessionUpdate: "tool_call_update",
toolCallId,
status: "failed",
}),
);
expect(logError).toHaveBeenCalledExactlyOnceWith(
expect.stringMatching(
/Session test-session, turn .+, stopReason=end_turn:.*unfinished-bash/,
),
);
const failed = updates.find((u) => u.toolCallId === toolCallId && u.status === "failed");
expect(failed.content[0].content.text).toContain("without returning a result");
expect(failed).not.toHaveProperty("rawOutput");
expect(hasHookCallback(toolCallId)).toBe(false);
},
);
it("detects a tool emitted before the user echo", async () => {
const { prompt } = createTestSession(toolBeforeUserEchoMessages);
await expect(prompt()).rejects.toMatchObject({ data: { errorKind: "incomplete_tool_call" } });
});
it("detects a permission tool whose streamed tool_use never arrived", async () => {
const { prompt, updates } = createTestSession(permissionOnlyToolMessages);
await expect(prompt()).rejects.toMatchObject({ data: { errorKind: "incomplete_tool_call" } });
expect(updates).toContainEqual(expect.objectContaining({ toolCallId, status: "failed" }));
});
it("allows a completed tool with a late PostToolUse hook", async () => {
const { prompt, logError } = createTestSession(completedToolMessages);
await expect(prompt()).resolves.toMatchObject({ stopReason: "end_turn" });
expect(hasHookCallback(toolCallId)).toBe(true);
await createPostToolUseHook()(
{
hook_event_name: "PostToolUse",
tool_name: "Bash",
tool_input: { command: "echo test" },
tool_response: { stdout: "test", stderr: "", interrupted: false },
} as any,
toolCallId,
{ signal: new AbortController().signal },
);
expect(logError).not.toHaveBeenCalled();
});
it("allows a tool explicitly handed off to a background task", async () => {
const { prompt, updates } = createTestSession(backgroundToolMessages);
await expect(prompt()).resolves.toMatchObject({ stopReason: "end_turn" });
expect(updates.some((u) => u.toolCallId === toolCallId && u.status === "failed")).toBe(false);
});
it("does not claim a subagent's tool as a foreground tool", async () => {
const { prompt } = createTestSession(subagentToolMessages);
await expect(prompt()).resolves.toMatchObject({ stopReason: "end_turn" });
});
it("does not treat a request to run in the background as a confirmed handoff", async () => {
const { prompt } = createTestSession(unconfirmedBackgroundToolMessages);
await expect(prompt()).rejects.toMatchObject({ data: { errorKind: "incomplete_tool_call" } });
});
it("waits for deferred settlement before checking unfinished tools", async () => {
const { prompt, logError } = createTestSession(deferredSettlementMessages);
await expect(prompt()).resolves.toMatchObject({ stopReason: "end_turn" });
expect(logError).not.toHaveBeenCalled();
});
it("fails every unfinished tool and allows a later prompt to succeed", async () => {
const { prompt, updates, agent } = createTestSession(incompleteThenSuccessfulTurnMessages);
await expect(prompt()).rejects.toMatchObject({ data: { errorKind: "incomplete_tool_call" } });
expect(updates.filter((u) => u.status === "failed").map((u) => u.toolCallId)).toEqual([
toolCallId,
"second-tool",
]);
expect(agent.sessions[sessionId].emittedToolCalls.size).toBe(0);
expect(agent.sessions[sessionId].toolUseCache).toEqual({});
await expect(prompt()).resolves.toMatchObject({ stopReason: "end_turn" });
});
it("preserves cancellation and does not attribute its unfinished tool to the next turn", async () => {
const { prompt, updates } = createTestSession(cancelledThenSuccessfulTurnMessages);
await expect(prompt()).resolves.toMatchObject({ stopReason: "cancelled" });
await expect(prompt()).resolves.toMatchObject({ stopReason: "end_turn" });
expect(updates.some((u) => u.toolCallId === toolCallId && u.status === "failed")).toBe(false);
});
it("preserves an existing SDK failure", async () => {
const { prompt } = createTestSession(sdkFailureMessages);
await expect(prompt()).rejects.toThrow("original failure");
});
});
function toolStart(id = toolCallId, parent: string | null = null) {
return {
type: "stream_event",
session_id: sessionId,
uuid: "stream-message",
parent_tool_use_id: parent,
event: {
type: "content_block_start",
index: 0,
content_block: { type: "tool_use", id, name: "Bash", input: {} },
},
};
}
function toolResult(id = toolCallId) {
return {
type: "user",
session_id: sessionId,
parent_tool_use_id: null,
message: {
role: "user",
content: [{ type: "tool_result", tool_use_id: id, content: "done" }],
},
};
}
function createTestSession(
createSdkMessages: (input: Pushable<any>, agent: ClaudeAcpAgent) => AsyncGenerator<any>,
) {
const updates: any[] = [];
const logError = vi.fn();
const input = new Pushable<any>();
const agent = new ClaudeAcpAgent(
{
sessionUpdate: async (notification: any) => {
updates.push(notification.update);
},
requestPermission: async () => ({ outcome: { outcome: "selected", optionId: "allow-once" } }),
} as unknown as AcpClient,
{ log: () => {}, error: logError },
);
agent.sessions[sessionId] = mockSessionState({
input,
// The real consumer reads the scripted SDK events from this generator.
query: wrapQuery(createSdkMessages(input, agent)),
});
const prompt = () => agent.prompt({ sessionId, prompt: [{ type: "text", text: "continue" }] });
return { agent, updates, logError, prompt };
}
// Wait for prompt() to submit input, then echo it as the SDK would.
async function* echoNextPrompt(input: Pushable<any>) {
const { value } = await input[Symbol.asyncIterator]().next();
yield userEcho(value);
}
async function* unfinishedToolMessages(input: Pushable<any>, ending: "result" | "EOF") {
yield* echoNextPrompt(input);
yield toolStart();
yield {
...toolStart(),
event: { type: "content_block_stop", index: 0 },
};
// The input block closed, but no tool_result arrived. Returning ends the stream.
if (ending === "result") yield successfulResultMessage();
}
async function* toolBeforeUserEchoMessages(input: Pushable<any>) {
const { value } = await input[Symbol.asyncIterator]().next();
yield toolStart();
yield userEcho(value);
yield successfulResultMessage();
}
async function* permissionOnlyToolMessages(input: Pushable<any>, agent: ClaudeAcpAgent) {
yield* echoNextPrompt(input);
await agent.canUseTool(sessionId)("Bash", { command: "echo test" }, {
toolUseID: toolCallId,
signal: new AbortController().signal,
suggestions: [],
} as any);
yield successfulResultMessage();
}
async function* completedToolMessages(input: Pushable<any>) {
yield* echoNextPrompt(input);
yield toolStart();
yield toolResult();
yield successfulResultMessage();
}
async function* backgroundToolMessages(input: Pushable<any>) {
yield* echoNextPrompt(input);
yield toolStart();
yield {
type: "system",
subtype: "task_started",
session_id: sessionId,
task_id: "background-shell",
tool_use_id: toolCallId,
description: "dev server",
};
yield successfulResultMessage();
}
async function* subagentToolMessages(input: Pushable<any>) {
yield* echoNextPrompt(input);
yield toolStart("child-tool", "parent-agent");
yield successfulResultMessage();
}
async function* unconfirmedBackgroundToolMessages(input: Pushable<any>) {
yield* echoNextPrompt(input);
yield {
type: "assistant",
parent_tool_use_id: null,
message: {
role: "assistant",
usage: successfulResultMessage().usage,
content: [
{
type: "tool_use",
id: toolCallId,
name: "Bash",
input: { command: "sleep 100", run_in_background: true },
},
],
},
};
yield successfulResultMessage();
}
async function* deferredSettlementMessages(input: Pushable<any>) {
yield* echoNextPrompt(input);
yield toolStart();
yield {
type: "system",
subtype: "task_started",
session_id: sessionId,
task_id: "child",
tool_use_id: "parent-agent",
subagent_type: "Explore",
description: "investigate",
};
// The result arrives while the subagent is still running, so settlement waits.
yield successfulResultMessage();
yield toolResult();
yield {
type: "system",
subtype: "task_notification",
session_id: sessionId,
task_id: "child",
tool_use_id: "parent-agent",
status: "completed",
summary: "done",
};
yield { type: "system", subtype: "session_state_changed", state: "idle" };
}
async function* incompleteThenSuccessfulTurnMessages(input: Pushable<any>) {
const messages = input[Symbol.asyncIterator]();
yield userEcho((await messages.next()).value);
yield toolStart();
yield toolStart("second-tool");
yield successfulResultMessage();
yield { type: "system", subtype: "session_state_changed", state: "idle" };
// Keep the SDK stream open for the next prompt in the same session.
yield userEcho((await messages.next()).value);
yield successfulResultMessage();
}
async function* cancelledThenSuccessfulTurnMessages(input: Pushable<any>, agent: ClaudeAcpAgent) {
const messages = input[Symbol.asyncIterator]();
yield userEcho((await messages.next()).value);
yield toolStart();
await agent.cancel({ sessionId });
yield { type: "system", subtype: "session_state_changed", state: "idle" };
yield userEcho((await messages.next()).value);
yield successfulResultMessage();
}
async function* sdkFailureMessages(input: Pushable<any>) {
yield* echoNextPrompt(input);
yield toolStart();
yield successfulResultMessage({
subtype: "error_during_execution",
is_error: true,
errors: ["original failure"],
});
}