Compare commits

..

3 Commits

Author SHA1 Message Date
Ben Brandt
766fd142f6 feat: send the checklist as a plan operation and remove plans on reset (#1554)
## Summary

Follow-up to the [plan operations
RFD](https://agentclientprotocol.com/rfds/plan-operations). The adapter
already sent approved ExitPlanMode plans as `markdown` plans to clients
with the `plan` capability, but two parts of the RFD were unused:

- **The checklist is now a plan operation on v1 too.** Clients with
`plan` (every v2 client, and v1 clients that opt in) get the
TodoWrite/Task checklist as `plan_update` with type `items` and the ID
`plan`. Before, v1 clients with the capability still got an ID-less
legacy `plan` next to the `plan-mode` plan. Clients without the
capability still get the legacy `plan`.
- **`plan_removed` is now sent.**
- An empty checklist sends `plan_removed` for `plan`. v2 clients used to
get a `plan_update` with no entries.
- A conversation reset (`/clear`) removes the checklist and the old
conversation's approved `plan-mode` plan. AIR keeps its approved plans,
which are joined to their approval cards by ID (#1469).

The new `src/plans.ts` builds the checklist updates. All three places
that send the checklist use it, and so does the v2 conversion of a
legacy `plan`, so v1 and v2 follow one rule.

The reset sends `plan_removed` for `plan-mode` even when the session
never published an approved plan. Tracking whether one was published
would be state that a restart loses, so the reset would behave
differently before and after one.

`file` plans are left out. A `file://` URI only works for a client on
the same machine, and v2 has no client filesystem.

## Tests

- `tools.test.ts`: TodoWrite with and without todos, and a TaskCreate
snapshot, for a v1 client with `plan`.
- `acp-agent.test.ts`: the reset test now runs for a client without plan
operations, a client with them, and AIR.
- `acp-v2.test.ts`: v2 end to end, TodoWrite then `/clear`, and an empty
legacy plan converting to `plan_removed`.
2026-11-09 14:58:36 +00:00
Ben Brandt
176ae0634c 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).
2026-11-09 14:49:48 +00:00
dependabot[bot]
fcb7e175ee ci(deps): Bump amannn/action-semantic-pull-request from 6.1.1 to 6.1.2 (#1551)
Bumps
[amannn/action-semantic-pull-request](https://github.com/amannn/action-semantic-pull-request)
from 6.1.1 to 6.1.2.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/amannn/action-semantic-pull-request/releases">amannn/action-semantic-pull-request's
releases</a>.</em></p>
<blockquote>
<h2>v6.1.2</h2>
<h2><a
href="https://github.com/amannn/action-semantic-pull-request/compare/v6.1.1...v6.1.2">6.1.2</a>
(2026-11-05)</h2>
<h3>Bug Fixes</h3>
<ul>
<li>Release accumulated dependency updates (closes <a
href="https://redirect.github.com/amannn/action-semantic-pull-request/issues/321">#321</a>)
(<a
href="fc7da0cece">fc7da0c</a>)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/amannn/action-semantic-pull-request/blob/main/CHANGELOG.md">amannn/action-semantic-pull-request's
changelog</a>.</em></p>
<blockquote>
<h1>Changelog</h1>
<h2><a
href="https://github.com/amannn/action-semantic-pull-request/compare/v6.1.1...v6.1.2">6.1.2</a>
(2026-11-05)</h2>
<h3>Bug Fixes</h3>
<ul>
<li>Release accumulated dependency updates (closes <a
href="https://redirect.github.com/amannn/action-semantic-pull-request/issues/321">#321</a>)
(<a
href="fc7da0cece">fc7da0c</a>)</li>
</ul>
<h2><a
href="https://github.com/amannn/action-semantic-pull-request/compare/v6.1.0...v6.1.1">6.1.1</a>
(2025-08-22)</h2>
<h3>Bug Fixes</h3>
<ul>
<li>Parse <code>headerPatternCorrespondence</code> properly (<a
href="https://redirect.github.com/amannn/action-semantic-pull-request/issues/295">#295</a>)
(<a
href="800da4c97f">800da4c</a>)</li>
</ul>
<h2><a
href="https://github.com/amannn/action-semantic-pull-request/compare/v6.0.1...v6.1.0">6.1.0</a>
(2025-08-19)</h2>
<h3>Features</h3>
<ul>
<li>Support providing regexps for types (<a
href="https://redirect.github.com/amannn/action-semantic-pull-request/issues/292">#292</a>)
(<a
href="a30288bf13">a30288b</a>)</li>
</ul>
<h3>Bug Fixes</h3>
<ul>
<li>Remove trailing whitespace from &quot;unknown release type&quot;
error message (<a
href="https://redirect.github.com/amannn/action-semantic-pull-request/issues/291">#291</a>)
(<a
href="afa4edb1c4">afa4edb</a>)</li>
</ul>
<h2><a
href="https://github.com/amannn/action-semantic-pull-request/compare/v6.0.0...v6.0.1">6.0.1</a>
(2025-08-13)</h2>
<h3>Bug Fixes</h3>
<ul>
<li>Actually execute action (<a
href="https://redirect.github.com/amannn/action-semantic-pull-request/issues/289">#289</a>)
(<a
href="58e4ab40f5">58e4ab4</a>)</li>
</ul>
<h2><a
href="https://github.com/amannn/action-semantic-pull-request/compare/v5.5.3...v6.0.0">6.0.0</a>
(2025-08-13)</h2>
<h3>⚠ BREAKING CHANGES</h3>
<ul>
<li>Upgrade action to use Node.js 24 and ESM (<a
href="https://redirect.github.com/amannn/action-semantic-pull-request/issues/287">#287</a>)</li>
</ul>
<h3>Features</h3>
<ul>
<li>Upgrade action to use Node.js 24 and ESM (<a
href="https://redirect.github.com/amannn/action-semantic-pull-request/issues/287">#287</a>)
(<a
href="bc0c9a79ab">bc0c9a7</a>)</li>
</ul>
<h2><a
href="https://github.com/amannn/action-semantic-pull-request/compare/v5.5.2...v5.5.3">5.5.3</a>
(2024-06-28)</h2>
<h3>Bug Fixes</h3>
<ul>
<li>Bump <code>braces</code> dependency (<a
href="https://redirect.github.com/amannn/action-semantic-pull-request/issues/269">#269</a>.
by <a href="https://github.com/EelcoLos"><code>@​EelcoLos</code></a>)
(<a
href="2d952a1bf9">2d952a1</a>)</li>
</ul>
<h2><a
href="https://github.com/amannn/action-semantic-pull-request/compare/v5.5.1...v5.5.2">5.5.2</a>
(2024-04-24)</h2>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="2252a14e81"><code>2252a14</code></a>
chore: Release 6.1.2 [skip ci]</li>
<li><a
href="af97545064"><code>af97545</code></a>
ci: pin conventional-changelog-conventionalcommits to v9 (<a
href="https://redirect.github.com/amannn/action-semantic-pull-request/issues/323">#323</a>)</li>
<li><a
href="fc7da0cece"><code>fc7da0c</code></a>
fix: Release accumulated dependency updates (closes <a
href="https://redirect.github.com/amannn/action-semantic-pull-request/issues/321">#321</a>)</li>
<li><a
href="4e8becf171"><code>4e8becf</code></a>
ci: bump the npm-packages group with 10 updates (<a
href="https://redirect.github.com/amannn/action-semantic-pull-request/issues/319">#319</a>)</li>
<li><a
href="b3fa6edcb1"><code>b3fa6ed</code></a>
ci: bump actions/setup-node from 6 to 7 in the github-action-workflows
group ...</li>
<li><a
href="2c9480cf28"><code>2c9480c</code></a>
chore: bump the npm-packages group with 12 updates (<a
href="https://redirect.github.com/amannn/action-semantic-pull-request/issues/315">#315</a>)</li>
<li><a
href="58e675f6ba"><code>58e675f</code></a>
ci: bump the github-action-workflows group with 2 updates (<a
href="https://redirect.github.com/amannn/action-semantic-pull-request/issues/313">#313</a>)</li>
<li><a
href="45b9ed7cf2"><code>45b9ed7</code></a>
chore: bump the npm-packages group with 12 updates (<a
href="https://redirect.github.com/amannn/action-semantic-pull-request/issues/308">#308</a>)</li>
<li><a
href="9d28649546"><code>9d28649</code></a>
ci: Bump the github-action-workflows group with 2 updates (<a
href="https://redirect.github.com/amannn/action-semantic-pull-request/issues/309">#309</a>)</li>
<li><a
href="ac7e3fc035"><code>ac7e3fc</code></a>
docs: Fix snippet (<a
href="https://redirect.github.com/amannn/action-semantic-pull-request/issues/306">#306</a>)</li>
<li>Additional commits viewable in <a
href="48f256284b...2252a14e81">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=amannn/action-semantic-pull-request&package-manager=github_actions&previous-version=6.1.1&new-version=6.1.2)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-09 15:39:29 +01:00
10 changed files with 575 additions and 69 deletions

View File

@@ -24,7 +24,7 @@ jobs:
steps:
# Config reference: https://github.com/amannn/action-semantic-pull-request#configuration
- uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6.1.1
- uses: amannn/action-semantic-pull-request@2252a14e810aa9b85c2d560bd7070c802206ecf6 # v6.1.2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:

View File

@@ -106,6 +106,7 @@ import {
toGoalSnapshot,
} from "./goal-extension.js";
import { blobResourceToClaude } from "./blob-resources.js";
import { checklistPlanUpdate, resetPlanUpdates } from "./plans.js";
import { promptSuggestionsMeta, promptSuggestionText } from "./prompt-suggestions.js";
import { sanitizeTitle, SessionTitles } from "./session-titles.js";
import {
@@ -165,6 +166,7 @@ import {
clientSupportsAsyncTasks,
taskProgressFields,
} from "./async-tasks.js";
import { workflowSnapshot } from "./workflow-progress.js";
import {
type BackgroundSink,
type BackgroundTaskEnd,
@@ -4460,7 +4462,7 @@ export class ClaudeAcpAgent {
if (!entries) return;
await this.client.sessionUpdate({
sessionId,
update: { sessionUpdate: "plan", entries },
update: checklistPlanUpdate(entries, this.clientCapabilities?.plan != null),
});
}
@@ -6763,10 +6765,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,
@@ -8742,7 +8748,13 @@ export class ClaudeAcpAgent {
session.toolCallFields?.clear();
clearHookCallbacks(params.sessionId);
session.taskState.clear();
await this.publishTaskPlan(params.sessionId, session.taskState);
forgetPublishedTaskPlan(session.taskState);
for (const update of resetPlanUpdates(
this.clientCapabilities?.plan != null,
this.toolCallCapabilities.air.client,
)) {
await this.client.sessionUpdate({ sessionId: params.sessionId, update });
}
// A reset mounts a fresh transcript (`new_conversation_id`), so our
// cached title no longer describes the session: drop it and
// re-evaluate at the next turn-end.
@@ -14360,10 +14372,10 @@ export function toAcpNotifications(
if (chunk.name === "TodoWrite") {
// @ts-expect-error - sometimes input is empty object or undefined
if (Array.isArray(chunk.input?.todos)) {
update = {
sessionUpdate: "plan",
entries: planEntries(chunk.input as { todos: ClaudePlanEntry[] }),
};
update = checklistPlanUpdate(
planEntries(chunk.input as { todos: ClaudePlanEntry[] }),
options?.clientCapabilities?.plan != null,
);
}
} else if (isTaskTool(chunk.name)) {
// Task* tool_use is suppressed; the plan update is emitted at
@@ -14598,7 +14610,9 @@ export function toAcpNotifications(
const entries = shouldEmitTaskPlan
? changedTaskPlanEntries(taskState, renderer.capabilities.air.client)
: undefined;
if (entries) update = { sessionUpdate: "plan", entries };
if (entries) {
update = checklistPlanUpdate(entries, options?.clientCapabilities?.plan != null);
}
} else if (toolUse.name !== "TodoWrite") {
// A command sends its output first, then the exit and the status.
const [finalUpdate, ...rest] = renderer

View File

@@ -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");

58
src/plans.ts Normal file
View File

@@ -0,0 +1,58 @@
/**
* The session updates that show the session's plans. A client with the `plan`
* capability (every v2 client, and a v1 client that asks for it) tracks plans
* by ID and gets `plan_update` and `plan_removed`; any other client gets the
* legacy `plan` update, which holds one ID-less plan per session.
*/
import type {
PlanEntry,
PlanItems,
PlanRemoved,
SessionNotification,
} from "@agentclientprotocol/sdk/unstable";
import { PLAN_MODE_PLAN_ID } from "./exit-plan.js";
type PlanRemovalUpdate = PlanRemoved & { sessionUpdate: "plan_removed" };
/** The ID of the session's checklist, from TodoWrite and the Task tools. Every
* update replaces it whole. An approved ExitPlanMode plan is a separate plan
* ({@link PLAN_MODE_PLAN_ID}). */
export const CHECKLIST_PLAN_ID = "plan";
/** The plan operation that shows the checklist `entries`: `plan_update`, or
* `plan_removed` for an empty checklist. */
export function checklistPlanOperation(
entries: PlanEntry[],
): { sessionUpdate: "plan_update"; plan: PlanItems & { type: "items" } } | PlanRemovalUpdate {
if (entries.length === 0) return { sessionUpdate: "plan_removed", planId: CHECKLIST_PLAN_ID };
return {
sessionUpdate: "plan_update",
plan: { type: "items", planId: CHECKLIST_PLAN_ID, entries },
};
}
/** The update that shows the checklist `entries`: a plan operation (see
* {@link checklistPlanOperation}), else a legacy `plan`, which with no entries
* clears the client's plan. */
export function checklistPlanUpdate(
entries: PlanEntry[],
planOperations: boolean,
): SessionNotification["update"] {
return planOperations ? checklistPlanOperation(entries) : { sessionUpdate: "plan", entries };
}
/** The updates that dismiss the plans of a conversation that a reset (`/clear`)
* replaced: the checklist, and the approved ExitPlanMode plan. AIR keeps its
* approved plans, each joined to its approval card in the transcript (see
* planModeUpdate). Without plan operations, a legacy empty `plan` clears the
* one plan the client holds. */
export function resetPlanUpdates(
planOperations: boolean,
airClient: boolean,
): SessionNotification["update"][] {
const updates = [checklistPlanUpdate([], planOperations)];
if (planOperations && !airClient) {
updates.push({ sessionUpdate: "plan_removed", planId: PLAN_MODE_PLAN_ID });
}
return updates;
}

View File

@@ -346,49 +346,81 @@ describe("task plan lifecycle", () => {
);
});
it("clears the task plan on a conversation reset", async () => {
const taskState = new Map([["task-1", { subject: "Run tests", status: "in_progress" }]]);
const { agent, updates } = setup(taskState, async function* (input) {
const iter = input[Symbol.asyncIterator]();
const first = await iter.next();
yield userEcho(first.value);
yield {
type: "conversation_reset",
new_conversation_id: randomUUID(),
uuid: randomUUID(),
session_id: "test-session",
};
yield successfulResult();
const inProgressPlan = [{ content: "Run tests", status: "in_progress", priority: "medium" }];
const planUpdateKinds = new Set(["plan", "plan_update", "plan_removed"]);
const second = await iter.next();
yield userEcho(second.value);
yield successfulResult();
});
await agent.prompt({
sessionId: "test-session",
prompt: [{ type: "text", text: "/clear" }],
});
await agent.prompt({
sessionId: "test-session",
prompt: [{ type: "text", text: "Start fresh" }],
});
expect(taskState.size).toBe(0);
expect(updates.filter((notification) => notification.update.sessionUpdate === "plan")).toEqual([
{
sessionId: "test-session",
update: {
sessionUpdate: "plan",
entries: [{ content: "Run tests", status: "in_progress", priority: "medium" }],
it.each([
{
client: "a client without plan operations",
clientCapabilities: {},
expected: [
{ sessionUpdate: "plan", entries: inProgressPlan },
{ sessionUpdate: "plan", entries: [] },
],
},
{
client: "a client with plan operations",
clientCapabilities: { plan: {} },
expected: [
{
sessionUpdate: "plan_update",
plan: { type: "items", planId: "plan", entries: inProgressPlan },
},
},
{
{ sessionUpdate: "plan_removed", planId: "plan" },
{ sessionUpdate: "plan_removed", planId: "plan-mode" },
],
},
{
// AIR keeps each approved plan with its approval card.
client: "AIR with plan operations",
clientCapabilities: { ...AIR_CLIENT_CAPABILITIES, plan: {} },
expected: [
{
sessionUpdate: "plan_update",
plan: { type: "items", planId: "plan", entries: inProgressPlan },
},
{ sessionUpdate: "plan_removed", planId: "plan" },
],
},
])(
"clears the plans on a conversation reset for $client",
async ({ clientCapabilities, expected }) => {
const taskState = new Map([["task-1", { subject: "Run tests", status: "in_progress" }]]);
const { agent, updates } = setup(taskState, async function* (input) {
const iter = input[Symbol.asyncIterator]();
const first = await iter.next();
yield userEcho(first.value);
yield {
type: "conversation_reset",
new_conversation_id: randomUUID(),
uuid: randomUUID(),
session_id: "test-session",
};
yield successfulResult();
const second = await iter.next();
yield userEcho(second.value);
yield successfulResult();
});
await initializeClient(agent, clientCapabilities);
await agent.prompt({
sessionId: "test-session",
update: { sessionUpdate: "plan", entries: [] },
},
]);
});
prompt: [{ type: "text", text: "/clear" }],
});
await agent.prompt({
sessionId: "test-session",
prompt: [{ type: "text", text: "Start fresh" }],
});
expect(taskState.size).toBe(0);
expect(
updates
.map((notification) => notification.update)
.filter((update) => planUpdateKinds.has(update.sessionUpdate)),
).toEqual(expected);
},
);
});
describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("ACP subprocess integration", () => {

View File

@@ -1574,6 +1574,59 @@ describe("ACP v2 prompts", () => {
]);
});
it("removes the checklist and the approved plan on a conversation reset", async () => {
scriptTurns(
async function* (options) {
const todoWrite = assistantText(options, "", "msg_todo");
todoWrite.message.content = [
{
type: "tool_use",
id: "toolu_todo",
name: "TodoWrite",
input: { todos: [{ content: "Do X", status: "pending", activeForm: "Doing X" }] },
},
] as never;
yield todoWrite;
yield result(options);
},
async function* (options) {
yield {
type: "conversation_reset",
new_conversation_id: randomUUID(),
uuid: randomUUID(),
session_id: options.sessionId,
};
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("plan") });
await agent.request(v2.methods.agent.session.prompt, { sessionId, prompt: text("/clear") });
await vi.waitFor(() => expect(client.updates("plan_removed")).toHaveLength(2));
await client.authUpdate(1);
});
expect(
client.sessionUpdates
.map(({ update }) => update)
.filter(({ sessionUpdate }) => sessionUpdate.startsWith("plan_")),
).toEqual([
{
sessionUpdate: "plan_update",
plan: {
type: "items",
planId: "plan",
entries: [{ content: "Do X", status: "pending", priority: "medium" }],
},
},
{ sessionUpdate: "plan_removed", planId: "plan" },
{ sessionUpdate: "plan_removed", planId: "plan-mode" },
]);
});
it("reports Claude Code's warnings as live notices", async () => {
scriptTurns(async function* (options) {
yield {
@@ -6168,6 +6221,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);
@@ -6472,6 +6615,13 @@ describe("ACP v2 session translation", () => {
});
});
it("reports an empty legacy plan as the removal of the session's v2 plan", () => {
expect(v2SessionUpdate({ sessionUpdate: "plan", entries: [] })).toEqual({
sessionUpdate: "plan_removed",
planId: "plan",
});
});
it("passes the agent's plan operations through, which have v2's shape", () => {
const update = {
sessionUpdate: "plan_update" as const,

View File

@@ -2684,6 +2684,36 @@ describe("toAcpNotifications - TodoWrite with undefined input regression", () =>
const planUpdates = notifications.filter((n) => (n.update as any).sessionUpdate === "plan");
expect(planUpdates).toHaveLength(1);
});
it.each([
{
todos: [{ content: "Do X", status: "pending", activeForm: "Doing X" }],
expected: {
sessionUpdate: "plan_update",
plan: {
type: "items",
planId: "plan",
entries: [{ content: "Do X", status: "pending", priority: "medium" }],
},
},
},
{ todos: [], expected: { sessionUpdate: "plan_removed", planId: "plan" } },
])(
"sends $todos.length todos as a plan operation to a client with plan operations",
({ todos, expected }) => {
const notifications = toAcpNotifications(
[{ type: "tool_use" as const, id: "toolu_todo", name: "TodoWrite", input: { todos } }],
"assistant",
"test-session",
{},
mockClient,
mockLogger,
{ clientCapabilities: { plan: {} } },
);
expect(notifications.map(({ update }) => update)).toEqual([expected]);
},
);
});
describe("parseTaskCreateOutput", () => {
@@ -2923,6 +2953,39 @@ describe("toAcpNotifications - Task* tools", () => {
expect(taskState.size).toBe(0);
});
it("sends the task snapshot as a plan operation to a client with plan operations", () => {
const toolUseCache: ToolUseCache = {
"1": { type: "tool_use", id: "1", name: "TaskCreate", input: { subject: "First" } },
};
const created = toAcpNotifications(
[
{
type: "tool_result",
tool_use_id: "1",
content: JSON.stringify({ task: { id: "1", subject: "First" } }),
is_error: false,
},
] as any,
"user",
"test-session",
toolUseCache,
mockClient,
mockLogger,
{ taskState: new Map(), clientCapabilities: { plan: {} } },
);
expect(created.map(({ update }) => update)).toEqual([
{
sessionUpdate: "plan_update",
plan: {
type: "items",
planId: "plan",
entries: [{ content: "First", status: "pending", priority: "medium" }],
},
},
]);
});
it("emits a plan snapshot after a TaskCreate tool_result and accumulates state", () => {
const toolUseCache: ToolUseCache = {};
const taskState: TaskState = new Map();

View 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);
});
});

View File

@@ -5,15 +5,9 @@
import * as v2 from "@agentclientprotocol/sdk/experimental/v2/unstable";
import type { AcpSessionNotification } from "../acp-subagents.js";
import { v2AvailableCommands, v2ConfigOptions } from "./session.js";
import { checklistPlanOperation } from "../plans.js";
import { v2ToolCallUpdate } from "./tool-call.js";
/**
* The id of the plan of a session. v1 has one plan per session, which every
* plan update replaces whole, from TodoWrite and from the Task tools alike.
* v2 tracks plans by id, so that plan gets one.
*/
export const SESSION_PLAN_ID = "plan";
/**
* The v2 form of a v1 session update, or `undefined` when v2 has no
* counterpart and the update tells a v2 client nothing new.
@@ -48,13 +42,12 @@ export function v2SessionUpdate(
case "tool_call":
case "tool_call_update":
return v2ToolCallUpdate(update);
case "plan":
// A v2 plan of items is replaced whole as well.
return {
sessionUpdate: "plan_update",
plan: { type: "items", planId: SESSION_PLAN_ID, entries: update.entries },
...(update._meta != null ? { _meta: update._meta } : {}),
};
case "plan": {
// Every v2 client has the `plan` capability, so the agent sends the
// checklist as `plan_update`; a legacy plan converts the same way.
const converted = checklistPlanOperation(update.entries);
return update._meta != null ? { ...converted, _meta: update._meta } : converted;
}
case "plan_update":
case "plan_removed":
// v1's draft plan operations have v2's shape.

124
src/workflow-progress.ts Normal file
View 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);
}