mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-10-27 12:40:32 +00:00
Compare commits
3 Commits
v0.85.0
...
v0.85.1-pr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c862e2949 | ||
|
|
4ffa625977 | ||
|
|
7f8d5471e2 |
272
src/acp-agent.ts
272
src/acp-agent.ts
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
321
src/tests/incomplete-tools.test.ts
Normal file
321
src/tests/incomplete-tools.test.ts
Normal 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"],
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user