mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-11-09 19:02:05 +00:00
Compare commits
7 Commits
v0.101.1-p
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
766fd142f6 | ||
|
|
176ae0634c | ||
|
|
fcb7e175ee | ||
|
|
e9e0d414fe | ||
|
|
30600ba348 | ||
|
|
dd9eed4e46 | ||
|
|
4cceb06b31 |
2
.github/workflows/conventional-prs.yml
vendored
2
.github/workflows/conventional-prs.yml
vendored
@@ -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:
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "0.101.0"
|
||||
".": "0.101.1"
|
||||
}
|
||||
|
||||
18
CHANGELOG.md
18
CHANGELOG.md
@@ -1,5 +1,23 @@
|
||||
# Changelog
|
||||
|
||||
## [0.101.1](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.101.0...v0.101.1) (2026-11-09)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* keep autonomous turn tasks open across owed trailing idles ([#1455](https://github.com/agentclientprotocol/claude-agent-acp/issues/1455)) ([1ee2422](https://github.com/agentclientprotocol/claude-agent-acp/commit/1ee242293401372aa71ea5765e8dfd0679146dc9))
|
||||
* list a resumed Fable 5.1 session's model as `fable` ([#1548](https://github.com/agentclientprotocol/claude-agent-acp/issues/1548)) ([4cceb06](https://github.com/agentclientprotocol/claude-agent-acp/commit/4cceb06b31525ccaaecfd999ff9b21d401b2659a))
|
||||
* list a subagent's background Bash commands ([#1540](https://github.com/agentclientprotocol/claude-agent-acp/issues/1540)) ([6597626](https://github.com/agentclientprotocol/claude-agent-acp/commit/6597626d86ddb8c10ea9f1091d921806752d045a))
|
||||
* list Monitor calls in the session's background work ([#1538](https://github.com/agentclientprotocol/claude-agent-acp/issues/1538)) ([ba16234](https://github.com/agentclientprotocol/claude-agent-acp/commit/ba162346c9ccb4be51f13a3271198351d7822e78))
|
||||
* relay a rate_limit_event that arrives before the turn's first assistant frame ([#1534](https://github.com/agentclientprotocol/claude-agent-acp/issues/1534)) ([54392e1](https://github.com/agentclientprotocol/claude-agent-acp/commit/54392e144d307d5dd5b7732ae14634d4b2678bd5))
|
||||
* render workflow tool cards and progress correctly in ACP v2 ([#1550](https://github.com/agentclientprotocol/claude-agent-acp/issues/1550)) ([30600ba](https://github.com/agentclientprotocol/claude-agent-acp/commit/30600ba348e5f5c3755a50f5797fd24645e0ff48))
|
||||
* replay a compaction summary by its flag, not its framing ([#1536](https://github.com/agentclientprotocol/claude-agent-acp/issues/1536)) ([aa0c123](https://github.com/agentclientprotocol/claude-agent-acp/commit/aa0c1234b5c52a63d96c46c9826631bd0f428062))
|
||||
* report setup-token logins as logged in ([#1376](https://github.com/agentclientprotocol/claude-agent-acp/issues/1376)) ([7d1ef76](https://github.com/agentclientprotocol/claude-agent-acp/commit/7d1ef76c1d32e390f8ff8824087df3003825a170))
|
||||
* start transcript notices on their own line for clients without session.notices ([#1362](https://github.com/agentclientprotocol/claude-agent-acp/issues/1362)) ([47b64c0](https://github.com/agentclientprotocol/claude-agent-acp/commit/47b64c04de83145c4d42033c4408c811736c9c40))
|
||||
* **test:** assert the blockquoted MCP startup transcript line ([#1543](https://github.com/agentclientprotocol/claude-agent-acp/issues/1543)) ([3b22586](https://github.com/agentclientprotocol/claude-agent-acp/commit/3b22586cf63cef06f1029a8418a9abb3b2cc3236))
|
||||
* update to @agentclientprotocol/sdk 2.5.0 ([#1549](https://github.com/agentclientprotocol/claude-agent-acp/issues/1549)) ([ef65ab3](https://github.com/agentclientprotocol/claude-agent-acp/commit/ef65ab31b405a5d339133c980eb606a70a2d3624))
|
||||
* update to @anthropic-ai/claude-agent-sdk 0.3.319 ([#1545](https://github.com/agentclientprotocol/claude-agent-acp/issues/1545)) ([a58293f](https://github.com/agentclientprotocol/claude-agent-acp/commit/a58293f4c9aa559a500d61834ef97d2a23b3722b))
|
||||
|
||||
## [0.101.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.100.0...v0.101.0) (2026-11-05)
|
||||
|
||||
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@agentclientprotocol/claude-agent-acp",
|
||||
"version": "0.101.0",
|
||||
"version": "0.101.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@agentclientprotocol/claude-agent-acp",
|
||||
"version": "0.101.0",
|
||||
"version": "0.101.1",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "2.5.0",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"version": "0.101.0",
|
||||
"version": "0.101.1",
|
||||
"description": "An ACP-compatible coding agent powered by the Claude Agent SDK (TypeScript)",
|
||||
"main": "dist/lib.js",
|
||||
"types": "dist/lib.d.ts",
|
||||
|
||||
101
src/acp-agent.ts
101
src/acp-agent.ts
@@ -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 {
|
||||
@@ -163,7 +164,9 @@ import {
|
||||
backgroundBashTaskFromToolResult,
|
||||
backgroundedBashToolCallIds,
|
||||
clientSupportsAsyncTasks,
|
||||
taskProgressFields,
|
||||
} from "./async-tasks.js";
|
||||
import { workflowSnapshot } from "./workflow-progress.js";
|
||||
import {
|
||||
type BackgroundSink,
|
||||
type BackgroundTaskEnd,
|
||||
@@ -362,6 +365,7 @@ import {
|
||||
SessionModeManager,
|
||||
} from "./session-mode.js";
|
||||
import {
|
||||
aliasResumedModelRow,
|
||||
applyAvailableModelsAllowlist,
|
||||
buildModelConfigOption,
|
||||
getAvailableModels,
|
||||
@@ -4458,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),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -6754,13 +6758,22 @@ 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;
|
||||
}
|
||||
// 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,
|
||||
description: message.description,
|
||||
@@ -6770,6 +6783,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(
|
||||
@@ -8633,6 +8647,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) {
|
||||
@@ -8731,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.
|
||||
@@ -10779,15 +10802,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 }) => {
|
||||
@@ -10814,34 +10835,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);
|
||||
@@ -12318,9 +12330,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: [
|
||||
@@ -12519,14 +12531,21 @@ export class ClaudeAcpAgent {
|
||||
// configured. Managed `deniedModels` drops entries the CLI would refuse.
|
||||
const settingsAvailableModels = settingsManager.getSettings().availableModels;
|
||||
const settingsModelOverrides = settingsManager.getSettings().modelOverrides;
|
||||
const resumedModel = await resumedModelHint;
|
||||
// A resumed Fable 5.1 session lists Fable 5.1 as `fable`, as a new
|
||||
// session does (see `aliasResumedModelRow`).
|
||||
const sdkModels = aliasResumedModelRow(
|
||||
initializationResult.models,
|
||||
creationOpts.resume !== undefined ? resumedModel : undefined,
|
||||
);
|
||||
const settingsAllowedModels = Array.isArray(settingsAvailableModels)
|
||||
? applyAvailableModelsAllowlist(
|
||||
initializationResult.models,
|
||||
sdkModels,
|
||||
settingsAvailableModels,
|
||||
settingsModelOverrides,
|
||||
settingsManager.getManagedDeniedModels(),
|
||||
)
|
||||
: initializationResult.models;
|
||||
: sdkModels;
|
||||
// Then keep only the models the `providers/set` gateway listed.
|
||||
const allowedModels = resolvedProvider?.listedModels
|
||||
? narrowToGatewayModels(
|
||||
@@ -12544,7 +12563,7 @@ export class ClaudeAcpAgent {
|
||||
this.logger,
|
||||
creationOpts.resume !== undefined,
|
||||
sessionId,
|
||||
await resumedModelHint,
|
||||
resumedModel,
|
||||
resolvedProvider?.listedModels !== undefined,
|
||||
);
|
||||
timing.phase("models");
|
||||
@@ -12560,7 +12579,7 @@ export class ClaudeAcpAgent {
|
||||
const allowlistedModelInfo = allowedModels.find((m) => m.value === models.currentModelId);
|
||||
const fallbackModelInfo = allowlistedModelInfo
|
||||
? undefined
|
||||
: (resolveModelPreference(initializationResult.models, models.currentModelId) ?? undefined);
|
||||
: (resolveModelPreference(sdkModels, models.currentModelId) ?? undefined);
|
||||
const currentModelInfo = allowlistedModelInfo ?? fallbackModelInfo;
|
||||
// Register the fallback-resolved capabilities under the verbatim live id
|
||||
// so every modelInfos consumer (buildConfigOptions' effort lookup, later
|
||||
@@ -14353,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
|
||||
@@ -14591,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
|
||||
|
||||
@@ -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,14 @@
|
||||
* (`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 { type WorkflowSnapshot, workflowSnapshotText } from "./workflow-progress.js";
|
||||
import { textContent } from "./tool-calls/content.js";
|
||||
|
||||
/** An item of a `background` list. */
|
||||
export type BackgroundItem =
|
||||
@@ -68,9 +71,18 @@ 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?: 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";
|
||||
|
||||
@@ -264,11 +276,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 +292,36 @@ 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. 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);
|
||||
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 +342,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 +371,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 +474,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 +597,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 +615,27 @@ function toolCallStatusUpdate(
|
||||
};
|
||||
}
|
||||
|
||||
/** The latest workflow activity, rendered as one replaceable tool-card body. */
|
||||
function workflowProgress(progress: WorkflowCardProgress | 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,
|
||||
progress.workflow ? workflowSnapshotText(progress.workflow) : 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
|
||||
|
||||
58
src/plans.ts
Normal file
58
src/plans.ts
Normal 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;
|
||||
}
|
||||
@@ -281,6 +281,66 @@ function resolveSettingsModel(
|
||||
|
||||
const MODEL_FAMILY_ALIASES = new Set(["opus", "sonnet", "haiku", "fable"]);
|
||||
|
||||
/** The family and version of a `claude-<family>-<major>[-<minor>]` id:
|
||||
* `claude-fable-5-1` is fable 5.1, `claude-haiku-4-5-20251001` haiku 4.5. */
|
||||
function familyVersionOf(id: string): { family: string; major: number; minor: number } | null {
|
||||
const match = id.toLowerCase().match(/^claude-([a-z]+)-(\d+)(?:-(\d{1,2}))?(?![\d.])/);
|
||||
if (!match) return null;
|
||||
return { family: match[1], major: Number(match[2]), minor: Number(match[3] ?? 0) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Give back the family alias value to the row the CLI lists under a resumed
|
||||
* session's model id.
|
||||
*
|
||||
* The CLI lists Fable 5.1 as `fable`, but when it resumes a session that ran
|
||||
* Fable 5.1 it lists the same row as `claude-fable-5-1` and has no `fable`
|
||||
* row. Clients store the option value as the session's choice, so a value
|
||||
* from a new session would not be offered by a resumed one, and the reverse.
|
||||
*
|
||||
* A row gets its family's alias as its value when its `value` is
|
||||
* `resumedModel`, equals its own `resolvedModel` and has no context hint, no
|
||||
* row has that alias, and no row of the family has a newer version (an alias
|
||||
* runs its family's newest model). Every other row is returned as it is.
|
||||
*
|
||||
* The CLI lists a row the same way when `ANTHROPIC_MODEL` or `settings.model`
|
||||
* pins `claude-fable-5-1`. Those rows keep the pinned id, so a client's choice
|
||||
* stays on that version after a newer Fable becomes `fable`.
|
||||
*/
|
||||
export function aliasResumedModelRow(
|
||||
models: ModelInfo[],
|
||||
resumedModel: string | undefined,
|
||||
): ModelInfo[] {
|
||||
if (resumedModel === undefined) return models;
|
||||
const resumed = canonicalizeModelId(resumedModel);
|
||||
const values = new Set(models.map((model) => model.value));
|
||||
const versions = models.flatMap((model) => {
|
||||
const version = familyVersionOf(model.resolvedModel ?? model.value);
|
||||
return version ? [version] : [];
|
||||
});
|
||||
return models.map((model) => {
|
||||
const version = familyVersionOf(model.value);
|
||||
if (
|
||||
version === null ||
|
||||
!MODEL_FAMILY_ALIASES.has(version.family) ||
|
||||
values.has(version.family) ||
|
||||
model.resolvedModel !== model.value ||
|
||||
contextHintOf(model.value) !== null ||
|
||||
canonicalizeModelId(model.value) !== resumed ||
|
||||
versions.some(
|
||||
(other) =>
|
||||
other.family === version.family &&
|
||||
(other.major > version.major ||
|
||||
(other.major === version.major && other.minor > version.minor)),
|
||||
)
|
||||
) {
|
||||
return model;
|
||||
}
|
||||
values.add(version.family);
|
||||
return { ...model, value: version.family };
|
||||
});
|
||||
}
|
||||
|
||||
/** A model id without the spellings that name the same model: provider
|
||||
* prefixes, dates, version suffixes, `:variant` suffixes, dotted versions,
|
||||
* `-fast` and context hints. `deniedModels` and gateway listings both match on
|
||||
|
||||
@@ -30,22 +30,22 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
} as unknown as AcpClient;
|
||||
}
|
||||
|
||||
function mockQuery() {
|
||||
function mockQuery(
|
||||
models: unknown[] = [
|
||||
{
|
||||
value: "claude-sonnet-4-6",
|
||||
displayName: "Claude Sonnet 4.5",
|
||||
description: "Default",
|
||||
supportsAutoMode: true,
|
||||
},
|
||||
],
|
||||
) {
|
||||
let capturedOptions: any;
|
||||
const setModelSpy = vi.fn();
|
||||
querySpy.mockImplementation(({ options }: any) => {
|
||||
capturedOptions = options;
|
||||
return makeMockQuery({
|
||||
initializationResult: async () => ({
|
||||
models: [
|
||||
{
|
||||
value: "claude-sonnet-4-6",
|
||||
displayName: "Claude Sonnet 4.5",
|
||||
description: "Default",
|
||||
supportsAutoMode: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
initializationResult: async () => ({ models }),
|
||||
setModel: setModelSpy,
|
||||
}) as any;
|
||||
});
|
||||
@@ -432,7 +432,7 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
describe("on resume", () => {
|
||||
const sessionId = "resumed-mode-session";
|
||||
|
||||
async function setup(records: unknown[]) {
|
||||
async function setup(records: unknown[], models?: unknown[]) {
|
||||
await fs.promises.writeFile(
|
||||
path.join(tempDir, "settings.json"),
|
||||
JSON.stringify({ permissions: { defaultMode: "plan" } }),
|
||||
@@ -446,11 +446,11 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
records.map((record) => JSON.stringify(record)).join("\n") + "\n",
|
||||
);
|
||||
|
||||
const { getCapturedOptions } = mockQuery();
|
||||
const { getCapturedOptions, setModelSpy } = mockQuery(models);
|
||||
const { ClaudeAcpAgent } = await import("../acp-agent.js");
|
||||
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
|
||||
(agent as any).logger = { log: () => {}, error: () => {} };
|
||||
return { agent, projectDir, getCapturedOptions };
|
||||
return { agent, projectDir, getCapturedOptions, setModelSpy };
|
||||
}
|
||||
|
||||
async function resume(records: unknown[], meta: object = {}) {
|
||||
@@ -479,6 +479,26 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
previousUuid = uuid;
|
||||
return record;
|
||||
};
|
||||
const assistant = (model: string) => {
|
||||
const uuid = crypto.randomUUID();
|
||||
const record = {
|
||||
type: "assistant",
|
||||
uuid,
|
||||
parentUuid: previousUuid,
|
||||
sessionId,
|
||||
timestamp: new Date().toISOString(),
|
||||
isSidechain: false,
|
||||
message: {
|
||||
id: `msg_${uuid}`,
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
model,
|
||||
content: [{ type: "text", text: "ok" }],
|
||||
},
|
||||
};
|
||||
previousUuid = uuid;
|
||||
return record;
|
||||
};
|
||||
|
||||
it("continues in the last permission mode of the transcript", async () => {
|
||||
const { permissionMode, response } = await resume([user("plan"), user("acceptEdits")]);
|
||||
@@ -513,6 +533,41 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
expect(response.modes.currentModeId).toBe("plan");
|
||||
});
|
||||
|
||||
it("offers Fable 5.1 as `fable` when the CLI resumed on it", async () => {
|
||||
// The CLI names the Fable 5.1 row after its startup model, which on
|
||||
// resume is the transcript's model; a fresh session lists it as `fable`.
|
||||
const fable = (value: string, resolvedModel: string, displayName: string) => ({
|
||||
value,
|
||||
resolvedModel,
|
||||
displayName,
|
||||
description: "",
|
||||
});
|
||||
const { agent, projectDir, setModelSpy } = await setup(
|
||||
[user(), assistant("claude-fable-5-1")],
|
||||
[
|
||||
fable("default", "claude-opus-5-5", "Default (recommended)"),
|
||||
fable("claude-fable-5-1", "claude-fable-5-1", "Fable 5.1"),
|
||||
fable("claude-fable-5", "claude-fable-5", "Fable 5"),
|
||||
],
|
||||
);
|
||||
|
||||
const response = await (agent as any).createSession(
|
||||
{ cwd: projectDir, mcpServers: [], _meta: { disableBuiltInTools: true } },
|
||||
{ resume: sessionId },
|
||||
);
|
||||
|
||||
const model = response.configOptions.find((option: any) => option.id === "model");
|
||||
expect(model).toMatchObject({
|
||||
currentValue: "fable",
|
||||
options: [
|
||||
{ value: "default" },
|
||||
{ value: "fable", name: "Fable 5.1" },
|
||||
{ value: "claude-fable-5", name: "Fable 5" },
|
||||
],
|
||||
});
|
||||
expect(setModelSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("continues in the last permission mode on session/load", async () => {
|
||||
const { agent, projectDir, getCapturedOptions } = await setup([
|
||||
user("plan"),
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 {
|
||||
@@ -2000,6 +2053,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 +2115,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 +4091,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 +6107,210 @@ 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("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);
|
||||
@@ -6353,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,
|
||||
@@ -6888,3 +7157,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" });
|
||||
|
||||
@@ -4,6 +4,7 @@ import { query } from "@anthropic-ai/claude-agent-sdk";
|
||||
import type { ModelInfo } from "@anthropic-ai/claude-agent-sdk";
|
||||
import type { SessionConfigOption } from "@agentclientprotocol/sdk/unstable";
|
||||
import {
|
||||
aliasResumedModelRow,
|
||||
resolveModelPreference,
|
||||
applyAvailableModelsAllowlist,
|
||||
isDeniedModel,
|
||||
@@ -196,6 +197,52 @@ describe("matchResumedModel", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("aliasResumedModelRow", () => {
|
||||
const row = (value: string, resolvedModel = value): ModelInfo => ({
|
||||
value,
|
||||
resolvedModel,
|
||||
displayName: value,
|
||||
description: "",
|
||||
});
|
||||
// What the CLI lists after resuming a session on `claude-fable-5-1`.
|
||||
const STARTED_ON_FABLE = [
|
||||
row("default", "claude-opus-5-5"),
|
||||
row("claude-fable-5-1"),
|
||||
row("claude-fable-5"),
|
||||
];
|
||||
|
||||
it("lists the resumed model's row under its family alias", () => {
|
||||
expect(aliasResumedModelRow(STARTED_ON_FABLE, "claude-fable-5-1").map((m) => m.value)).toEqual([
|
||||
"default",
|
||||
"fable",
|
||||
"claude-fable-5",
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
"the alias already has a row",
|
||||
[row("fable", "claude-fable-5-1"), row("claude-fable-5")],
|
||||
"claude-fable-5",
|
||||
],
|
||||
["the row is not the resumed model", STARTED_ON_FABLE, "claude-opus-5-5"],
|
||||
["the session is not resumed", STARTED_ON_FABLE, undefined],
|
||||
[
|
||||
"the family has a newer row",
|
||||
[row("claude-fable-5-1"), row("claude-fable-5")],
|
||||
"claude-fable-5",
|
||||
],
|
||||
[
|
||||
"the row carries a context hint",
|
||||
[row("claude-fable-5-1[1m]", "claude-fable-5-1")],
|
||||
"claude-fable-5-1[1m]",
|
||||
],
|
||||
["the row is not a family model", [row("claude-gpt-9")], "claude-gpt-9"],
|
||||
])("keeps every value when %s", (_case, models, resumedModel) => {
|
||||
expect(aliasResumedModelRow(models, resumedModel)).toEqual(models);
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyAvailableModelsAllowlist - resolvedModel matching", () => {
|
||||
it("resolves an allowlist entry pinned to a canonical resolvedModel id", () => {
|
||||
const result = applyAvailableModelsAllowlist(LIVE_SHAPED_MODELS, ["claude-sonnet-5"]);
|
||||
|
||||
71
src/tests/module-exports.test.ts
Normal file
71
src/tests/module-exports.test.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("module runtime exports", () => {
|
||||
/**
|
||||
* The public runtime exports of lib.ts must change only deliberately; consumers import these names.
|
||||
*/
|
||||
it("lib.ts exports the expected names", async () => {
|
||||
expect(Object.keys(await import("../lib.js")).sort()).toEqual([
|
||||
"AUTH_STATUS_UPDATE_METHOD",
|
||||
"ClaudeAcpAgent",
|
||||
"Pushable",
|
||||
"SettingsManager",
|
||||
"isLocalCommandMetadata",
|
||||
"nodeToWebReadable",
|
||||
"nodeToWebWritable",
|
||||
"planEntries",
|
||||
"runAcp",
|
||||
"streamEventToAcpNotifications",
|
||||
"stripLocalCommandMetadata",
|
||||
"toAcpNotifications",
|
||||
"toDisplayPath",
|
||||
"toolInfoFromToolUse",
|
||||
"toolUpdateFromToolResult",
|
||||
"unreachable",
|
||||
]);
|
||||
});
|
||||
|
||||
/**
|
||||
* Moving code out of acp-agent.ts must keep every name re-exported, since consumers deep-import
|
||||
* dist/acp-agent.js; change this list only deliberately.
|
||||
*/
|
||||
it("acp-agent.ts exports the expected names", async () => {
|
||||
expect(Object.keys(await import("../acp-agent.js")).sort()).toEqual([
|
||||
"AGENT_CONFIG_ID",
|
||||
"BUILTIN_AGENT_NAMES",
|
||||
"ClaudeAcpAgent",
|
||||
"DEFAULT_AGENT_ID",
|
||||
"EFFORT_CONFIG_ID",
|
||||
"FAST_MODE_CONFIG_ID",
|
||||
"FAST_MODE_OFF",
|
||||
"FAST_MODE_ON",
|
||||
"MODEL_CONFIG_ID",
|
||||
"MODE_CONFIG_ID",
|
||||
"applyAvailableModelsAllowlist",
|
||||
"buildConfigOptions",
|
||||
"clientSupportsBooleanConfigOptions",
|
||||
"clientSupportsRecommendedConfigValue",
|
||||
"computeSessionFingerprint",
|
||||
"createFastModeConfigOption",
|
||||
"discoverCustomAgents",
|
||||
"fastModeStateEnabled",
|
||||
"isLocalCommandMetadata",
|
||||
"isLoginRequiredText",
|
||||
"isReplayHiddenMetaMessage",
|
||||
"isSyntheticLoginMessage",
|
||||
"matchResumedModel",
|
||||
"messageIdForGrouping",
|
||||
"normalizeFastModeDisabledReason",
|
||||
"promptToClaude",
|
||||
"resolveFastModeEnabled",
|
||||
"resolveModelPreference",
|
||||
"runAcp",
|
||||
"runPromptWithCancellation",
|
||||
"settingsEffortForModel",
|
||||
"streamEventToAcpNotifications",
|
||||
"stripLocalCommandMetadata",
|
||||
"toAcpNotifications",
|
||||
"v1AgentApp",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
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