mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-11-09 19:02:05 +00:00
fix: render workflow tool cards and progress correctly in ACP v2 (#1550)
## Summary Fix workflow tool cards that remain pending indefinitely, and surface workflow progress through standard ACP v2 updates. ### Prevent orphan preview cards Claude Code invokes Edit/Write preview hooks for workflow agents without streaming those tools' uses and results through the root query. Publishing a card from the hook alone leaves a pending call with no completion path. Stage early preview patches until the stream or a permission request publishes the actual tool call. Keep patches pinned against later input refinements, bound unclaimed previews, and clear them on teardown. A call's own content takes precedence over a staged preview. ### Show workflow progress in place Forward the SDK's typed task-progress fields to the existing workflow card: current activity, summary, last activity, token count, tool-call count, and elapsed time. Preserve progress arriving before the launch result, suppress duplicate display updates, and include the latest progress on session resume. Progress does not change paused/running state or reopen completed calls. This reads no workflow transcripts, journals, or metadata files and introduces no ACP extension. The CLI also emits an untyped `workflow_progress` snapshot; consuming that richer phase/agent data is deliberately left out of this change. Existing v1 AIR progress uses the same field normalization as before. ## Validation - Regression reproduced before the orphan-preview fix. - Nine relevant Vitest suites: **1,972 passed, 10 skipped**, including v1/v2 scenarios, preview ordering, progress-before-launch, idle and paused updates, duplicate suppression, resume snapshots, and late progress after completion. - `npm run check` - `npm run build` - `git diff --check` - Live Claude Code **2.1.319** probes: captured SDK workflow events and ran a real workflow through the updated ACP v2 adapter. One card received two progress updates and then reached `completed`. This verified protocol updates, not the Delta UI. Rebased onto current `main` and reran the automated checks before opening the PR.
This commit is contained in:
@@ -163,6 +163,7 @@ import {
|
||||
backgroundBashTaskFromToolResult,
|
||||
backgroundedBashToolCallIds,
|
||||
clientSupportsAsyncTasks,
|
||||
taskProgressFields,
|
||||
} from "./async-tasks.js";
|
||||
import {
|
||||
type BackgroundSink,
|
||||
@@ -6755,13 +6756,18 @@ export class ClaudeAcpAgent {
|
||||
case "hook_response":
|
||||
case "files_persisted":
|
||||
break;
|
||||
case "task_progress":
|
||||
case "task_progress": {
|
||||
if (isLateTaskRun(session, message.task_id, message.run_id)) {
|
||||
this.logger.log(
|
||||
`Ignoring task_progress for task ${message.task_id} from earlier run ${message.run_id}`,
|
||||
);
|
||||
break;
|
||||
}
|
||||
const progress = session.background.taskProgress(
|
||||
message.task_id,
|
||||
taskProgressFields(message),
|
||||
);
|
||||
if (progress) await sendUpdate(progress);
|
||||
await asyncTasks.taskProgress({
|
||||
task_id: message.task_id,
|
||||
description: message.description,
|
||||
@@ -6771,6 +6777,7 @@ export class ClaudeAcpAgent {
|
||||
tool_use_id: message.tool_use_id,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "task_started":
|
||||
if (isLateTaskRun(session, message.task_id, message.run_id)) {
|
||||
this.logger.log(
|
||||
@@ -8634,6 +8641,9 @@ export class ClaudeAcpAgent {
|
||||
),
|
||||
);
|
||||
}
|
||||
for (const progress of session.background.toolCallProgress(runningToolCalls)) {
|
||||
await sendUpdate(progress);
|
||||
}
|
||||
// After the result's own updates: the command's output replaces
|
||||
// the placeholder that they gave its terminal.
|
||||
if (runningTerminalTask) {
|
||||
@@ -10780,15 +10790,13 @@ export class ClaudeAcpAgent {
|
||||
}
|
||||
|
||||
/**
|
||||
* The PreToolUse hook of a v2 connection, for Edit and Write: the tool call
|
||||
* shows the exact patch of the change before the tool runs.
|
||||
* Captures pre-execution patches for v2 Edit and Write tool calls.
|
||||
*
|
||||
* A v2 diff cannot hold the input's snippet, so without it an auto-approved
|
||||
* Edit or Write would show its change only once it is done. Claude Code runs
|
||||
* the tool only after the hook returns, so the file is as it was before the
|
||||
* change, as for the approval patch of a permission request (which runs
|
||||
* after this hook, and shows the same patch). The hook makes no permission
|
||||
* decision, and a failure only leaves the preview out.
|
||||
* A v2 diff cannot hold the input's snippet, so the patch needs the file as
|
||||
* it was before the tool runs. Claude Code waits for the hook before running
|
||||
* the tool. If its streamed tool use has not arrived, the patch waits for
|
||||
* the tool call's first publication. The hook makes no permission decision,
|
||||
* and a failure only leaves the preview out.
|
||||
*/
|
||||
private fileChangePreviewHook(sessionId: string): HookCallback {
|
||||
return async (input, toolUseID, { signal }) => {
|
||||
@@ -10815,34 +10823,25 @@ export class ClaudeAcpAgent {
|
||||
const toolInput = (input.tool_input ?? {}) as Record<string, unknown>;
|
||||
const content = await previewPatchContent(input.tool_name, toolInput, session.cwd, "v2");
|
||||
if (!content) return;
|
||||
const fields = toolCallFieldsOf(session);
|
||||
// Workflow tools can run hooks without streaming their use or result.
|
||||
// Keep the patch until the stream or a permission request publishes the
|
||||
// tool call, instead of creating a pending call with no completion path.
|
||||
if (!session.emittedToolCalls.has(toolUseID)) {
|
||||
fields.stagePreview(toolUseID, content);
|
||||
return;
|
||||
}
|
||||
const { parentToolUseId, toolCallSessionId } = this.toolUseOwner(
|
||||
session,
|
||||
sessionId,
|
||||
input.agent_id,
|
||||
);
|
||||
// Hook callbacks bypass the stream, so the hook can come before the
|
||||
// streamed tool_use, as `canUseTool` can: then the tool call goes out
|
||||
// with the patch.
|
||||
if (!session.emittedToolCalls.has(toolUseID)) {
|
||||
await this.ensureToolCallEmitted(
|
||||
sessionId,
|
||||
input.tool_name,
|
||||
toolUseID,
|
||||
toolInput,
|
||||
parentToolUseId,
|
||||
undefined,
|
||||
toolCallSessionId,
|
||||
content,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const update: SessionNotification["update"] = {
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId: toolUseID,
|
||||
content,
|
||||
};
|
||||
if (parentToolUseId) stampParentToolUseId(update, parentToolUseId);
|
||||
const fields = toolCallFieldsOf(session);
|
||||
if (!fields.apply(update)) return;
|
||||
// The streamed input must not replace the patch, as for an approval patch.
|
||||
fields.pinContent(toolUseID, content);
|
||||
@@ -12319,9 +12318,9 @@ export class ClaudeAcpAgent {
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
// On v2, an Edit or Write shows its exact patch before it runs, also
|
||||
// when no permission request shows it (`fileChangePreviewHook`): a v2
|
||||
// diff cannot hold the input's snippet.
|
||||
// v2 needs the patch captured before an Edit or Write runs, also
|
||||
// without a permission request (`fileChangePreviewHook`): a v2 diff
|
||||
// cannot hold the input's snippet.
|
||||
...(this.v2 || subagentConfigHook
|
||||
? {
|
||||
PreToolUse: [
|
||||
|
||||
@@ -105,6 +105,28 @@ type TaskProgress = TaskIdentity & {
|
||||
output_file?: unknown;
|
||||
};
|
||||
|
||||
/** The display fields of a task progress report, shared by AIR and v2 cards. */
|
||||
export type TaskProgressFields = {
|
||||
description?: string;
|
||||
summary?: string;
|
||||
lastToolName?: string;
|
||||
usage?: { totalTokens: number; toolUses: number; durationMs: number };
|
||||
};
|
||||
|
||||
/** Reads the display fields supplied by a task progress report. */
|
||||
export function taskProgressFields(message: TaskProgress): TaskProgressFields {
|
||||
const usage = taskUsage(message.usage);
|
||||
const description = nonBlankString(message.description);
|
||||
const summary = nonBlankString(message.summary);
|
||||
const lastToolName = nonBlankString(field(message, "lastToolName", "last_tool_name"));
|
||||
return {
|
||||
...(description ? { description } : {}),
|
||||
...(summary ? { summary } : {}),
|
||||
...(lastToolName ? { lastToolName } : {}),
|
||||
...(usage ? { usage } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
type BackgroundTaskLevel = TaskIdentity & {
|
||||
taskType?: unknown;
|
||||
task_type?: unknown;
|
||||
@@ -267,15 +289,8 @@ export class AsyncTaskRuntime {
|
||||
return;
|
||||
}
|
||||
|
||||
const usage = taskUsage(message.usage);
|
||||
const description = nonBlankString(message.description);
|
||||
const summary = nonBlankString(message.summary);
|
||||
const lastToolName = nonBlankString(field(message, "lastToolName", "last_tool_name"));
|
||||
const progress = {
|
||||
...(description ? { description } : {}),
|
||||
...(summary ? { summary } : {}),
|
||||
...(lastToolName ? { lastToolName } : {}),
|
||||
...(usage ? { usage } : {}),
|
||||
...taskProgressFields(message),
|
||||
...(outputChanged && task.outputFilePath ? { outputFilePath: task.outputFilePath } : {}),
|
||||
...(task.toolCallId !== previousToolCallId && task.toolCallId
|
||||
? { toolCallId: task.toolCallId }
|
||||
|
||||
@@ -27,11 +27,13 @@
|
||||
* (`Session.reportsBackground`): `session/cancel` must leave listed work
|
||||
* running.
|
||||
*/
|
||||
import { RequestError } from "@agentclientprotocol/sdk/unstable";
|
||||
import { RequestError, type ToolCallUpdate } from "@agentclientprotocol/sdk/unstable";
|
||||
import type { AcpSessionNotification } from "./acp-subagents.js";
|
||||
import type { NativeSubagent } from "./native-subagents.js";
|
||||
import { type AcpToolCallRenderer, V2_CANCELLED, V2_PAUSED } from "./tool-calls/renderer.js";
|
||||
import { type BackgroundOutput, BackgroundOutputTail } from "./background-output.js";
|
||||
import type { TaskProgressFields } from "./async-tasks.js";
|
||||
import { textContent } from "./tool-calls/content.js";
|
||||
|
||||
/** An item of a `background` list. */
|
||||
export type BackgroundItem =
|
||||
@@ -68,7 +70,13 @@ export type BackgroundTaskEnd = "completed" | "failed" | "stopped" | undefined;
|
||||
/** The live background task set of `background_tasks_changed`, by task id. */
|
||||
export type BackgroundLevel = Map<
|
||||
string,
|
||||
{ ambient: boolean; taskType: string; description: string; parentTaskId?: string }
|
||||
{
|
||||
ambient: boolean;
|
||||
taskType: string;
|
||||
description: string;
|
||||
parentTaskId?: string;
|
||||
progress?: TaskProgressFields;
|
||||
}
|
||||
>;
|
||||
|
||||
/** The work state of a session, as its `state_update` names it. */
|
||||
@@ -264,11 +272,9 @@ export class SessionBackgroundWork {
|
||||
if (toolCall) {
|
||||
this.toolCalls.delete(taskId);
|
||||
await send(
|
||||
toolCallStatusUpdate(
|
||||
this.sessionId,
|
||||
toolCall,
|
||||
end === "stopped" ? V2_CANCELLED : end === "failed" ? "failed" : "completed",
|
||||
),
|
||||
toolCallUpdate(this.sessionId, toolCall, {
|
||||
status: end === "stopped" ? V2_CANCELLED : end === "failed" ? "failed" : "completed",
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -282,7 +288,33 @@ export class SessionBackgroundWork {
|
||||
const toolCall = this.toolCalls.get(taskId);
|
||||
if (!toolCall || (toolCall.paused ?? false) === paused) return undefined;
|
||||
toolCall.paused = paused;
|
||||
return toolCallStatusUpdate(this.sessionId, toolCall, paused ? V2_PAUSED : "in_progress");
|
||||
return toolCallUpdate(this.sessionId, toolCall, { status: paused ? V2_PAUSED : "in_progress" });
|
||||
}
|
||||
|
||||
/** Updates an existing workflow card without changing its work state. */
|
||||
taskProgress(taskId: string, progress: TaskProgressFields): AcpSessionNotification | undefined {
|
||||
const task = this.level.get(taskId);
|
||||
if (!task || task.taskType !== "local_workflow") return undefined;
|
||||
const previous = workflowProgress(task.progress);
|
||||
task.progress = { ...task.progress, ...progress };
|
||||
const current = workflowProgress(task.progress);
|
||||
if (!current || JSON.stringify(previous) === JSON.stringify(current)) return undefined;
|
||||
const toolCall = this.toolCalls.get(taskId);
|
||||
return toolCall ? toolCallUpdate(this.sessionId, toolCall, current) : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The latest progress of the workflow calls in `toolCallIds`.
|
||||
*
|
||||
* A task can report progress before its launch tool returns. Those fields
|
||||
* wait in the live task level and are sent after the launch result.
|
||||
*/
|
||||
toolCallProgress(toolCallIds: ReadonlySet<string>): AcpSessionNotification[] {
|
||||
return [...this.toolCalls].flatMap(([taskId, toolCall]) => {
|
||||
if (!toolCallIds.has(toolCall.toolCallId)) return [];
|
||||
const progress = workflowProgress(this.level.get(taskId)?.progress);
|
||||
return progress ? [toolCallUpdate(this.sessionId, toolCall, progress)] : [];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -303,6 +335,7 @@ export class SessionBackgroundWork {
|
||||
taskType: task.task_type,
|
||||
description: task.description,
|
||||
...(task.parent_task_id !== undefined ? { parentTaskId: task.parent_task_id } : {}),
|
||||
progress: this.level.get(task.task_id)?.progress,
|
||||
},
|
||||
]),
|
||||
);
|
||||
@@ -331,7 +364,7 @@ export class SessionBackgroundWork {
|
||||
for (const [taskId, toolCall] of this.toolCalls) {
|
||||
if (toolCall.parentToolUseId) continue;
|
||||
this.toolCalls.delete(taskId);
|
||||
updates.push(toolCallStatusUpdate(this.sessionId, toolCall, V2_CANCELLED));
|
||||
updates.push(toolCallUpdate(this.sessionId, toolCall, { status: V2_CANCELLED }));
|
||||
}
|
||||
this.level = new Map();
|
||||
this.returned.clear();
|
||||
@@ -434,14 +467,18 @@ export class SessionBackgroundWork {
|
||||
renderer: AcpToolCallRenderer,
|
||||
cwd: string,
|
||||
): AcpSessionNotification[] {
|
||||
return [...this.toolCalls.values()]
|
||||
.filter((toolCall) => toolCallIds.includes(toolCall.toolCallId))
|
||||
.map((toolCall) => {
|
||||
return [...this.toolCalls]
|
||||
.filter(([, toolCall]) => toolCallIds.includes(toolCall.toolCallId))
|
||||
.map(([taskId, toolCall]) => {
|
||||
const update = renderer.toolCall({ id: toolCall.toolCallId, ...toolCall.toolUse }, { cwd });
|
||||
const status = toolCall.paused ? V2_PAUSED : "in_progress";
|
||||
return {
|
||||
sessionId: this.sessionId,
|
||||
update: { ...update, status } as AcpSessionNotification["update"],
|
||||
update: {
|
||||
...update,
|
||||
...workflowProgress(this.level.get(taskId)?.progress),
|
||||
status,
|
||||
} as AcpSessionNotification["update"],
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -553,19 +590,17 @@ function terminalExit(sessionId: string, terminal: BackgroundTerminal): AcpSessi
|
||||
};
|
||||
}
|
||||
|
||||
function toolCallStatusUpdate(
|
||||
function toolCallUpdate(
|
||||
sessionId: string,
|
||||
toolCall: BackgroundToolCall,
|
||||
status: NonNullable<
|
||||
Extract<AcpSessionNotification["update"], { sessionUpdate: "tool_call_update" }>["status"]
|
||||
>,
|
||||
fields: Omit<ToolCallUpdate, "toolCallId">,
|
||||
): AcpSessionNotification {
|
||||
return {
|
||||
sessionId,
|
||||
update: {
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId: toolCall.toolCallId,
|
||||
status,
|
||||
...fields,
|
||||
...(toolCall.parentToolUseId
|
||||
? { _meta: { claudeCode: { parentToolUseId: toolCall.parentToolUseId } } }
|
||||
: {}),
|
||||
@@ -573,6 +608,26 @@ function toolCallStatusUpdate(
|
||||
};
|
||||
}
|
||||
|
||||
/** The latest workflow activity, rendered as one replaceable tool-card body. */
|
||||
function workflowProgress(progress: TaskProgressFields | undefined) {
|
||||
if (!progress || Object.keys(progress).length === 0) return undefined;
|
||||
const activity = progress.description ?? progress.summary;
|
||||
const usage = progress.usage;
|
||||
const text = [
|
||||
progress.summary ?? progress.description,
|
||||
progress.lastToolName ? `Last activity: ${progress.lastToolName}` : undefined,
|
||||
usage
|
||||
? `${usage.totalTokens.toLocaleString("en-US")} tokens · ${usage.toolUses.toLocaleString("en-US")} tool calls · ${Math.floor(usage.durationMs / 1000)}s`
|
||||
: undefined,
|
||||
]
|
||||
.filter((part) => part !== undefined)
|
||||
.join("\n\n");
|
||||
return {
|
||||
title: activity ? `Workflow: ${activity}` : "Workflow",
|
||||
content: [textContent(text)],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Publishes the lists of each session that reports them, each time one
|
||||
* changes. The sink sends them to the client synchronously, so a list follows
|
||||
|
||||
@@ -2000,6 +2000,34 @@ describe("ACP v2 prompts", () => {
|
||||
expect(atClose).toEqual(["user_message", "running", "agent_message_chunk", "idle cancelled"]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["Edit", "workflow-agent"],
|
||||
["Write", undefined],
|
||||
] as const)("does not publish a preview for a hook-only %s", async (name, agentId) => {
|
||||
const file = path.join(cwd, "app.ts");
|
||||
await writeFile(file, "const value = 1;\n");
|
||||
const input =
|
||||
name === "Edit"
|
||||
? { file_path: file, old_string: "const value = 1;", new_string: "const value = 2;" }
|
||||
: { file_path: file, content: "const value = 2;\n" };
|
||||
scriptTurns(async function* (options) {
|
||||
// Workflow agents run these hooks without streaming their tool uses
|
||||
// or results to the root query.
|
||||
await previewToolUse(options, { id: "workflow-edit", name, input, agentId });
|
||||
await writeFile(file, "const value = 2;\n");
|
||||
yield result(options);
|
||||
});
|
||||
const client = v2Client();
|
||||
await client.app.connectWith(connectRouter(), async (agent) => {
|
||||
await initializeV2(agent);
|
||||
const { sessionId } = await agent.request(v2.methods.agent.session.new, { cwd });
|
||||
await agent.request(v2.methods.agent.session.prompt, { sessionId, prompt: text("hi") });
|
||||
await vi.waitFor(() => expect(turnTrace(client.sessionUpdates)).toContain("idle end_turn"));
|
||||
});
|
||||
|
||||
expect(client.updates("tool_call_update")).toEqual([]);
|
||||
});
|
||||
|
||||
// Hook callbacks bypass the SDK's message queue, so the hook can also come
|
||||
// before the agent has seen the streamed tool_use.
|
||||
it.each([
|
||||
@@ -2034,27 +2062,19 @@ describe("ACP v2 prompts", () => {
|
||||
};
|
||||
// Claude Code runs the PreToolUse hooks and waits for them; no
|
||||
// permission request comes for an auto-approved Edit.
|
||||
const preToolUse = async () => {
|
||||
for (const matcher of options.hooks?.PreToolUse ?? []) {
|
||||
for (const hook of matcher.hooks) {
|
||||
await hook(
|
||||
{
|
||||
hook_event_name: "PreToolUse",
|
||||
session_id: options.sessionId!,
|
||||
cwd,
|
||||
tool_name: "Edit",
|
||||
tool_input: input,
|
||||
tool_use_id: "toolu_edit",
|
||||
} as never,
|
||||
"toolu_edit",
|
||||
{ signal: new AbortController().signal },
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
const preToolUse = () => previewToolUse(options, { id: "toolu_edit", name: "Edit", input });
|
||||
if (hookFirst) await preToolUse();
|
||||
yield toolUse;
|
||||
yield stream(options, {
|
||||
type: "message_start",
|
||||
message: { id: "msg_edit", role: "assistant", content: [], usage: {} },
|
||||
});
|
||||
yield stream(options, {
|
||||
type: "content_block_start",
|
||||
index: 0,
|
||||
content_block: { type: "tool_use", id: "toolu_edit", name: "Edit", input: {} },
|
||||
});
|
||||
if (!hookFirst) await preToolUse();
|
||||
yield toolUse;
|
||||
// The Edit runs.
|
||||
await writeFile(file, "line 1\nconst value = 2;\nline 3\n");
|
||||
yield {
|
||||
@@ -4018,22 +4038,7 @@ describe("ACP v2 model cycles that Claude Code starts on its own", () => {
|
||||
session_id: options.sessionId,
|
||||
};
|
||||
// Claude Code runs the PreToolUse hooks, then asks for the permission.
|
||||
for (const matcher of options.hooks?.PreToolUse ?? []) {
|
||||
for (const hook of matcher.hooks) {
|
||||
await hook(
|
||||
{
|
||||
hook_event_name: "PreToolUse",
|
||||
session_id: options.sessionId!,
|
||||
cwd,
|
||||
tool_name: "Write",
|
||||
tool_input: input,
|
||||
tool_use_id: "toolu_late",
|
||||
} as never,
|
||||
"toolu_late",
|
||||
{ signal: new AbortController().signal },
|
||||
);
|
||||
}
|
||||
}
|
||||
await previewToolUse(options, { id: "toolu_late", name: "Write", input });
|
||||
decision = await options.canUseTool!("Write", input, {
|
||||
signal: new AbortController().signal,
|
||||
suggestions: [],
|
||||
@@ -6049,6 +6054,120 @@ describe("ACP v2 terminal of a command in the background", () => {
|
||||
).toEqual(["pending", "in_progress"]);
|
||||
});
|
||||
|
||||
it("updates a workflow card from SDK progress before launch and while idle, and keeps it on resume", async () => {
|
||||
const finish = Promise.withResolvers<void>();
|
||||
const progress = (options: Options, fields: Record<string, unknown> = {}) =>
|
||||
taskEvent(options, "task_progress", {
|
||||
task_id: "wf1",
|
||||
tool_use_id: "toolu_wf",
|
||||
description: "Probe: probe",
|
||||
summary: "Run a probe",
|
||||
last_tool_name: "probe",
|
||||
usage: { total_tokens: 0, tool_uses: 0, duration_ms: 29 },
|
||||
...fields,
|
||||
});
|
||||
scriptTurns(async function* (options) {
|
||||
yield progress(options, { task_id: "unknown" });
|
||||
yield toolUseMessage(options, "toolu_wf", "Workflow", { script: "probe" });
|
||||
yield level(options, [workflow]);
|
||||
yield taskEvent(options, "task_started", { ...workflow, tool_use_id: "toolu_wf" });
|
||||
// Observed on CLI 2.1.319: workflow progress can precede the launch
|
||||
// result. last_tool_name is the active agent's label, not a tool.
|
||||
yield progress(options);
|
||||
yield {
|
||||
...toolResultMessage(options, "toolu_wf", { content: "Workflow launched." }),
|
||||
tool_use_result: {
|
||||
status: "async_launched",
|
||||
taskId: "wf1",
|
||||
taskType: "local_workflow",
|
||||
},
|
||||
};
|
||||
yield result(options);
|
||||
yield idleState(options);
|
||||
yield taskEvent(options, "task_updated", { task_id: "wf1", patch: { status: "paused" } });
|
||||
yield progress(options, {
|
||||
summary: undefined,
|
||||
last_tool_name: undefined,
|
||||
usage: { total_tokens: 4003, tool_uses: 2, duration_ms: 1494 },
|
||||
});
|
||||
yield level(options, [workflow]);
|
||||
yield progress(options, {
|
||||
usage: { total_tokens: 4003, tool_uses: 2, duration_ms: 1494 },
|
||||
});
|
||||
await finish.promise;
|
||||
yield taskEvent(options, "task_updated", {
|
||||
task_id: "wf1",
|
||||
patch: { status: "completed" },
|
||||
});
|
||||
yield progress(options, { description: "Late progress" });
|
||||
yield level(options, []);
|
||||
});
|
||||
const body = (tokens: string, tools: number, seconds: number) => [
|
||||
{
|
||||
type: "content",
|
||||
content: {
|
||||
type: "text",
|
||||
text: `Run a probe\n\nLast activity: probe\n\n${tokens} tokens · ${tools} tool calls · ${seconds}s`,
|
||||
},
|
||||
},
|
||||
];
|
||||
let resumed: v2.SessionUpdate[] = [];
|
||||
let live: v2.SessionUpdate[] = [];
|
||||
const client = await promptUntil(
|
||||
"idle end_turn [toolu_wf]",
|
||||
async (agent, sessionId, client) => {
|
||||
await vi.waitFor(() =>
|
||||
expect(client.updates("tool_call_update")).toContainEqual(
|
||||
expect.objectContaining({ content: body("4,003", 2, 1) }),
|
||||
),
|
||||
);
|
||||
live = client.updates("tool_call_update");
|
||||
const before = client.sessionUpdates.length;
|
||||
await agent.request(v2.methods.agent.session.resume, { sessionId, cwd });
|
||||
resumed = client.sessionUpdates.slice(before).map(({ update }) => update);
|
||||
finish.resolve();
|
||||
await vi.waitFor(() => expect(backgroundTrace(client)).toContain("idle []"));
|
||||
},
|
||||
);
|
||||
|
||||
expect(
|
||||
live.filter(
|
||||
(update) =>
|
||||
update.sessionUpdate === "tool_call_update" &&
|
||||
update.title === "Workflow: Probe: probe",
|
||||
),
|
||||
).toEqual([
|
||||
{
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId: "toolu_wf",
|
||||
title: "Workflow: Probe: probe",
|
||||
content: body("0", 0, 0),
|
||||
},
|
||||
{
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId: "toolu_wf",
|
||||
title: "Workflow: Probe: probe",
|
||||
content: body("4,003", 2, 1),
|
||||
},
|
||||
]);
|
||||
expect(resumed[0]).toMatchObject({
|
||||
toolCallId: "toolu_wf",
|
||||
title: "Workflow: Probe: probe",
|
||||
content: body("4,003", 2, 1),
|
||||
status: "paused",
|
||||
});
|
||||
expect(
|
||||
client
|
||||
.updates("tool_call_update")
|
||||
.flatMap((update) => (update.status ? [update.status] : [])),
|
||||
).toEqual(["pending", "in_progress", "paused", "paused", "completed"]);
|
||||
const calls = client.updates("tool_call_update");
|
||||
const completed = calls.findIndex((update) => update.status === "completed");
|
||||
expect(
|
||||
calls.slice(completed + 1).filter((update) => update.title || update.content),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("sends each listed call's current state before the state snapshot of session/resume", async () => {
|
||||
scriptTurns(async function* (options) {
|
||||
yield* monitorStarts(options);
|
||||
@@ -6888,3 +7007,27 @@ describe("ACP v2 auth", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
async function previewToolUse(
|
||||
options: Options,
|
||||
tool: { id: string; name: string; input: unknown; agentId?: string },
|
||||
): Promise<void> {
|
||||
for (const matcher of options.hooks?.PreToolUse ?? []) {
|
||||
for (const hook of matcher.hooks) {
|
||||
await hook(
|
||||
{
|
||||
hook_event_name: "PreToolUse",
|
||||
session_id: options.sessionId!,
|
||||
transcript_path: path.join(cwd, "session.jsonl"),
|
||||
cwd,
|
||||
tool_name: tool.name,
|
||||
tool_input: tool.input,
|
||||
tool_use_id: tool.id,
|
||||
...(tool.agentId ? { agent_id: tool.agentId } : {}),
|
||||
},
|
||||
tool.id,
|
||||
{ signal: new AbortController().signal },
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,6 +151,36 @@ describe("ToolCallFieldTracker", () => {
|
||||
expect(final.content).toEqual(snippet);
|
||||
});
|
||||
|
||||
it("prefers a call's own content to an earlier staged preview", () => {
|
||||
const tracker = new ToolCallFieldTracker();
|
||||
tracker.stagePreview("t", [{ type: "diff", path: "/a.ts", oldText: "a", newText: "b" }]);
|
||||
const content = [{ type: "diff" as const, path: "/a.ts", oldText: "a", newText: "c" }];
|
||||
const update = { sessionUpdate: "tool_call" as const, toolCallId: "t", title: "Edit", content };
|
||||
tracker.apply(update);
|
||||
|
||||
expect(update.content).toEqual(content);
|
||||
});
|
||||
|
||||
it("bounds unclaimed previews and releases them when a call or session is forgotten", () => {
|
||||
const tracker = new ToolCallFieldTracker();
|
||||
const patch = [{ type: "diff" as const, path: "/a.ts", oldText: "a", newText: "b" }];
|
||||
for (let index = 0; index < 65; index++) tracker.stagePreview(`${index}`, patch);
|
||||
const start = (id: string) => {
|
||||
const update = { sessionUpdate: "tool_call" as const, toolCallId: id, title: "Edit" };
|
||||
tracker.apply(update);
|
||||
return update;
|
||||
};
|
||||
|
||||
expect(start("0")).not.toHaveProperty("content");
|
||||
expect(start("1")).toHaveProperty("content", patch);
|
||||
// A preview belongs only to the initial publication, not a later restart.
|
||||
expect(start("1")).not.toHaveProperty("content");
|
||||
tracker.delete("2");
|
||||
expect(start("2")).not.toHaveProperty("content");
|
||||
tracker.clear();
|
||||
expect(start("3")).not.toHaveProperty("content");
|
||||
});
|
||||
|
||||
it("ends an entry after both the tool_result and the hook", () => {
|
||||
const tracker = new ToolCallFieldTracker();
|
||||
tracker.apply({ sessionUpdate: "tool_call", toolCallId: "a", title: "A" });
|
||||
|
||||
@@ -19,6 +19,9 @@ const REPLACED_FIELDS = [
|
||||
|
||||
type ReplacedField = (typeof REPLACED_FIELDS)[number];
|
||||
|
||||
/** Hooks also report workflow tools that never appear in the SDK stream. */
|
||||
const MAX_STAGED_PREVIEWS = 64;
|
||||
|
||||
interface SentToolCall {
|
||||
/** The JSON of each replaced field that the client holds now. */
|
||||
fields: Map<ReplacedField, string>;
|
||||
@@ -46,10 +49,12 @@ interface SentToolCall {
|
||||
*/
|
||||
export class ToolCallFieldTracker {
|
||||
private readonly calls = new Map<string, SentToolCall>();
|
||||
private readonly previews = new Map<string, ToolCallContent[]>();
|
||||
|
||||
/**
|
||||
* Records a `tool_call`, or removes the unchanged fields of a
|
||||
* `tool_call_update` and records the rest.
|
||||
* `tool_call_update` and records the rest. A new call without content takes
|
||||
* its staged preview, pinned until a final result replaces it.
|
||||
*
|
||||
* Returns false when the update carries nothing new: no replaced field
|
||||
* remains, `_meta` has no key besides `claudeCode` and `jetbrains`, and
|
||||
@@ -62,10 +67,13 @@ export class ToolCallFieldTracker {
|
||||
*/
|
||||
apply(update: SessionUpdate, options?: { replacePinnedContent?: boolean }): boolean {
|
||||
if (update.sessionUpdate === "tool_call") {
|
||||
const preview = update.content?.length ? undefined : this.previews.get(update.toolCallId);
|
||||
this.previews.delete(update.toolCallId);
|
||||
if (preview) update.content = preview;
|
||||
const entry: SentToolCall = {
|
||||
fields: new Map(),
|
||||
mergedMeta: new Map(),
|
||||
contentPinned: false,
|
||||
contentPinned: preview !== undefined,
|
||||
resultSeen: false,
|
||||
};
|
||||
this.calls.set(update.toolCallId, entry);
|
||||
@@ -106,6 +114,21 @@ export class ToolCallFieldTracker {
|
||||
return changed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps a pre-execution patch until the tool call is first published.
|
||||
*
|
||||
* A hook can beat the streamed tool use. Workflow tools can also run hooks
|
||||
* without ever streaming their use or result, so staging a preview must not
|
||||
* create a client-visible call. At most 64 previews wait; the oldest is
|
||||
* discarded when the limit is exceeded.
|
||||
*/
|
||||
stagePreview(toolCallId: string, content: ToolCallContent[]): void {
|
||||
this.previews.set(toolCallId, content);
|
||||
if (this.previews.size > MAX_STAGED_PREVIEWS) {
|
||||
this.previews.delete(this.previews.keys().next().value!);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Records the exact approval patch that the client shows for a tool call.
|
||||
*
|
||||
@@ -142,11 +165,13 @@ export class ToolCallFieldTracker {
|
||||
/** Forgets one tool call. */
|
||||
delete(toolCallId: string): void {
|
||||
this.calls.delete(toolCallId);
|
||||
this.previews.delete(toolCallId);
|
||||
}
|
||||
|
||||
/** Forgets every tool call, when the session is torn down. */
|
||||
clear(): void {
|
||||
this.calls.clear();
|
||||
this.previews.clear();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user