mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-11-02 15:31:50 +00:00
Compare commits
2 Commits
v0.99.2-pr
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
981bf9a28a | ||
|
|
4d79b9ffbc |
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "0.99.1"
|
||||
".": "0.100.0"
|
||||
}
|
||||
|
||||
24
CHANGELOG.md
24
CHANGELOG.md
@@ -1,5 +1,29 @@
|
||||
# Changelog
|
||||
|
||||
## [0.100.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.99.1...v0.100.0) (2026-11-02)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* list v2 background tool calls ([#1511](https://github.com/agentclientprotocol/claude-agent-acp/issues/1511)) ([4d79b9f](https://github.com/agentclientprotocol/claude-agent-acp/commit/4d79b9ffbce95c23928a5dd771d6c87476d37160))
|
||||
* report subagent working directories ([#1496](https://github.com/agentclientprotocol/claude-agent-acp/issues/1496)) ([eb78afa](https://github.com/agentclientprotocol/claude-agent-acp/commit/eb78afa8556b8fc4082495e48e3fd1762fb6a50f))
|
||||
* report v2 background work ([#1510](https://github.com/agentclientprotocol/claude-agent-acp/issues/1510)) ([5565259](https://github.com/agentclientprotocol/claude-agent-acp/commit/5565259771cb74e3a9bdbacfcbce43e061d81422))
|
||||
* report why Claude Code could not start a session ([#1508](https://github.com/agentclientprotocol/claude-agent-acp/issues/1508)) ([38cf656](https://github.com/agentclientprotocol/claude-agent-acp/commit/38cf656fc9d80fec2d1a16c472779803e2c802d2))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* align session/cancel, session/close, and v2 setup with the ACP v2 spec ([#1494](https://github.com/agentclientprotocol/claude-agent-acp/issues/1494)) ([f50598a](https://github.com/agentclientprotocol/claude-agent-acp/commit/f50598a39c5dc3908d4b98a7213e01d63ea867d5))
|
||||
* forward v2 elicitation values and read dismissed v2 permission prompts as a refusal ([#1493](https://github.com/agentclientprotocol/claude-agent-acp/issues/1493)) ([3bc6711](https://github.com/agentclientprotocol/claude-agent-acp/commit/3bc671193c6456516fe05a0eaa53c17bdfb6a190))
|
||||
* keep native Windows paths in v2 git patches ([#1492](https://github.com/agentclientprotocol/claude-agent-acp/issues/1492)) ([c3428de](https://github.com/agentclientprotocol/claude-agent-acp/commit/c3428de1bf411c513b0703ddd56bb6c5fdff583c))
|
||||
* preserve live session settings across provider refreshes ([#1474](https://github.com/agentclientprotocol/claude-agent-acp/issues/1474)) ([e01a288](https://github.com/agentclientprotocol/claude-agent-acp/commit/e01a28838499dd6dd7c31e35708090b938e852c6))
|
||||
* reject unreadable skill manifests ([#1482](https://github.com/agentclientprotocol/claude-agent-acp/issues/1482)) ([99a6e76](https://github.com/agentclientprotocol/claude-agent-acp/commit/99a6e76158b9164e51bd62e3b89113beadea5ce2))
|
||||
* report the fresh context's usage when ExitPlanMode clears context ([#1484](https://github.com/agentclientprotocol/claude-agent-acp/issues/1484)) ([2fd46c8](https://github.com/agentclientprotocol/claude-agent-acp/commit/2fd46c866d4ff2939fe1b1245b15a536d6de7c0b))
|
||||
* update to @agentclientprotocol/sdk 2.2.0 ([#1491](https://github.com/agentclientprotocol/claude-agent-acp/issues/1491)) ([1d2b92f](https://github.com/agentclientprotocol/claude-agent-acp/commit/1d2b92feae5eeccde3ac738edca1f1d71250762e))
|
||||
* update to @agentclientprotocol/sdk 2.4.0 ([#1509](https://github.com/agentclientprotocol/claude-agent-acp/issues/1509)) ([720bbb3](https://github.com/agentclientprotocol/claude-agent-acp/commit/720bbb3e4c65874775a0bb9418799baf4b6e3680))
|
||||
* update to Claude Agent SDK 0.3.316 (Claude Code 2.1.316) ([#1507](https://github.com/agentclientprotocol/claude-agent-acp/issues/1507)) ([1d03064](https://github.com/agentclientprotocol/claude-agent-acp/commit/1d0306468b632c6a56b4fb076ad7a19f23d9e7b5))
|
||||
* validate session, prompt, config, and auth requests per the latest ACP spec ([#1495](https://github.com/agentclientprotocol/claude-agent-acp/issues/1495)) ([d2ef490](https://github.com/agentclientprotocol/claude-agent-acp/commit/d2ef490a318033049af00cb9164a39ac2dd1eb95))
|
||||
|
||||
## [0.99.1](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.99.0...v0.99.1) (2026-10-29)
|
||||
|
||||
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@agentclientprotocol/claude-agent-acp",
|
||||
"version": "0.99.1",
|
||||
"version": "0.100.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@agentclientprotocol/claude-agent-acp",
|
||||
"version": "0.99.1",
|
||||
"version": "0.100.0",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "2.4.0",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"version": "0.99.1",
|
||||
"version": "0.100.0",
|
||||
"description": "An ACP-compatible coding agent powered by the Claude Agent SDK (TypeScript)",
|
||||
"main": "dist/lib.js",
|
||||
"types": "dist/lib.d.ts",
|
||||
|
||||
117
src/acp-agent.ts
117
src/acp-agent.ts
@@ -168,11 +168,17 @@ import {
|
||||
backgroundLevel,
|
||||
type BackgroundLevel,
|
||||
type BackgroundSink,
|
||||
backgroundToolCallPause,
|
||||
backgroundToolCallStates,
|
||||
type BackgroundTaskEnd,
|
||||
type BackgroundToolCall,
|
||||
BackgroundWorkReporter,
|
||||
cancelBackgroundTerminal,
|
||||
droppedTerminalTasks,
|
||||
cancelBackgroundWork,
|
||||
droppedTasks,
|
||||
endBackgroundWork,
|
||||
registerBackgroundToolCalls,
|
||||
takeBackgroundTerminalExit,
|
||||
takeBackgroundToolCallEnd,
|
||||
} from "./background-work.js";
|
||||
import {
|
||||
AUTH_STATUS_PROBE_TIMEOUT_MS,
|
||||
@@ -312,7 +318,11 @@ import {
|
||||
unregisterHookCallback,
|
||||
} from "./tools.js";
|
||||
import { previewPatchContent } from "./diff.js";
|
||||
import { backgroundedBashToolCall, withRunningTerminal } from "./tool-calls/background.js";
|
||||
import {
|
||||
backgroundedBashToolCall,
|
||||
withRunningTerminal,
|
||||
withRunningToolCall,
|
||||
} from "./tool-calls/background.js";
|
||||
import { ChangedMetaFilter } from "./tool-calls/changed-meta-filter.js";
|
||||
import { ToolCallFieldTracker } from "./tool-calls/field-tracker.js";
|
||||
import { ClientCapabilities as ToolCallClientCapabilities } from "./tool-calls/client-capabilities.js";
|
||||
@@ -1465,7 +1475,9 @@ export type Session = {
|
||||
* `perTaskStopAffordance`: then it spares both, and the client is expected
|
||||
* to stop each one itself. A client that can cancel one subagent gets the
|
||||
* declaration, but it has no control for a workflow unless it is AIR with
|
||||
* `asyncTasks` (`_session/async_task/stop`). So for such a client the
|
||||
* `asyncTasks` (`_session/async_task/stop`), or gets the session's
|
||||
* background work (`reportsBackground`, with `tool_call/cancel`), which
|
||||
* `session/cancel` must leave running. So for any other such client the
|
||||
* adapter stops the workflows after the interrupt, as the interrupt did
|
||||
* before. */
|
||||
stopsWorkflowsOnCancel?: boolean;
|
||||
@@ -1476,6 +1488,11 @@ export type Session = {
|
||||
* `withRunningTerminal`). Only for a client that renders terminals and is
|
||||
* not AIR, which shows the command through its async tasks. */
|
||||
backgroundTerminals?: Map<string, { toolCallId: string; parentToolUseId?: string }>;
|
||||
/** The tool calls whose tasks went on in the background and still run, by
|
||||
* task id: a Monitor, a Workflow, or a backgrounded MCP call. The tool call
|
||||
* stays `in_progress` (or `paused`) after its result, until its task ends
|
||||
* (see `withRunningToolCall`). Only while `reportsBackground`. */
|
||||
backgroundToolCalls?: Map<string, BackgroundToolCall>;
|
||||
/** Whether the client gets the session's background work (see
|
||||
* `background-work.ts`). Only when Claude Code's interrupt spares
|
||||
* background tasks, as `perTaskStopAffordance` makes it. */
|
||||
@@ -5400,11 +5417,15 @@ export class ClaudeAcpAgent {
|
||||
this.syncAwaitingUser(params.sessionId, session);
|
||||
};
|
||||
|
||||
/** Ends the display terminal of a command that went to the background, once
|
||||
* its task ends (see `Session.backgroundTerminals`). */
|
||||
const endBackgroundTerminal = async (taskId: string) => {
|
||||
/** Ends the display terminal of a command that went to the background, and
|
||||
* gives a tool call whose task went on in the background its final
|
||||
* status, once the task ends (see `Session.backgroundTerminals` and
|
||||
* `Session.backgroundToolCalls`). */
|
||||
const endBackgroundTask = async (taskId: string, end: BackgroundTaskEnd) => {
|
||||
const exit = takeBackgroundTerminalExit(session, params.sessionId, taskId);
|
||||
if (exit) await sendUpdate(exit);
|
||||
const toolCallEnd = takeBackgroundToolCallEnd(session, params.sessionId, taskId, end);
|
||||
if (toolCallEnd) await sendUpdate(toolCallEnd);
|
||||
};
|
||||
|
||||
/** Settles a task. Its request may still be open, and an agent without a
|
||||
@@ -6856,7 +6877,7 @@ export class ClaudeAcpAgent {
|
||||
streamedBlocksByParent.delete(message.tool_use_id);
|
||||
droppedStreams.delete(message.tool_use_id);
|
||||
}
|
||||
await endBackgroundTerminal(message.task_id);
|
||||
await endBackgroundTask(message.task_id, message.status);
|
||||
if (message.status === "stopped") {
|
||||
await closeStoppedSubagentToolCalls(message.tool_use_id);
|
||||
}
|
||||
@@ -6901,15 +6922,33 @@ export class ClaudeAcpAgent {
|
||||
streamedBlocksByParent.delete(parentToolUseId);
|
||||
droppedStreams.delete(parentToolUseId);
|
||||
}
|
||||
await endBackgroundTerminal(message.task_id);
|
||||
await endBackgroundTask(
|
||||
message.task_id,
|
||||
message.patch.status === "killed" ? "stopped" : message.patch.status,
|
||||
);
|
||||
if (message.patch.status === "killed") {
|
||||
await closeStoppedSubagentToolCalls(parentToolUseId);
|
||||
}
|
||||
settleLiveTask(message.task_id);
|
||||
} else if (message.patch.status === "paused") {
|
||||
const pause = backgroundToolCallPause(
|
||||
session,
|
||||
params.sessionId,
|
||||
message.task_id,
|
||||
true,
|
||||
);
|
||||
if (pause) await sendUpdate(pause);
|
||||
} else if (
|
||||
message.patch.status === "running" ||
|
||||
message.patch.status === "pending"
|
||||
) {
|
||||
const resume = backgroundToolCallPause(
|
||||
session,
|
||||
params.sessionId,
|
||||
message.task_id,
|
||||
false,
|
||||
);
|
||||
if (resume) await sendUpdate(resume);
|
||||
// The SDK can resume a finished subagent under the same
|
||||
// agent id without a new task_started.
|
||||
resumeLiveTask(message.task_id);
|
||||
@@ -7081,8 +7120,8 @@ export class ClaudeAcpAgent {
|
||||
await asyncTasks.backgroundTasksChanged(message.tasks);
|
||||
if (session.reportsBackground) {
|
||||
const level = backgroundLevel(message.tasks);
|
||||
for (const taskId of droppedTerminalTasks(session, level)) {
|
||||
await endBackgroundTerminal(taskId);
|
||||
for (const taskId of droppedTasks(session, level)) {
|
||||
await endBackgroundTask(taskId, undefined);
|
||||
}
|
||||
session.backgroundLevel = level;
|
||||
}
|
||||
@@ -8488,6 +8527,7 @@ export class ClaudeAcpAgent {
|
||||
const acceptedPlanToolUseId = observeExitPlanToolResults(message, content, session);
|
||||
let backgroundedToolCalls: ReadonlySet<string> = new Set();
|
||||
let runningTerminal: string | undefined;
|
||||
let runningToolCalls: ReadonlySet<string> = new Set();
|
||||
if (message.type === "user") {
|
||||
rememberResolvedToolNames(session, content);
|
||||
recordSubagentFileEdits(
|
||||
@@ -8520,6 +8560,13 @@ export class ClaudeAcpAgent {
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
runningToolCalls = registerBackgroundToolCalls(
|
||||
session,
|
||||
content,
|
||||
toolUseResult,
|
||||
session.toolUseCache,
|
||||
message.parent_tool_use_id,
|
||||
);
|
||||
await asyncTasks.toolResults(content);
|
||||
backgroundedToolCalls = backgroundedBashToolCallIds(
|
||||
content,
|
||||
@@ -8582,13 +8629,16 @@ export class ClaudeAcpAgent {
|
||||
// (e.g. a subagent image) carry the stamped parentToolUseId
|
||||
// meta and are excluded there.
|
||||
await sendUpdate(
|
||||
withRunningTerminal(
|
||||
backgroundedBashToolCall(
|
||||
acceptedPlanToolResult(notification, acceptedPlanToolUseId),
|
||||
backgroundedToolCalls,
|
||||
asyncTasks.enabled,
|
||||
withRunningToolCall(
|
||||
withRunningTerminal(
|
||||
backgroundedBashToolCall(
|
||||
acceptedPlanToolResult(notification, acceptedPlanToolUseId),
|
||||
backgroundedToolCalls,
|
||||
asyncTasks.enabled,
|
||||
),
|
||||
runningTerminal,
|
||||
),
|
||||
runningTerminal,
|
||||
runningToolCalls,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -9417,9 +9467,37 @@ export class ClaudeAcpAgent {
|
||||
}
|
||||
|
||||
/** Stops a command running in the background (v2 `terminal/cancel`, see
|
||||
* {@link cancelBackgroundTerminal}). */
|
||||
* {@link cancelBackgroundWork}). */
|
||||
cancelBackgroundTerminal(params: { sessionId: string; terminalId: string }): Promise<void> {
|
||||
return cancelBackgroundTerminal(this.sessions, params.sessionId, params.terminalId);
|
||||
return cancelBackgroundWork(this.sessions, params.sessionId, {
|
||||
terminalId: params.terminalId,
|
||||
});
|
||||
}
|
||||
|
||||
/** Stops the task of a tool call running in the background (v2
|
||||
* `tool_call/cancel`, see {@link cancelBackgroundWork}). */
|
||||
cancelBackgroundToolCall(params: { sessionId: string; toolCallId: string }): Promise<void> {
|
||||
return cancelBackgroundWork(this.sessions, params.sessionId, {
|
||||
toolCallId: params.toolCallId,
|
||||
});
|
||||
}
|
||||
|
||||
/** Sends the current state of each tool call in `toolCallIds` that the root
|
||||
* session `sessionId` runs in the background: the call as its tool use
|
||||
* shows it, with its status now. v2 requires it for each tool call that a
|
||||
* `session/resume` snapshot lists. A failure is logged, as for
|
||||
* {@link publishLiveSubagents}. */
|
||||
async publishBackgroundToolCalls(sessionId: string, toolCallIds: string[]): Promise<void> {
|
||||
const session = this.sessions[sessionId];
|
||||
if (!session) return;
|
||||
const renderer = new AcpToolCallRenderer(this.toolCallCapabilities);
|
||||
try {
|
||||
for (const update of backgroundToolCallStates(session, sessionId, toolCallIds, renderer)) {
|
||||
await this.client.sessionUpdate(update);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(`Session ${sessionId}: failed to send background tool calls:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/** Sends the current `subagent_update` of each live subagent session in
|
||||
@@ -12651,6 +12729,7 @@ export class ClaudeAcpAgent {
|
||||
reportsBackground: this.backgroundWork.enabled && options.perTaskStopAffordance === true,
|
||||
stopsWorkflowsOnCancel:
|
||||
options.perTaskStopAffordance === true &&
|
||||
!this.backgroundWork.enabled &&
|
||||
!clientSupportsAsyncTasks(this.clientCapabilities),
|
||||
nativeSubagentsByTaskId: new Map(),
|
||||
nativeSubagentTaskIdByToolUseId: new Map(),
|
||||
|
||||
@@ -14,9 +14,13 @@
|
||||
* Bash tool result until its task ends (`Session.backgroundTerminals`).
|
||||
* - A detached subagent: its child session, in its parent's list, while it
|
||||
* works, and while it is idle with background work of its own.
|
||||
* - Any other task, such as a `Monitor` or a `Workflow`: a `_claude/task`
|
||||
* item with the task's id and description. Its tool call ended at its tool
|
||||
* result, so it cannot be listed as a tool call.
|
||||
* - A task that a tool call started, such as a `Monitor`, a `Workflow` or a
|
||||
* backgrounded MCP call: that tool call, kept `in_progress` (or `paused`)
|
||||
* from its tool result until its task ends (`Session.backgroundToolCalls`).
|
||||
* - Any other task, once its `task_started` came and the tool call that
|
||||
* started it, if any, returned: a `_claude/task` item with the task's id
|
||||
* and description. For example, a task whose tool call ended before the
|
||||
* task joined the level.
|
||||
*
|
||||
* Only a session whose interrupt spares background tasks reports its lists
|
||||
* (`Session.reportsBackground`): `session/cancel` must leave listed work
|
||||
@@ -25,13 +29,28 @@
|
||||
import { RequestError } 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";
|
||||
|
||||
/** An item of a `background` list. */
|
||||
export type BackgroundItem =
|
||||
| { type: "terminal"; terminalId: string }
|
||||
| { type: "tool_call"; toolCallId: string }
|
||||
| { type: "subagent"; sessionId: string }
|
||||
| { type: "_claude/task"; taskId: string; description: string };
|
||||
|
||||
/** A tool call whose task runs on in the background (see `Session.backgroundToolCalls`). */
|
||||
export interface BackgroundToolCall {
|
||||
toolCallId: string;
|
||||
/** The tool use of the call, which Claude Code no longer caches after its result. */
|
||||
toolUse: { name: string; input: unknown };
|
||||
/** The subagent tool call that made the call, if any. */
|
||||
parentToolUseId?: string;
|
||||
paused?: boolean;
|
||||
}
|
||||
|
||||
/** How a background task ended, as Claude Code reports it. Undefined when it did not say. */
|
||||
export type BackgroundTaskEnd = "completed" | "failed" | "stopped" | undefined;
|
||||
|
||||
/** The live background task set of `background_tasks_changed`, by task id. */
|
||||
export type BackgroundLevel = Map<
|
||||
string,
|
||||
@@ -64,10 +83,77 @@ export interface BackgroundWorkSession {
|
||||
reportsBackground?: boolean;
|
||||
backgroundLevel?: BackgroundLevel;
|
||||
backgroundTerminals?: Map<string, { toolCallId: string; parentToolUseId?: string }>;
|
||||
backgroundToolCalls?: Map<string, BackgroundToolCall>;
|
||||
nativeSubagentsByTaskId?: Map<string, NativeSubagent>;
|
||||
liveBackgroundTasks?: Map<string, { parentToolUseId?: string; isSubagent: boolean }>;
|
||||
/** The tool uses whose result has not come yet, by id. */
|
||||
toolUseCache?: Record<string, unknown>;
|
||||
query: { stopTask(taskId: string): Promise<void> };
|
||||
}
|
||||
|
||||
/** The tools whose structured result names the task that the call started. */
|
||||
const TASK_TOOLS = new Set(["Monitor", "Workflow"]);
|
||||
|
||||
/**
|
||||
* Registers each tool call of a tool result message whose task goes on in
|
||||
* the background: a `Monitor` or `Workflow` call, whose structured result
|
||||
* names its task, or a call that a live task names as its tool use, such as
|
||||
* a backgrounded MCP call. The task must be in the level: one that ended
|
||||
* already, or that Claude Code marks `ambient`, leaves its call as it is.
|
||||
* Returns the ids of the calls, whose result then reports `in_progress` (see
|
||||
* `withRunningToolCall`).
|
||||
*/
|
||||
export function registerBackgroundToolCalls(
|
||||
session: BackgroundWorkSession,
|
||||
content: unknown,
|
||||
toolUseResult: unknown,
|
||||
toolUses: Record<string, { name: string; input: unknown }>,
|
||||
parentToolUseId: string | null,
|
||||
): Set<string> {
|
||||
const registered = new Set<string>();
|
||||
if (!session.reportsBackground || !Array.isArray(content)) return registered;
|
||||
const results = content.filter(
|
||||
(block): block is { type: "tool_result"; tool_use_id: string; is_error?: boolean } =>
|
||||
typeof block === "object" &&
|
||||
block !== null &&
|
||||
block.type === "tool_result" &&
|
||||
typeof block.tool_use_id === "string" &&
|
||||
block.is_error !== true,
|
||||
);
|
||||
const structuredTaskId =
|
||||
results.length === 1 &&
|
||||
typeof toolUseResult === "object" &&
|
||||
toolUseResult !== null &&
|
||||
typeof (toolUseResult as { taskId?: unknown }).taskId === "string"
|
||||
? (toolUseResult as { taskId: string }).taskId
|
||||
: undefined;
|
||||
for (const { tool_use_id: toolCallId } of results) {
|
||||
const toolUse = toolUses[toolCallId];
|
||||
if (!toolUse) continue;
|
||||
const taskId =
|
||||
(TASK_TOOLS.has(toolUse.name) ? structuredTaskId : undefined) ??
|
||||
[...(session.liveBackgroundTasks ?? [])].find(
|
||||
([, task]) => task.parentToolUseId === toolCallId && !task.isSubagent,
|
||||
)?.[0];
|
||||
const task = taskId === undefined ? undefined : session.backgroundLevel?.get(taskId);
|
||||
if (
|
||||
!task ||
|
||||
task.ambient ||
|
||||
task.taskType === "local_bash" ||
|
||||
task.taskType === "local_agent"
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
(session.backgroundToolCalls ??= new Map()).set(taskId!, {
|
||||
toolCallId,
|
||||
toolUse,
|
||||
...(parentToolUseId ? { parentToolUseId } : {}),
|
||||
});
|
||||
registered.add(toolCallId);
|
||||
}
|
||||
return registered;
|
||||
}
|
||||
|
||||
/** The level of a `background_tasks_changed` payload. */
|
||||
export function backgroundLevel(
|
||||
tasks: {
|
||||
@@ -118,18 +204,32 @@ export function backgroundLists(
|
||||
const level: BackgroundLevel = session.backgroundLevel ?? new Map();
|
||||
for (const [taskId, task] of level) {
|
||||
if (task.ambient) continue;
|
||||
// A subagent's command or tool call runs on the child's stream.
|
||||
const ownerOf = (parentToolUseId: string | undefined) =>
|
||||
parentToolUseId ? childByToolUse.get(parentToolUseId)?.sessionId : rootSessionId;
|
||||
const terminal = session.backgroundTerminals?.get(taskId);
|
||||
if (terminal) {
|
||||
// A subagent's command runs on the child's stream.
|
||||
const owner = terminal.parentToolUseId
|
||||
? childByToolUse.get(terminal.parentToolUseId)?.sessionId
|
||||
: rootSessionId;
|
||||
const owner = ownerOf(terminal.parentToolUseId);
|
||||
if (owner) lists.get(owner)!.push({ type: "terminal", terminalId: terminal.toolCallId });
|
||||
continue;
|
||||
}
|
||||
const toolCall = session.backgroundToolCalls?.get(taskId);
|
||||
if (toolCall) {
|
||||
const owner = ownerOf(toolCall.parentToolUseId);
|
||||
if (owner) lists.get(owner)!.push({ type: "tool_call", toolCallId: toolCall.toolCallId });
|
||||
continue;
|
||||
}
|
||||
// A command without a terminal yet is one that foreground work waits on.
|
||||
// A subagent is listed below, as its session.
|
||||
if (task.taskType === "local_bash" || task.taskType === "local_agent") continue;
|
||||
// A task joins the level before its `task_started`, and before the tool
|
||||
// call that starts it returns. Until both, the task is unknown or part of
|
||||
// that tool call, which the foreground work waits on. Claude Code drops a
|
||||
// tool use from its cache at the tool's result.
|
||||
const live = session.liveBackgroundTasks?.get(taskId);
|
||||
if (!live || (live.parentToolUseId && session.toolUseCache?.[live.parentToolUseId])) {
|
||||
continue;
|
||||
}
|
||||
const parent = task.parentTaskId ? childByTask.get(task.parentTaskId) : undefined;
|
||||
lists
|
||||
.get(parent?.sessionId ?? rootSessionId)!
|
||||
@@ -166,18 +266,18 @@ export function backgroundLists(
|
||||
}
|
||||
|
||||
/**
|
||||
* The tasks of the terminals that `level` does not hold: Claude Code no
|
||||
* longer runs them, so their terminals exit before a list drops them, as v2
|
||||
* requires. A terminal is registered at its Bash result, after its task
|
||||
* joined the level, so a later level without the task means that it ended.
|
||||
* Claude Code usually sends a task's end before that level, which ends the
|
||||
* terminal first, but the SDK does not promise that order.
|
||||
* The tasks of the terminals and tool calls that `level` does not hold:
|
||||
* Claude Code no longer runs them, so they end before a list drops them, as
|
||||
* v2 requires. A terminal or tool call is registered at its tool result,
|
||||
* after its task joined the level, so a later level without the task means
|
||||
* that it ended. Claude Code usually sends a task's end before that level,
|
||||
* which ends them first, but the SDK does not promise that order.
|
||||
*/
|
||||
export function droppedTerminalTasks(
|
||||
session: BackgroundWorkSession,
|
||||
level: BackgroundLevel,
|
||||
): string[] {
|
||||
return [...(session.backgroundTerminals?.keys() ?? [])].filter((taskId) => !level.has(taskId));
|
||||
export function droppedTasks(session: BackgroundWorkSession, level: BackgroundLevel): string[] {
|
||||
return [
|
||||
...(session.backgroundTerminals?.keys() ?? []),
|
||||
...(session.backgroundToolCalls?.keys() ?? []),
|
||||
].filter((taskId) => !level.has(taskId));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -210,12 +310,96 @@ export function takeBackgroundTerminalExit(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the background tool call of the task `taskId` of the session
|
||||
* `sessionId`, and returns the update that gives it its final status, or
|
||||
* undefined when the task has no such call. A task that Claude Code stopped
|
||||
* is `cancelled`. One that ended without saying how, such as one that the
|
||||
* level dropped, is `completed`: it no longer runs, and reported no failure.
|
||||
*/
|
||||
export function takeBackgroundToolCallEnd(
|
||||
session: BackgroundWorkSession,
|
||||
sessionId: string,
|
||||
taskId: string,
|
||||
end: BackgroundTaskEnd,
|
||||
): AcpSessionNotification | undefined {
|
||||
const toolCall = session.backgroundToolCalls?.get(taskId);
|
||||
if (!toolCall) return undefined;
|
||||
session.backgroundToolCalls!.delete(taskId);
|
||||
return toolCallStatusUpdate(
|
||||
sessionId,
|
||||
toolCall,
|
||||
end === "stopped" ? V2_CANCELLED : end === "failed" ? "failed" : "completed",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The update that reports the background tool call of the task `taskId` as
|
||||
* `paused`, or as `in_progress` again, when `paused` changes it. Claude Code
|
||||
* pauses a workflow this way.
|
||||
*/
|
||||
export function backgroundToolCallPause(
|
||||
session: BackgroundWorkSession,
|
||||
sessionId: string,
|
||||
taskId: string,
|
||||
paused: boolean,
|
||||
): AcpSessionNotification | undefined {
|
||||
const toolCall = session.backgroundToolCalls?.get(taskId);
|
||||
if (!toolCall || (toolCall.paused ?? false) === paused) return undefined;
|
||||
toolCall.paused = paused;
|
||||
return toolCallStatusUpdate(sessionId, toolCall, paused ? V2_PAUSED : "in_progress");
|
||||
}
|
||||
|
||||
/**
|
||||
* The current state of each background tool call in `toolCallIds` of the
|
||||
* root session `sessionId`: the call as `renderer` shows its tool use, with
|
||||
* its status now (see `ClaudeAcpAgent.publishBackgroundToolCalls`).
|
||||
*/
|
||||
export function backgroundToolCallStates(
|
||||
session: BackgroundWorkSession & { cwd: string },
|
||||
sessionId: string,
|
||||
toolCallIds: string[],
|
||||
renderer: AcpToolCallRenderer,
|
||||
): AcpSessionNotification[] {
|
||||
return [...(session.backgroundToolCalls?.values() ?? [])]
|
||||
.filter((toolCall) => toolCallIds.includes(toolCall.toolCallId))
|
||||
.map((toolCall) => {
|
||||
const update = renderer.toolCall(
|
||||
{ id: toolCall.toolCallId, ...toolCall.toolUse },
|
||||
{ cwd: session.cwd },
|
||||
);
|
||||
const status = toolCall.paused ? V2_PAUSED : "in_progress";
|
||||
return { sessionId, update: { ...update, status } as AcpSessionNotification["update"] };
|
||||
});
|
||||
}
|
||||
|
||||
function toolCallStatusUpdate(
|
||||
sessionId: string,
|
||||
toolCall: BackgroundToolCall,
|
||||
status: NonNullable<
|
||||
Extract<AcpSessionNotification["update"], { sessionUpdate: "tool_call_update" }>["status"]
|
||||
>,
|
||||
): AcpSessionNotification {
|
||||
return {
|
||||
sessionId,
|
||||
update: {
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId: toolCall.toolCallId,
|
||||
status,
|
||||
...(toolCall.parentToolUseId
|
||||
? { _meta: { claudeCode: { parentToolUseId: toolCall.parentToolUseId } } }
|
||||
: {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Ends the background work of a session whose query closed: Claude Code's
|
||||
* background tasks end with its process. Returns the updates that exit the
|
||||
* root session's terminals, and empties the level, so the next lists are
|
||||
* empty. A subagent's command is left as it was: its terminal is on the
|
||||
* child's stream, which the closed query's routing owned.
|
||||
* root session's terminals and cancel its tool calls, and empties the level,
|
||||
* so the next lists are empty. A subagent's command or tool call is left as
|
||||
* it was: it is on the child's stream, which the closed query's routing
|
||||
* owned.
|
||||
*/
|
||||
export function endBackgroundWork(
|
||||
session: BackgroundWorkSession,
|
||||
@@ -228,42 +412,56 @@ export function endBackgroundWork(
|
||||
: takeBackgroundTerminalExit(session, sessionId, taskId);
|
||||
return exit ? [exit] : [];
|
||||
});
|
||||
const ends = [...(session.backgroundToolCalls ?? [])].flatMap(([taskId, toolCall]) => {
|
||||
const end = toolCall.parentToolUseId
|
||||
? undefined
|
||||
: takeBackgroundToolCallEnd(session, sessionId, taskId, "stopped");
|
||||
return end ? [end] : [];
|
||||
});
|
||||
session.backgroundLevel = undefined;
|
||||
return exits;
|
||||
return [...exits, ...ends];
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops the background command whose terminal `terminalId` the session
|
||||
* `sessionId` lists (v2 `terminal/cancel`). `sessions` are the root sessions,
|
||||
* by id: a subagent session's list is built with its root's. The terminal
|
||||
* exits when Claude Code reports the task stopped.
|
||||
* Stops the task of a terminal or tool call that the session `sessionId`
|
||||
* lists (v2 `terminal/cancel` and `tool_call/cancel`). `sessions` are the
|
||||
* root sessions, by id: a subagent session's list is built with its root's.
|
||||
* The terminal exits, or the tool call gets its final status, when Claude
|
||||
* Code reports the task stopped.
|
||||
*/
|
||||
export async function cancelBackgroundTerminal(
|
||||
export async function cancelBackgroundWork(
|
||||
sessions: Record<string, BackgroundWorkSession>,
|
||||
sessionId: string,
|
||||
terminalId: string,
|
||||
target: { terminalId: string } | { toolCallId: string },
|
||||
): Promise<void> {
|
||||
const notCancellable = () =>
|
||||
RequestError.invalidParams(
|
||||
{ sessionId, terminalId },
|
||||
`Terminal ${terminalId} is not a command running in the background, so it cannot be cancelled.`,
|
||||
);
|
||||
"terminalId" in target
|
||||
? RequestError.invalidParams(
|
||||
{ sessionId, terminalId: target.terminalId },
|
||||
`Terminal ${target.terminalId} is not a command running in the background, so it cannot be cancelled.`,
|
||||
)
|
||||
: RequestError.invalidParams(
|
||||
{ sessionId, toolCallId: target.toolCallId },
|
||||
`Tool call ${target.toolCallId} is not running in the background, so it cannot be cancelled.`,
|
||||
);
|
||||
const isTarget = (item: BackgroundItem) =>
|
||||
"terminalId" in target
|
||||
? item.type === "terminal" && item.terminalId === target.terminalId
|
||||
: item.type === "tool_call" && item.toolCallId === target.toolCallId;
|
||||
const listed = (root: string, session: BackgroundWorkSession) =>
|
||||
session.reportsBackground === true &&
|
||||
(backgroundLists(session, root)
|
||||
.get(sessionId)
|
||||
?.some((item) => item.type === "terminal" && item.terminalId === terminalId) ??
|
||||
false);
|
||||
(backgroundLists(session, root).get(sessionId)?.some(isTarget) ?? false);
|
||||
const owner = Object.entries(sessions).find(([root, session]) => listed(root, session));
|
||||
if (!owner) throw notCancellable();
|
||||
const [root, session] = owner;
|
||||
const taskId = [...(session.backgroundTerminals ?? [])].find(
|
||||
([, terminal]) => terminal.toolCallId === terminalId,
|
||||
)![0];
|
||||
const id = "terminalId" in target ? target.terminalId : target.toolCallId;
|
||||
const taskId = [
|
||||
...(("terminalId" in target ? session.backgroundTerminals : session.backgroundToolCalls) ?? []),
|
||||
].find(([, entry]) => entry.toolCallId === id)![0];
|
||||
try {
|
||||
await session.query.stopTask(taskId);
|
||||
} catch (error) {
|
||||
// The command ended meanwhile: it has exited, or is about to.
|
||||
// The task ended meanwhile: it has ended on the client too, or is about to.
|
||||
if (!listed(root, session)) throw notCancellable();
|
||||
throw error;
|
||||
}
|
||||
|
||||
@@ -5519,7 +5519,11 @@ describe("ACP v2 terminal of a command in the background", () => {
|
||||
"stopReason" in update && update.stopReason ? ` ${update.stopReason}` : "";
|
||||
const { background } = update as { background?: v2.BackgroundWork[] | null };
|
||||
const items = (background ?? []).map((item) =>
|
||||
"terminalId" in item ? String(item.terminalId) : String(item.type),
|
||||
"terminalId" in item
|
||||
? String(item.terminalId)
|
||||
: "toolCallId" in item
|
||||
? String(item.toolCallId)
|
||||
: String(item.type),
|
||||
);
|
||||
return [`${update.state}${stopReason} [${items.join(",")}]`];
|
||||
});
|
||||
@@ -5696,23 +5700,234 @@ describe("ACP v2 terminal of a command in the background", () => {
|
||||
expect(rejected).toMatchObject({ code: -32602 });
|
||||
});
|
||||
|
||||
it("lists another task, such as a Monitor, as a _claude/task item", async () => {
|
||||
scriptTurns(async function* (options) {
|
||||
yield level(options, [
|
||||
{ task_id: "mon1", task_type: "local_monitor", description: "Watch the logs" },
|
||||
]);
|
||||
yield result(options);
|
||||
yield idleState(options);
|
||||
});
|
||||
const client = await promptUntil("idle end_turn [_claude/task]");
|
||||
describe("a task that a tool call started", () => {
|
||||
const monitor = {
|
||||
task_id: "mon1",
|
||||
task_type: "local_monitor",
|
||||
description: "Watch the logs",
|
||||
};
|
||||
const workflow = { task_id: "wf1", task_type: "local_workflow", description: "Review" };
|
||||
/** A Monitor call whose task `mon1` runs on after the call returns. The
|
||||
* tool starts the task, which joins the level before its
|
||||
* `task_started`, and returns. */
|
||||
async function* monitorStarts(options: Options) {
|
||||
yield toolUseMessage(options, "toolu_mon", "Monitor", { command: "tail -f app.log" });
|
||||
yield level(options, [monitor]);
|
||||
yield taskEvent(options, "task_started", { ...monitor, tool_use_id: "toolu_mon" });
|
||||
yield {
|
||||
...toolResultMessage(options, "toolu_mon", { content: "Monitor started (task mon1)." }),
|
||||
tool_use_result: { taskId: "mon1", timeoutMs: 0, persistent: true },
|
||||
};
|
||||
}
|
||||
const monitorEvent = (options: Options, subtype: string, fields: Record<string, unknown>) =>
|
||||
taskEvent(options, subtype, { ...fields, task_id: "mon1" });
|
||||
|
||||
expect(backgroundTrace(client)).toEqual([
|
||||
"running []",
|
||||
"running [_claude/task]",
|
||||
"idle end_turn [_claude/task]",
|
||||
]);
|
||||
expect(client.updates("state_update").at(-1)).toMatchObject({
|
||||
background: [{ type: "_claude/task", taskId: "mon1", description: "Watch the logs" }],
|
||||
/** The states with their lists, and the Monitor call's statuses and capability. */
|
||||
const toolCallTrace = (client: ReturnType<typeof v2Client>) =>
|
||||
client.sessionUpdates.flatMap(({ update }) => {
|
||||
if (update.sessionUpdate === "tool_call_update" && update.toolCallId === "toolu_mon") {
|
||||
const capabilities = update.capabilities as { cancel?: unknown } | null | undefined;
|
||||
return [
|
||||
...(update.status ? [String(update.status)] : []),
|
||||
...(capabilities === null ? ["no cancel"] : capabilities?.cancel ? ["cancel"] : []),
|
||||
];
|
||||
}
|
||||
if (update.sessionUpdate !== "state_update") return [];
|
||||
const stopReason =
|
||||
"stopReason" in update && update.stopReason ? ` ${update.stopReason}` : "";
|
||||
const { background } = update as { background?: v2.BackgroundWork[] | null };
|
||||
const items = (background ?? []).map((item) =>
|
||||
"toolCallId" in item ? String(item.toolCallId) : String(item.type),
|
||||
);
|
||||
return [`${update.state}${stopReason} [${items.join(",")}]`];
|
||||
});
|
||||
|
||||
it("keeps the call in_progress and listed until its task ends, and paused while the task is", async () => {
|
||||
scriptTurns(async function* (options) {
|
||||
yield* monitorStarts(options);
|
||||
yield result(options);
|
||||
yield idleState(options);
|
||||
yield monitorEvent(options, "task_updated", { patch: { status: "paused" } });
|
||||
yield monitorEvent(options, "task_updated", { patch: { status: "running" } });
|
||||
yield monitorEvent(options, "task_notification", {
|
||||
tool_use_id: "toolu_mon",
|
||||
status: "completed",
|
||||
output_file: "/tmp/tasks/mon1.output",
|
||||
summary: "Done",
|
||||
});
|
||||
yield level(options, []);
|
||||
});
|
||||
const client = await promptUntil("idle end_turn [toolu_mon]");
|
||||
await vi.waitFor(() => expect(toolCallTrace(client)).toContain("idle []"));
|
||||
|
||||
// The task is part of the call until the call returns, so nothing is
|
||||
// listed before. The call can be cancelled before a list names it, and
|
||||
// no longer once it ends, before the list drops it.
|
||||
expect(toolCallTrace(client)).toEqual([
|
||||
"running []",
|
||||
"pending",
|
||||
"in_progress",
|
||||
"cancel",
|
||||
"running [toolu_mon]",
|
||||
"idle end_turn [toolu_mon]",
|
||||
"paused",
|
||||
"in_progress",
|
||||
"completed",
|
||||
"no cancel",
|
||||
"idle []",
|
||||
]);
|
||||
});
|
||||
|
||||
it("stops the task on tool_call/cancel, and reports the call cancelled", async () => {
|
||||
const stopped = Promise.withResolvers<string>();
|
||||
sdk.stopTask = async (taskId) => stopped.resolve(taskId);
|
||||
scriptTurns(async function* (options) {
|
||||
yield* monitorStarts(options);
|
||||
yield result(options);
|
||||
yield idleState(options);
|
||||
await stopped.promise;
|
||||
// Claude Code reports a stopped task as killed first.
|
||||
yield monitorEvent(options, "task_updated", { patch: { status: "killed" } });
|
||||
yield monitorEvent(options, "task_notification", {
|
||||
tool_use_id: "toolu_mon",
|
||||
status: "stopped",
|
||||
output_file: "/tmp/tasks/mon1.output",
|
||||
summary: "Stopped",
|
||||
});
|
||||
yield level(options, []);
|
||||
});
|
||||
let rejected: unknown;
|
||||
const client = await promptUntil(
|
||||
"idle end_turn [toolu_mon]",
|
||||
async (agent, sessionId, client) => {
|
||||
await expect(
|
||||
agent.request(v2.methods.agent.toolCall.cancel, {
|
||||
sessionId,
|
||||
toolCallId: "toolu_mon",
|
||||
}),
|
||||
).resolves.toEqual({});
|
||||
expect(await stopped.promise).toBe("mon1");
|
||||
await vi.waitFor(() => expect(toolCallTrace(client)).toContain("idle []"));
|
||||
rejected = await agent
|
||||
.request(v2.methods.agent.toolCall.cancel, { sessionId, toolCallId: "toolu_mon" })
|
||||
.catch((error: unknown) => error);
|
||||
},
|
||||
);
|
||||
|
||||
expect(toolCallTrace(client).slice(-3)).toEqual(["cancelled", "no cancel", "idle []"]);
|
||||
expect(rejected).toMatchObject({ code: -32602 });
|
||||
});
|
||||
|
||||
it("cancels the call when the query ends, as its task ends with it", async () => {
|
||||
scriptTurns(async function* (options) {
|
||||
yield* monitorStarts(options);
|
||||
yield result(options);
|
||||
yield idleState(options);
|
||||
throw new Error("The Claude Code process exited");
|
||||
});
|
||||
const client = await promptUntil("idle []");
|
||||
|
||||
expect(toolCallTrace(client).slice(-4)).toEqual([
|
||||
"idle end_turn [toolu_mon]",
|
||||
"cancelled",
|
||||
"no cancel",
|
||||
"idle []",
|
||||
]);
|
||||
});
|
||||
|
||||
it("leaves a listed Workflow running on session/cancel", async () => {
|
||||
const interrupted = Promise.withResolvers<void>();
|
||||
sdk.interrupt = async () => interrupted.resolve();
|
||||
const stopTask = vi.fn(async () => {});
|
||||
sdk.stopTask = stopTask;
|
||||
scriptTurns(async function* (options) {
|
||||
yield toolUseMessage(options, "toolu_wf", "Workflow", { script: "review" });
|
||||
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",
|
||||
},
|
||||
};
|
||||
await interrupted.promise;
|
||||
yield result(options, ABORTED_TOOLS);
|
||||
yield idleState(options);
|
||||
});
|
||||
const client = await promptUntil("running [toolu_wf]", async (agent, sessionId, client) => {
|
||||
await agent.notify(v2.methods.agent.session.cancel, { sessionId });
|
||||
await vi.waitFor(() =>
|
||||
expect(backgroundTrace(client)).toContain("idle cancelled [toolu_wf]"),
|
||||
);
|
||||
});
|
||||
|
||||
expect(stopTask).not.toHaveBeenCalled();
|
||||
expect(
|
||||
client
|
||||
.updates("tool_call_update")
|
||||
.filter((update) => update.toolCallId === "toolu_wf" && update.status)
|
||||
.map((update) => update.status),
|
||||
).toEqual(["pending", "in_progress"]);
|
||||
});
|
||||
|
||||
it("sends each listed call's current state before the state snapshot of session/resume", async () => {
|
||||
scriptTurns(async function* (options) {
|
||||
yield* monitorStarts(options);
|
||||
yield result(options);
|
||||
yield idleState(options);
|
||||
yield monitorEvent(options, "task_updated", { patch: { status: "paused" } });
|
||||
});
|
||||
let resumed: v2.SessionUpdate[] = [];
|
||||
await promptUntil("idle end_turn [toolu_mon]", async (agent, sessionId, client) => {
|
||||
await vi.waitFor(() => expect(toolCallTrace(client)).toContain("paused"));
|
||||
const before = client.sessionUpdates.length;
|
||||
await agent.request(v2.methods.agent.session.resume, { sessionId, cwd });
|
||||
resumed = client.sessionUpdates.slice(before).map(({ update }) => update);
|
||||
});
|
||||
|
||||
// The call as its tool use shows it, with its status now, then its
|
||||
// capability.
|
||||
expect(resumed).toEqual([
|
||||
expect.objectContaining({
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId: "toolu_mon",
|
||||
name: "Monitor",
|
||||
status: "paused",
|
||||
rawInput: { command: "tail -f app.log" },
|
||||
}),
|
||||
{
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId: "toolu_mon",
|
||||
capabilities: { cancel: {} },
|
||||
},
|
||||
{
|
||||
sessionUpdate: "state_update",
|
||||
state: "idle",
|
||||
background: [{ type: "tool_call", toolCallId: "toolu_mon" }],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("lists a task without such a call as a _claude/task item", async () => {
|
||||
scriptTurns(async function* (options) {
|
||||
yield level(options, [monitor]);
|
||||
// No tool call started it, such as a task that a hook started.
|
||||
yield taskEvent(options, "task_started", monitor);
|
||||
yield result(options);
|
||||
yield idleState(options);
|
||||
});
|
||||
const client = await promptUntil("idle end_turn [_claude/task]");
|
||||
|
||||
expect(backgroundTrace(client)).toEqual([
|
||||
"running []",
|
||||
"running [_claude/task]",
|
||||
"idle end_turn [_claude/task]",
|
||||
]);
|
||||
expect(client.updates("state_update").at(-1)).toMatchObject({
|
||||
background: [{ type: "_claude/task", taskId: "mon1", description: "Watch the logs" }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -72,3 +72,27 @@ export function withRunningTerminal(
|
||||
if (Object.keys(meta).length === 0) delete next._meta;
|
||||
return { ...notification, update: next };
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps the tool call of a task that went on in the background running: the
|
||||
* tool result update of a tool call in `toolCallIds` reports `in_progress`
|
||||
* instead of `completed`.
|
||||
*
|
||||
* A Monitor or Workflow call returns as soon as its task starts. The consumer
|
||||
* reports the task's end, or its pause, on the tool call (see
|
||||
* `Session.backgroundToolCalls`).
|
||||
*/
|
||||
export function withRunningToolCall(
|
||||
notification: SessionNotification,
|
||||
toolCallIds: ReadonlySet<string>,
|
||||
): SessionNotification {
|
||||
const update = notification.update;
|
||||
if (
|
||||
update.sessionUpdate !== "tool_call_update" ||
|
||||
!toolCallIds.has(update.toolCallId) ||
|
||||
update.status !== "completed"
|
||||
) {
|
||||
return notification;
|
||||
}
|
||||
return { ...notification, update: { ...update, status: "in_progress" } };
|
||||
}
|
||||
|
||||
@@ -29,12 +29,13 @@ export type ToolCallUpdate = SessionNotification["update"];
|
||||
const PLAN_FILE_CLEAR_SUFFIX = ":plan-file-clear";
|
||||
|
||||
/**
|
||||
* ACP v2's `cancelled` tool call status. v1's statuses have no such value, and
|
||||
* the agent builds its reports in v1 types, so it travels as a v1 status. Only
|
||||
* a v2 client gets it, and the v2 surface sends it as it is
|
||||
* (`src/v2/tool-call.ts`).
|
||||
* ACP v2's `cancelled` and `paused` tool call statuses. v1's statuses have no
|
||||
* such values, and the agent builds its reports in v1 types, so they travel as
|
||||
* v1 statuses. Only a v2 client gets them, and the v2 surface sends them as
|
||||
* they are (`src/v2/tool-call.ts`).
|
||||
*/
|
||||
const V2_CANCELLED = "cancelled" as unknown as ToolCallStatus;
|
||||
export const V2_CANCELLED = "cancelled" as unknown as ToolCallStatus;
|
||||
export const V2_PAUSED = "paused" as unknown as ToolCallStatus;
|
||||
|
||||
/** Whether `status` ends a tool call: `completed`, `failed`, or, on v2, `cancelled`. */
|
||||
export function isFinalToolCallStatus(status: ToolCallStatus | null | undefined): boolean {
|
||||
|
||||
@@ -106,13 +106,17 @@ export function v2AgentApp(
|
||||
// v2 requires a snapshot of the foreground state after the replay and
|
||||
// before the response, also without a replay: a resumed live session can
|
||||
// be running a turn, or waiting on a permission request. The current
|
||||
// state of each subagent and terminal it lists comes first, as v2
|
||||
// requires. The subagents' go again after the response (see below).
|
||||
// state of each subagent, terminal and tool call it lists comes first,
|
||||
// as v2 requires. The subagents' go again after the response (see below).
|
||||
await agent.publishLiveSubagents(
|
||||
params.sessionId,
|
||||
client.background.listedSubagents(params.sessionId),
|
||||
);
|
||||
for (const notification of client.background.currentTerminals(params.sessionId)) {
|
||||
await agent.publishBackgroundToolCalls(
|
||||
params.sessionId,
|
||||
client.background.listedToolCalls(params.sessionId),
|
||||
);
|
||||
for (const notification of client.background.currentItems(params.sessionId)) {
|
||||
void client.send(notification);
|
||||
}
|
||||
void client.send({
|
||||
@@ -177,6 +181,10 @@ export function v2AgentApp(
|
||||
await agent.cancelBackgroundTerminal(params);
|
||||
return {};
|
||||
})
|
||||
.onRequest(v2.methods.agent.toolCall.cancel, async ({ params }) => {
|
||||
await agent.cancelBackgroundToolCall(params);
|
||||
return {};
|
||||
})
|
||||
.onNotification(v2.methods.agent.session.cancel, ({ params }) => agent.cancel(params));
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,9 @@ function withBackground(state: v2.StateUpdate, background: BackgroundItem[]): v2
|
||||
return { ...state, background } as v2.StateUpdate;
|
||||
}
|
||||
|
||||
/** The final tool call statuses. */
|
||||
const FINAL_STATUSES = new Set(["completed", "failed", "cancelled"]);
|
||||
|
||||
export class V2Background {
|
||||
/**
|
||||
* The latest list of each session that reports one, whether a state of the
|
||||
@@ -40,6 +43,13 @@ export class V2Background {
|
||||
{ items: BackgroundItem[]; reported: boolean; parent?: string }
|
||||
>();
|
||||
|
||||
/**
|
||||
* The tool calls that advertise `cancel`, by session and tool call. Like a
|
||||
* terminal's (see `V2Terminals.advertiseCancel`), the capability goes with
|
||||
* the final status.
|
||||
*/
|
||||
private readonly cancellableToolCalls = new Set<string>();
|
||||
|
||||
constructor(private readonly terminals: V2Terminals) {}
|
||||
|
||||
/**
|
||||
@@ -50,6 +60,14 @@ export class V2Background {
|
||||
*/
|
||||
stamp(notification: v2.UpdateSessionNotification): v2.UpdateSessionNotification {
|
||||
const { sessionId, update } = notification;
|
||||
if (
|
||||
update.sessionUpdate === "tool_call_update" &&
|
||||
FINAL_STATUSES.has(String(update.status)) &&
|
||||
this.cancellableToolCalls.delete(`${sessionId} ${update.toolCallId}`)
|
||||
) {
|
||||
// A tool call that reached its final status can no longer be cancelled.
|
||||
return { ...notification, update: { ...update, capabilities: null } };
|
||||
}
|
||||
if (update.sessionUpdate === "state_update") {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return notification;
|
||||
@@ -76,9 +94,10 @@ export class V2Background {
|
||||
/**
|
||||
* Records the new list of a session, and returns the updates that report it:
|
||||
*
|
||||
* - For each terminal that the list adds, a `terminal_update` that lets the
|
||||
* client cancel it. It also comes before the first list that names the
|
||||
* terminal, as v2 requires: the terminal's own updates came earlier.
|
||||
* - For each terminal or tool call that the list adds, a `terminal_update`
|
||||
* or `tool_call_update` that lets the client cancel it. It comes before
|
||||
* the first list that names it, as v2 requires of an announcement: its
|
||||
* own updates came earlier.
|
||||
* - Once a state of the session carried a list, the state again with the
|
||||
* new list, without a stop reason: a repeated `idle` is no new
|
||||
* completion. A subagent's goes to its parent's mirror first, as its
|
||||
@@ -93,14 +112,10 @@ export class V2Background {
|
||||
reported: session?.reported ?? false,
|
||||
parent: parentSessionId ?? session?.parent,
|
||||
});
|
||||
const known = new Set(
|
||||
(session?.items ?? []).flatMap((item) => (item.type === "terminal" ? [item.terminalId] : [])),
|
||||
const known = new Set((session?.items ?? []).map((item) => JSON.stringify(item)));
|
||||
const updates: v2.UpdateSessionNotification[] = items.flatMap((item) =>
|
||||
known.has(JSON.stringify(item)) ? [] : this.advertiseCancel(sessionId, item, false),
|
||||
);
|
||||
const updates: v2.UpdateSessionNotification[] = items.flatMap((item) => {
|
||||
if (item.type !== "terminal" || known.has(item.terminalId)) return [];
|
||||
const update = this.terminals.advertiseCancel(sessionId, item.terminalId);
|
||||
return update ? [{ sessionId, update }] : [];
|
||||
});
|
||||
if (session?.reported && state && !unchanged) {
|
||||
if (parentSessionId) {
|
||||
updates.push({
|
||||
@@ -115,15 +130,53 @@ export class V2Background {
|
||||
|
||||
/**
|
||||
* The current `terminal_update` of each terminal that the session
|
||||
* `sessionId` lists. v2 requires it before the state snapshot of
|
||||
* `session/resume`, as a replay may not have delivered it.
|
||||
* `sessionId` lists, and the `cancel` capability of each tool call that it
|
||||
* lists, whose other fields the agent sends (see
|
||||
* `ClaudeAcpAgent.publishBackgroundToolCalls`). v2 requires them before the
|
||||
* state snapshot of `session/resume`, as a replay may not have delivered
|
||||
* them.
|
||||
*/
|
||||
currentTerminals(sessionId: string): v2.UpdateSessionNotification[] {
|
||||
return (this.sessions.get(sessionId)?.items ?? []).flatMap((item) => {
|
||||
if (item.type !== "terminal") return [];
|
||||
const update = this.terminals.advertiseCancel(sessionId, item.terminalId, true);
|
||||
currentItems(sessionId: string): v2.UpdateSessionNotification[] {
|
||||
return (this.sessions.get(sessionId)?.items ?? []).flatMap((item) =>
|
||||
this.advertiseCancel(sessionId, item, true),
|
||||
);
|
||||
}
|
||||
|
||||
/** The tool calls that the session `sessionId` lists. */
|
||||
listedToolCalls(sessionId: string): string[] {
|
||||
return (this.sessions.get(sessionId)?.items ?? []).flatMap((item) =>
|
||||
item.type === "tool_call" ? [item.toolCallId] : [],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The update that lets the client cancel the listed terminal or tool call
|
||||
* `item`, unless it advertises that already. `again` sends it anyway, as
|
||||
* the item's current state.
|
||||
*/
|
||||
private advertiseCancel(
|
||||
sessionId: string,
|
||||
item: BackgroundItem,
|
||||
again: boolean,
|
||||
): v2.UpdateSessionNotification[] {
|
||||
if (item.type === "terminal") {
|
||||
const update = this.terminals.advertiseCancel(sessionId, item.terminalId, again);
|
||||
return update ? [{ sessionId, update }] : [];
|
||||
});
|
||||
}
|
||||
if (item.type !== "tool_call") return [];
|
||||
const key = `${sessionId} ${item.toolCallId}`;
|
||||
if (this.cancellableToolCalls.has(key) && !again) return [];
|
||||
this.cancellableToolCalls.add(key);
|
||||
return [
|
||||
{
|
||||
sessionId,
|
||||
update: {
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId: item.toolCallId,
|
||||
capabilities: { cancel: {} },
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** The subagent sessions that the session `sessionId` lists. */
|
||||
@@ -136,6 +189,9 @@ export class V2Background {
|
||||
/** Forgets the list of a session, and those of its subagent sessions. */
|
||||
forget(sessionId: string): void {
|
||||
this.sessions.delete(sessionId);
|
||||
for (const key of this.cancellableToolCalls) {
|
||||
if (key.startsWith(`${sessionId} `)) this.cancellableToolCalls.delete(key);
|
||||
}
|
||||
for (const [child, { parent }] of this.sessions) {
|
||||
if (parent === sessionId) this.forget(child);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user