mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-11-11 03:41:17 +00:00
feat: list workflow phases and agents on the workflow card (#1552)
## Summary Show the phases and agents of a running workflow on its ACP v2 tool card, not only the current activity and totals. Claude Code attaches a `workflow_progress` snapshot to some of a workflow's `task_progress` events: every phase, and every agent with its label, phase, state, tokens, tool calls, duration and last tool. #1550 deliberately left it out. This change renders it under the existing card body: ``` **Count** - two: done · 14,629 tokens · 0 tool calls · 1s - three: running · Bash: echo 3 **Sum** - not started ``` ### Why an untyped field The SDK (0.3.319) does not type `workflow_progress`. Workflow agents also don't stream their messages through the root query, so this snapshot is the only per-agent data the adapter gets. `src/workflow-progress.ts` reads the shape the CLI sends and skips entries that don't match it. If the CLI changes the format, the card loses the list but still renders. The right long-term fix is for the SDK to type this field, or to stream workflow agents with a parent id so each one can become a child session. ### Behavior - The snapshot is a full list, and the CLI leaves it out of some reports. A report without one keeps the last list. - A running agent shows its last tool (e.g. `Bash: echo 3`). A finished one shows tokens, tool calls and duration. - Agents with no known phase are listed first. A phase with no agent yet shows `not started`. A retry shows its attempt. A state other than `start`/`done` is shown as the CLI sends it. - The existing duplicate suppression and the `session/resume` snapshot cover the list too. - AIR `async_task_progress` is unchanged. Like the rest of the card progress from #1550, this only reaches v2 clients that get workflow progress on the card. Result previews, models and prompt previews are left out to keep the card short. ## Validation - Live probe on Claude Code 2.1.319 (SDK `query()`, two-phase three-agent workflow, permissions limited to `Workflow` and one `echo 3`). It captured the snapshot shape used in the test doubles. Every captured snapshot rendered through the built module as shown above. This checked the protocol output, not the Delta UI. - New v2 scenario built from the captured payload, plus unit cases in `src/tests/workflow-progress.test.ts`. - `npm run check`, `npm run build`, `npm run test:run` (3,691 passed, 30 skipped).
This commit is contained in:
@@ -165,6 +165,7 @@ import {
|
||||
clientSupportsAsyncTasks,
|
||||
taskProgressFields,
|
||||
} from "./async-tasks.js";
|
||||
import { workflowSnapshot } from "./workflow-progress.js";
|
||||
import {
|
||||
type BackgroundSink,
|
||||
type BackgroundTaskEnd,
|
||||
@@ -6763,10 +6764,14 @@ export class ClaudeAcpAgent {
|
||||
);
|
||||
break;
|
||||
}
|
||||
const progress = session.background.taskProgress(
|
||||
message.task_id,
|
||||
taskProgressFields(message),
|
||||
// The SDK does not type `workflow_progress`; see workflow-progress.ts.
|
||||
const workflow = workflowSnapshot(
|
||||
(message as { workflow_progress?: unknown }).workflow_progress,
|
||||
);
|
||||
const progress = session.background.taskProgress(message.task_id, {
|
||||
...taskProgressFields(message),
|
||||
...(workflow ? { workflow } : {}),
|
||||
});
|
||||
if (progress) await sendUpdate(progress);
|
||||
await asyncTasks.taskProgress({
|
||||
task_id: message.task_id,
|
||||
|
||||
@@ -33,6 +33,7 @@ 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 { type WorkflowSnapshot, workflowSnapshotText } from "./workflow-progress.js";
|
||||
import { textContent } from "./tool-calls/content.js";
|
||||
|
||||
/** An item of a `background` list. */
|
||||
@@ -75,10 +76,13 @@ export type BackgroundLevel = Map<
|
||||
taskType: string;
|
||||
description: string;
|
||||
parentTaskId?: string;
|
||||
progress?: TaskProgressFields;
|
||||
progress?: WorkflowCardProgress;
|
||||
}
|
||||
>;
|
||||
|
||||
/** What a workflow card shows: the task's progress fields and its latest phase and agent list. */
|
||||
export type WorkflowCardProgress = TaskProgressFields & { workflow?: WorkflowSnapshot };
|
||||
|
||||
/** The work state of a session, as its `state_update` names it. */
|
||||
export type WorkStateName = "idle" | "running" | "requires_action" | "unknown";
|
||||
|
||||
@@ -291,8 +295,11 @@ export class SessionBackgroundWork {
|
||||
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 {
|
||||
/**
|
||||
* Updates an existing workflow card without changing its work state. A
|
||||
* report without `workflow` keeps the last phase and agent list.
|
||||
*/
|
||||
taskProgress(taskId: string, progress: WorkflowCardProgress): AcpSessionNotification | undefined {
|
||||
const task = this.level.get(taskId);
|
||||
if (!task || task.taskType !== "local_workflow") return undefined;
|
||||
const previous = workflowProgress(task.progress);
|
||||
@@ -609,7 +616,7 @@ function toolCallUpdate(
|
||||
}
|
||||
|
||||
/** The latest workflow activity, rendered as one replaceable tool-card body. */
|
||||
function workflowProgress(progress: TaskProgressFields | undefined) {
|
||||
function workflowProgress(progress: WorkflowCardProgress | undefined) {
|
||||
if (!progress || Object.keys(progress).length === 0) return undefined;
|
||||
const activity = progress.description ?? progress.summary;
|
||||
const usage = progress.usage;
|
||||
@@ -619,6 +626,7 @@ function workflowProgress(progress: TaskProgressFields | undefined) {
|
||||
usage
|
||||
? `${usage.totalTokens.toLocaleString("en-US")} tokens · ${usage.toolUses.toLocaleString("en-US")} tool calls · ${Math.floor(usage.durationMs / 1000)}s`
|
||||
: undefined,
|
||||
progress.workflow ? workflowSnapshotText(progress.workflow) : undefined,
|
||||
]
|
||||
.filter((part) => part !== undefined)
|
||||
.join("\n\n");
|
||||
|
||||
@@ -6168,6 +6168,96 @@ describe("ACP v2 terminal of a command in the background", () => {
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("lists a workflow's phases and agents on its card, and keeps them when a report has none", async () => {
|
||||
// As CLI 2.1.319 sends it, trimmed of the fields the card ignores.
|
||||
const snapshot = [
|
||||
{ type: "workflow_phase", index: 1, title: "Count" },
|
||||
{ type: "workflow_phase", index: 2, title: "Sum" },
|
||||
{
|
||||
type: "workflow_agent",
|
||||
index: 1,
|
||||
label: "two",
|
||||
phaseIndex: 1,
|
||||
phaseTitle: "Count",
|
||||
agentId: "af3724ea06eadb5dd",
|
||||
model: "claude-haiku-5-5",
|
||||
state: "done",
|
||||
attempt: 1,
|
||||
promptPreview: "Reply with only the number 2.",
|
||||
tokens: 14629,
|
||||
toolCalls: 0,
|
||||
durationMs: 1329,
|
||||
resultPreview: "2",
|
||||
},
|
||||
{
|
||||
type: "workflow_agent",
|
||||
index: 2,
|
||||
label: "three",
|
||||
phaseIndex: 1,
|
||||
phaseTitle: "Count",
|
||||
model: "claude-haiku-5-5",
|
||||
state: "start",
|
||||
lastToolName: "Bash",
|
||||
lastToolSummary: "echo 3",
|
||||
},
|
||||
];
|
||||
const progress = (options: Options, fields: Record<string, unknown>) =>
|
||||
taskEvent(options, "task_progress", {
|
||||
task_id: "wf1",
|
||||
tool_use_id: "toolu_wf",
|
||||
description: "Count: three",
|
||||
...fields,
|
||||
});
|
||||
scriptTurns(async function* (options) {
|
||||
yield toolUseMessage(options, "toolu_wf", "Workflow", { script: "probe" });
|
||||
yield level(options, [workflow]);
|
||||
yield taskEvent(options, "task_started", { ...workflow, tool_use_id: "toolu_wf" });
|
||||
yield {
|
||||
...toolResultMessage(options, "toolu_wf", { content: "Workflow launched." }),
|
||||
tool_use_result: {
|
||||
status: "async_launched",
|
||||
taskId: "wf1",
|
||||
taskType: "local_workflow",
|
||||
},
|
||||
};
|
||||
yield progress(options, {
|
||||
usage: { total_tokens: 14629, tool_uses: 0, duration_ms: 1343 },
|
||||
workflow_progress: snapshot,
|
||||
});
|
||||
// The CLI leaves the snapshot out of some reports.
|
||||
yield progress(options, {
|
||||
usage: { total_tokens: 29282, tool_uses: 1, duration_ms: 2806 },
|
||||
});
|
||||
yield result(options);
|
||||
yield idleState(options);
|
||||
});
|
||||
const list = [
|
||||
"**Count**",
|
||||
"- two: done · 14,629 tokens · 0 tool calls · 1s",
|
||||
"- three: running · Bash: echo 3",
|
||||
"",
|
||||
"**Sum**",
|
||||
"- not started",
|
||||
].join("\n");
|
||||
const client = await promptUntil("idle end_turn [toolu_wf]");
|
||||
|
||||
expect(
|
||||
client
|
||||
.updates("tool_call_update")
|
||||
.flatMap((update) => (update.content ? [update.content] : []))
|
||||
.slice(-2),
|
||||
).toEqual(
|
||||
["14,629 tokens · 0 tool calls · 1s", "29,282 tokens · 1 tool calls · 2s"].map(
|
||||
(usage) => [
|
||||
{
|
||||
type: "content",
|
||||
content: { type: "text", text: `Count: three\n\n${usage}\n\n${list}` },
|
||||
},
|
||||
],
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("sends each listed call's current state before the state snapshot of session/resume", async () => {
|
||||
scriptTurns(async function* (options) {
|
||||
yield* monitorStarts(options);
|
||||
|
||||
64
src/tests/workflow-progress.test.ts
Normal file
64
src/tests/workflow-progress.test.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { workflowSnapshot, workflowSnapshotText } from "../workflow-progress.js";
|
||||
|
||||
const phase = (index: number, title: string) => ({ type: "workflow_phase", index, title });
|
||||
const agent = (label: string, fields: Record<string, unknown> = {}) => ({
|
||||
type: "workflow_agent",
|
||||
label,
|
||||
state: "start",
|
||||
phaseIndex: 1,
|
||||
...fields,
|
||||
});
|
||||
|
||||
describe("workflowSnapshot", () => {
|
||||
it.each([
|
||||
["not an array", { agents: [] }],
|
||||
["no phase or agent", [{ type: "workflow_note", text: "hi" }]],
|
||||
["only malformed entries", [phase(1, " "), { type: "workflow_agent", label: "a" }, null]],
|
||||
])("is undefined for %s", (_, value) => {
|
||||
expect(workflowSnapshot(value)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("skips malformed entries and fields of the wrong type", () => {
|
||||
expect(
|
||||
workflowSnapshot([
|
||||
phase(1, "Count"),
|
||||
{ type: "workflow_phase", title: "No index" },
|
||||
agent("a", { tokens: "12" }),
|
||||
]),
|
||||
).toEqual({
|
||||
phases: [{ index: 1, title: "Count" }],
|
||||
agents: [{ label: "a", state: "start", phaseIndex: 1 }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("workflowSnapshotText", () => {
|
||||
it.each([
|
||||
{
|
||||
name: "lists agents without a known phase before the phases",
|
||||
entries: [
|
||||
phase(1, "Count"),
|
||||
agent("loose", { phaseIndex: undefined }),
|
||||
agent("lost", { phaseIndex: 9 }),
|
||||
],
|
||||
text: "- loose: running\n- lost: running\n\n**Count**\n- not started",
|
||||
},
|
||||
{
|
||||
name: "shows a retry's attempt and a state the adapter does not know as sent",
|
||||
entries: [phase(1, "Count"), agent("a", { attempt: 2, state: "error" })],
|
||||
text: "**Count**\n- a: error (attempt 2)",
|
||||
},
|
||||
{
|
||||
name: "shows the last tool of a running agent only",
|
||||
entries: [
|
||||
phase(1, "Count"),
|
||||
agent("run", { lastToolName: "Read" }),
|
||||
agent("done", { state: "done", lastToolName: "Bash", lastToolSummary: "echo 3" }),
|
||||
],
|
||||
text: "**Count**\n- run: running · Read\n- done: done",
|
||||
},
|
||||
])("$name", ({ entries, text }) => {
|
||||
expect(workflowSnapshotText(workflowSnapshot(entries)!)).toBe(text);
|
||||
});
|
||||
});
|
||||
124
src/workflow-progress.ts
Normal file
124
src/workflow-progress.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* The phases and agents of a running workflow, from the `workflow_progress`
|
||||
* snapshot that Claude Code attaches to a workflow's `task_progress`.
|
||||
*
|
||||
* The SDK does not type `workflow_progress` (SDK 0.3.319, CLI 2.1.319). Its
|
||||
* agents do not stream through the root query either, so this snapshot is
|
||||
* the only per-agent data the adapter gets. The shape below is what the CLI
|
||||
* sends: a full list of `workflow_phase` and `workflow_agent` entries on
|
||||
* some progress reports and none on others. Entries that don't match it are
|
||||
* skipped, so a change in the CLI drops the list instead of the card.
|
||||
*/
|
||||
|
||||
export type WorkflowPhase = { index: number; title: string };
|
||||
|
||||
export type WorkflowAgent = {
|
||||
label: string;
|
||||
/** `start` while it runs and `done` when it finished, as the CLI names them. */
|
||||
state: string;
|
||||
phaseIndex?: number;
|
||||
attempt?: number;
|
||||
tokens?: number;
|
||||
toolCalls?: number;
|
||||
durationMs?: number;
|
||||
lastToolName?: string;
|
||||
lastToolSummary?: string;
|
||||
};
|
||||
|
||||
export type WorkflowSnapshot = { phases: WorkflowPhase[]; agents: WorkflowAgent[] };
|
||||
|
||||
/** Reads a `workflow_progress` value. Undefined when it holds no phase or agent. */
|
||||
export function workflowSnapshot(value: unknown): WorkflowSnapshot | undefined {
|
||||
if (!Array.isArray(value)) return undefined;
|
||||
const phases: WorkflowPhase[] = [];
|
||||
const agents: WorkflowAgent[] = [];
|
||||
for (const entry of value) {
|
||||
if (!isRecord(entry)) continue;
|
||||
if (entry.type === "workflow_phase") {
|
||||
const title = text(entry.title);
|
||||
if (typeof entry.index === "number" && title) phases.push({ index: entry.index, title });
|
||||
} else if (entry.type === "workflow_agent") {
|
||||
const label = text(entry.label);
|
||||
const state = text(entry.state);
|
||||
if (!label || !state) continue;
|
||||
agents.push({
|
||||
label,
|
||||
state,
|
||||
...numbers(entry, ["phaseIndex", "attempt", "tokens", "toolCalls", "durationMs"]),
|
||||
...strings(entry, ["lastToolName", "lastToolSummary"]),
|
||||
});
|
||||
}
|
||||
}
|
||||
return phases.length > 0 || agents.length > 0 ? { phases, agents } : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The snapshot as Markdown: each phase as a heading with its agents below,
|
||||
* after the agents that have no known phase. For example:
|
||||
*
|
||||
* ```
|
||||
* **Count**
|
||||
* - two: done · 14,629 tokens · 0 tool calls · 1s
|
||||
* - three: running · Bash: echo 3
|
||||
*
|
||||
* **Sum**
|
||||
* - not started
|
||||
* ```
|
||||
*/
|
||||
export function workflowSnapshotText(snapshot: WorkflowSnapshot): string {
|
||||
const known = new Set(snapshot.phases.map((phase) => phase.index));
|
||||
const unphased = snapshot.agents.filter(
|
||||
(agent) => agent.phaseIndex === undefined || !known.has(agent.phaseIndex),
|
||||
);
|
||||
const sections = unphased.length > 0 ? [unphased.map(agentLine).join("\n")] : [];
|
||||
for (const phase of snapshot.phases) {
|
||||
const agents = snapshot.agents.filter((agent) => agent.phaseIndex === phase.index);
|
||||
const lines = agents.length > 0 ? agents.map(agentLine) : ["- not started"];
|
||||
sections.push([`**${phase.title}**`, ...lines].join("\n"));
|
||||
}
|
||||
return sections.join("\n\n");
|
||||
}
|
||||
|
||||
function agentLine(agent: WorkflowAgent): string {
|
||||
const state = agent.state === "start" ? "running" : agent.state;
|
||||
const attempt =
|
||||
agent.attempt !== undefined && agent.attempt > 1 ? ` (attempt ${agent.attempt})` : "";
|
||||
const parts = [`${agent.label}: ${state}${attempt}`];
|
||||
if (agent.tokens !== undefined) parts.push(`${agent.tokens.toLocaleString("en-US")} tokens`);
|
||||
if (agent.toolCalls !== undefined) {
|
||||
parts.push(`${agent.toolCalls.toLocaleString("en-US")} tool calls`);
|
||||
}
|
||||
if (agent.durationMs !== undefined) parts.push(`${Math.floor(agent.durationMs / 1000)}s`);
|
||||
// A finished agent's last tool is history; a running one's is what it does now.
|
||||
if (agent.state === "start" && agent.lastToolName) {
|
||||
parts.push(
|
||||
agent.lastToolSummary
|
||||
? `${agent.lastToolName}: ${agent.lastToolSummary}`
|
||||
: agent.lastToolName,
|
||||
);
|
||||
}
|
||||
return `- ${parts.join(" · ")}`;
|
||||
}
|
||||
|
||||
function numbers<K extends string>(record: Record<string, unknown>, keys: K[]) {
|
||||
return Object.fromEntries(
|
||||
keys.flatMap((key) => (typeof record[key] === "number" ? [[key, record[key]]] : [])),
|
||||
) as Partial<Record<K, number>>;
|
||||
}
|
||||
|
||||
function strings<K extends string>(record: Record<string, unknown>, keys: K[]) {
|
||||
return Object.fromEntries(
|
||||
keys.flatMap((key) => {
|
||||
const value = text(record[key]);
|
||||
return value ? [[key, value]] : [];
|
||||
}),
|
||||
) as Partial<Record<K, string>>;
|
||||
}
|
||||
|
||||
function text(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
Reference in New Issue
Block a user