mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-11-10 11:21:06 +00:00
Compare commits
3 Commits
v0.102.1-p
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a1d83c6031 | ||
|
|
966aee0f04 | ||
|
|
66adc1a4c9 |
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "0.102.0"
|
||||
".": "0.103.0"
|
||||
}
|
||||
|
||||
21
CHANGELOG.md
21
CHANGELOG.md
@@ -1,5 +1,26 @@
|
||||
# Changelog
|
||||
|
||||
## [0.103.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.102.0...v0.103.0) (2026-11-10)
|
||||
|
||||
|
||||
### ⚠ BREAKING CHANGES
|
||||
|
||||
* deep imports of `dist/` files fail with `ERR_PACKAGE_PATH_NOT_EXPORTED`, and `lib.ts` exports only `serveAcp`, `AUTH_STATUS_UPDATE_METHOD` and their types.
|
||||
|
||||
### Features
|
||||
|
||||
* serve ACP v2 with a direct v2 agent and remove the v1 bridge ([#1560](https://github.com/agentclientprotocol/claude-agent-acp/issues/1560)) ([cb285cd](https://github.com/agentclientprotocol/claude-agent-acp/commit/cb285cd2857924c07038c129cc836da007e2737f))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* give subagent checklists their own plan ID ([#1558](https://github.com/agentclientprotocol/claude-agent-acp/issues/1558)) ([2f7e386](https://github.com/agentclientprotocol/claude-agent-acp/commit/2f7e386f3600658d905906f3b37301120b07605f))
|
||||
|
||||
|
||||
### Code Refactoring
|
||||
|
||||
* export only lib.ts, with serveAcp and the authStatus wire names ([#1566](https://github.com/agentclientprotocol/claude-agent-acp/issues/1566)) ([566e265](https://github.com/agentclientprotocol/claude-agent-acp/commit/566e2652a9da5a31dab57a23ea1b06e9fff2ead8))
|
||||
|
||||
## [0.102.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.101.1...v0.102.0) (2026-11-09)
|
||||
|
||||
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@agentclientprotocol/claude-agent-acp",
|
||||
"version": "0.102.0",
|
||||
"version": "0.103.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@agentclientprotocol/claude-agent-acp",
|
||||
"version": "0.102.0",
|
||||
"version": "0.103.0",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "2.5.0",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"version": "0.102.0",
|
||||
"version": "0.103.0",
|
||||
"description": "An ACP-compatible coding agent powered by the Claude Agent SDK (TypeScript)",
|
||||
"main": "dist/lib.js",
|
||||
"types": "dist/lib.d.ts",
|
||||
|
||||
@@ -139,10 +139,6 @@ export function stripLocalCommandMetadata(content: unknown): unknown | null {
|
||||
return kept;
|
||||
}
|
||||
|
||||
export function isLocalCommandMetadata(content: unknown): boolean {
|
||||
return stripLocalCommandMetadata(content) === null;
|
||||
}
|
||||
|
||||
/**
|
||||
* True for the synthetic assistant message the CLI injects into the transcript
|
||||
* when a turn fails authentication (e.g. "Not logged in · Please run /login",
|
||||
|
||||
@@ -84,7 +84,7 @@ export type ReplayChild = {
|
||||
responseModel?: string;
|
||||
};
|
||||
|
||||
type SendMessageCall = {
|
||||
export type SendMessageCall = {
|
||||
toolUseId: string;
|
||||
to: string;
|
||||
message: string;
|
||||
@@ -97,34 +97,27 @@ type SendMessageCall = {
|
||||
outgoingSent: boolean;
|
||||
};
|
||||
|
||||
export class RfdSubagentReplay {
|
||||
/** The children by the id of the tool use that launched each. */
|
||||
private readonly children = new Map<string, ReplayChild>();
|
||||
private readonly byAgentId = new Map<string, ReplayChild>();
|
||||
private readonly sendMessages: SendMessageCall[] = [];
|
||||
/** Launches without metadata, which the replay leaves out. */
|
||||
private readonly omitted = new Set<string>();
|
||||
/** Children whose transcript could not be read: only their association is replayed. */
|
||||
private withoutHistory = 0;
|
||||
/** The SubagentHandback calls, with their report and whether their result failed. */
|
||||
private readonly handbacks = new Map<
|
||||
string,
|
||||
{ message: string; failed: boolean; sent: boolean }
|
||||
>();
|
||||
/** A SubagentHandback call: its report, whether its result failed, and whether the replay sent it. */
|
||||
type ReplayHandback = { message: string; failed: boolean; sent: boolean };
|
||||
|
||||
constructor(
|
||||
private readonly history: SubagentHistory,
|
||||
private readonly publish: Publish,
|
||||
private readonly models?: SubagentModels,
|
||||
/**
|
||||
* Whether the replay maps a model to the session's model list as it goes.
|
||||
* A load lacks the list until the CLI started, so it maps only in
|
||||
* {@link currentStates}, after the load response.
|
||||
*/
|
||||
private readonly mapsDuringReplay = true,
|
||||
/** The working directory of the root session, which a child inherits unless it reports its own. */
|
||||
private readonly rootCwd?: string,
|
||||
) {}
|
||||
/**
|
||||
* What both replays read from the transcripts before they send anything: the
|
||||
* children that launches with metadata started, the launches without metadata
|
||||
* (left out), the SendMessage calls, and the SubagentHandback calls. It sends
|
||||
* nothing; `RfdSubagentReplay` (v1) and `src/v2/subagent-replay.ts` send the
|
||||
* replay from it.
|
||||
*/
|
||||
export class ReplayTranscript<Child extends ReplayChild = ReplayChild> {
|
||||
/** The children by the id of the tool use that launched each. */
|
||||
readonly children = new Map<string, Child>();
|
||||
readonly byAgentId = new Map<string, Child>();
|
||||
readonly sendMessages: SendMessageCall[] = [];
|
||||
/** Launches without metadata, which the replay leaves out. */
|
||||
readonly omitted = new Set<string>();
|
||||
/** The SubagentHandback calls, by tool use id. */
|
||||
readonly handbacks = new Map<string, ReplayHandback>();
|
||||
|
||||
constructor(private readonly history: SubagentHistory) {}
|
||||
|
||||
/**
|
||||
* Registers the launches, the launch results, and the SendMessage calls in
|
||||
@@ -165,9 +158,64 @@ export class RfdSubagentReplay {
|
||||
}
|
||||
}
|
||||
|
||||
private registerLaunch(
|
||||
toolUseId: string,
|
||||
input: Record<string, unknown>,
|
||||
parentSessionId: string,
|
||||
): void {
|
||||
if (this.children.has(toolUseId)) return;
|
||||
const record = this.history.agents.get(toolUseId);
|
||||
if (!record) {
|
||||
this.omitted.add(toolUseId);
|
||||
return;
|
||||
}
|
||||
// A child type adds only optional fields, which a new child leaves unset.
|
||||
const child = {
|
||||
launchId: toolUseId,
|
||||
sessionId: record.agentId,
|
||||
record,
|
||||
parentSessionId,
|
||||
input,
|
||||
announced: false,
|
||||
promptSent: false,
|
||||
} as Child;
|
||||
this.children.set(toolUseId, child);
|
||||
this.byAgentId.set(record.agentId, child);
|
||||
}
|
||||
}
|
||||
|
||||
export class RfdSubagentReplay {
|
||||
private readonly transcript: ReplayTranscript;
|
||||
/** Children whose transcript could not be read: only their association is replayed. */
|
||||
private withoutHistory = 0;
|
||||
|
||||
constructor(
|
||||
private readonly history: SubagentHistory,
|
||||
private readonly publish: Publish,
|
||||
private readonly models?: SubagentModels,
|
||||
/**
|
||||
* Whether the replay maps a model to the session's model list as it goes.
|
||||
* A load lacks the list until the CLI started, so it maps only in
|
||||
* {@link currentStates}, after the load response.
|
||||
*/
|
||||
private readonly mapsDuringReplay = true,
|
||||
/** The working directory of the root session, which a child inherits unless it reports its own. */
|
||||
private readonly rootCwd?: string,
|
||||
) {
|
||||
this.transcript = new ReplayTranscript(history);
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers the launches, the launch results, and the SendMessage calls in
|
||||
* `messages`, the transcript of the session `ownerSessionId`.
|
||||
*/
|
||||
scan(messages: readonly SessionMessage[], ownerSessionId: string): void {
|
||||
this.transcript.scan(messages, ownerSessionId);
|
||||
}
|
||||
|
||||
/** The child that the tool use `toolUseId` launched, when the replay shows it. */
|
||||
childOfLaunch(toolUseId: string): ReplayChild | undefined {
|
||||
return this.children.get(toolUseId);
|
||||
return this.transcript.children.get(toolUseId);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -211,11 +259,11 @@ export class RfdSubagentReplay {
|
||||
*/
|
||||
private effectiveCwd(sessionId: string): string | undefined {
|
||||
const seen = new Set<string>();
|
||||
let child = this.byAgentId.get(sessionId);
|
||||
let child = this.transcript.byAgentId.get(sessionId);
|
||||
while (child && !seen.has(child.sessionId)) {
|
||||
if (child.cwd) return child.cwd;
|
||||
seen.add(child.sessionId);
|
||||
child = this.byAgentId.get(child.parentSessionId);
|
||||
child = this.transcript.byAgentId.get(child.parentSessionId);
|
||||
}
|
||||
return this.rootCwd;
|
||||
}
|
||||
@@ -299,7 +347,9 @@ export class RfdSubagentReplay {
|
||||
if (update.sessionUpdate !== "tool_call" && update.sessionUpdate !== "tool_call_update") {
|
||||
return false;
|
||||
}
|
||||
const sendMessage = this.sendMessages.find((call) => call.toolUseId === update.toolCallId);
|
||||
const sendMessage = this.transcript.sendMessages.find(
|
||||
(call) => call.toolUseId === update.toolCallId,
|
||||
);
|
||||
if (sendMessage) {
|
||||
if (sendMessage.recipientSessionId === undefined) {
|
||||
sendMessage.shownAsToolCall = true;
|
||||
@@ -317,7 +367,7 @@ export class RfdSubagentReplay {
|
||||
}
|
||||
return true;
|
||||
}
|
||||
const handback = owner ? this.handbacks.get(update.toolCallId) : undefined;
|
||||
const handback = owner ? this.transcript.handbacks.get(update.toolCallId) : undefined;
|
||||
if (owner && handback && !handback.failed) {
|
||||
if (!handback.sent) {
|
||||
handback.sent = true;
|
||||
@@ -333,7 +383,7 @@ export class RfdSubagentReplay {
|
||||
}
|
||||
return true;
|
||||
}
|
||||
const child = this.children.get(update.toolCallId);
|
||||
const child = this.transcript.children.get(update.toolCallId);
|
||||
if (!child) return false;
|
||||
if (
|
||||
update.status === "completed" &&
|
||||
@@ -352,29 +402,29 @@ export class RfdSubagentReplay {
|
||||
* its task id is the agent id.
|
||||
*/
|
||||
taskNotification(taskId: string, status: string): boolean {
|
||||
const child = this.byAgentId.get(taskId);
|
||||
const child = this.transcript.byAgentId.get(taskId);
|
||||
if (!child) return false;
|
||||
child.notified = nativeSubagentState(status) ?? child.notified;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends the end of every announced child (see `endState`), and a notice when
|
||||
* Sends the end of every announced child (see `replayEndState`), and a notice when
|
||||
* subagent history is missing.
|
||||
*/
|
||||
async finish(supportsNotices: boolean, rootSessionId: string): Promise<void> {
|
||||
for (const child of [...this.children.values()].reverse()) {
|
||||
for (const child of [...this.transcript.children.values()].reverse()) {
|
||||
if (!child.announced) continue;
|
||||
await this.publish({
|
||||
sessionId: child.parentSessionId,
|
||||
update: {
|
||||
sessionUpdate: "subagent_update",
|
||||
sessionId: child.sessionId,
|
||||
state: endState(child),
|
||||
state: replayEndState(child),
|
||||
},
|
||||
});
|
||||
}
|
||||
const missing = this.omitted.size + this.withoutHistory;
|
||||
const missing = this.transcript.omitted.size + this.withoutHistory;
|
||||
if (missing > 0 && supportsNotices) {
|
||||
await this.publish({ sessionId: rootSessionId, update: missingHistoryNotice(missing) });
|
||||
}
|
||||
@@ -399,12 +449,14 @@ export class RfdSubagentReplay {
|
||||
live: (sessionId: string) => WorkState["state"] | undefined,
|
||||
liveConfiguration?: (sessionId: string) => SessionConfigOption[] | undefined,
|
||||
): AcpSessionNotification[] {
|
||||
return [...this.children.values()]
|
||||
return [...this.transcript.children.values()]
|
||||
.filter((child) => child.announced)
|
||||
.flatMap((child): AcpSessionNotification[] => {
|
||||
const liveState = live(child.sessionId);
|
||||
const state: WorkState =
|
||||
liveState === undefined || liveState === "idle" ? endState(child) : { state: liveState };
|
||||
liveState === undefined || liveState === "idle"
|
||||
? replayEndState(child)
|
||||
: { state: liveState };
|
||||
// The session's model list may be known only now, after the load response.
|
||||
const entry = responseModelEntry(child.responseModel, this.models);
|
||||
const replayed = child.model ? JSON.stringify([childModelOption(child.model)]) : undefined;
|
||||
@@ -435,30 +487,6 @@ export class RfdSubagentReplay {
|
||||
});
|
||||
}
|
||||
|
||||
private registerLaunch(
|
||||
toolUseId: string,
|
||||
input: Record<string, unknown>,
|
||||
parentSessionId: string,
|
||||
): void {
|
||||
if (this.children.has(toolUseId)) return;
|
||||
const record = this.history.agents.get(toolUseId);
|
||||
if (!record) {
|
||||
this.omitted.add(toolUseId);
|
||||
return;
|
||||
}
|
||||
const child: ReplayChild = {
|
||||
launchId: toolUseId,
|
||||
sessionId: record.agentId,
|
||||
record,
|
||||
parentSessionId,
|
||||
input,
|
||||
announced: false,
|
||||
promptSent: false,
|
||||
};
|
||||
this.children.set(toolUseId, child);
|
||||
this.byAgentId.set(record.agentId, child);
|
||||
}
|
||||
|
||||
/** The prompt of the launch, with the launch's id, to the parent and the child, as live. */
|
||||
private async sendPrompt(child: ReplayChild, text: string): Promise<void> {
|
||||
child.promptSent = true;
|
||||
@@ -489,7 +517,7 @@ export class RfdSubagentReplay {
|
||||
text: string,
|
||||
): Promise<void> {
|
||||
const call = deliveredCall(
|
||||
this.sendMessages,
|
||||
this.transcript.sendMessages,
|
||||
[child.sessionId, child.record.name, child.input.name],
|
||||
text,
|
||||
);
|
||||
@@ -553,7 +581,7 @@ export function replaySubagentTerminalState(
|
||||
* `cancelled` for a child the user stopped, else the result of its foreground
|
||||
* launch, else `idle` with no stop reason.
|
||||
*/
|
||||
function endState(child: ReplayChild): WorkState {
|
||||
export function replayEndState(child: ReplayChild): WorkState {
|
||||
const end = child.notified ?? (child.record.stoppedByUser ? "cancelled" : child.launchEnd);
|
||||
return end ? rfdEndState(end, undefined) : { state: "idle" };
|
||||
}
|
||||
@@ -586,7 +614,7 @@ function inputOf(block: Record<string, unknown>): Record<string, unknown> {
|
||||
}
|
||||
|
||||
/** The text of user message content: a string, or its text blocks. */
|
||||
function textOf(content: unknown): string {
|
||||
export function textOf(content: unknown): string {
|
||||
if (typeof content === "string") return content;
|
||||
if (!Array.isArray(content)) return "";
|
||||
return content
|
||||
@@ -598,7 +626,7 @@ function textOf(content: unknown): string {
|
||||
}
|
||||
|
||||
/** Whether `dir` is an existing directory. */
|
||||
function isDirectory(dir: string): boolean {
|
||||
export function isDirectory(dir: string): boolean {
|
||||
try {
|
||||
return statSync(dir).isDirectory();
|
||||
} catch {
|
||||
|
||||
@@ -15,8 +15,8 @@ import {
|
||||
} from "../diff.js";
|
||||
import { AcpToolCallRenderer } from "../tool-calls/renderer.js";
|
||||
import { buildClaudePermissionPresentation } from "../permissions/presentation.js";
|
||||
import { toolInfoFromToolUse } from "../tools.js";
|
||||
import { WriteReporter } from "../tool-calls/reporters/file-edit.js";
|
||||
import { toolInfoFromToolUse } from "./tool-render.js";
|
||||
|
||||
const tempDirectories: string[] = [];
|
||||
|
||||
|
||||
63
src/tests/tool-render.ts
Normal file
63
src/tests/tool-render.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Test shorthands for the shared tool call renderer: one tool use or tool
|
||||
* result, rendered for a client with the given capabilities. Production code
|
||||
* renders through {@link AcpToolCallRenderer} directly.
|
||||
*/
|
||||
import type {
|
||||
ToolCallContent,
|
||||
ToolCallLocation,
|
||||
ToolKind,
|
||||
} from "@agentclientprotocol/sdk/unstable";
|
||||
import { stripLocalCommandMetadata } from "../message-filters.js";
|
||||
import { ClientCapabilities } from "../tool-calls/client-capabilities.js";
|
||||
import type { ToolResultBlock } from "../tool-calls/content.js";
|
||||
import { AcpToolCallRenderer, type RenderedResult } from "../tool-calls/renderer.js";
|
||||
|
||||
/**
|
||||
* The title, kind, content, and locations of a tool use, for a client with the
|
||||
* given terminal and patch capabilities. The {@link AcpToolCallRenderer} builds
|
||||
* them from the facts of the tool reporter.
|
||||
*/
|
||||
export function toolInfoFromToolUse(
|
||||
toolUse: any,
|
||||
supportsTerminalOutput: boolean = false,
|
||||
cwd?: string,
|
||||
supportsDiffPatch: boolean = false,
|
||||
): {
|
||||
title: string;
|
||||
kind: ToolKind;
|
||||
content: ToolCallContent[];
|
||||
locations?: ToolCallLocation[];
|
||||
} {
|
||||
const renderer = new AcpToolCallRenderer(
|
||||
new ClientCapabilities(supportsTerminalOutput, false, supportsDiffPatch),
|
||||
);
|
||||
return renderer.toolInfo({ id: toolUse?.id, name: toolUse?.name, input: toolUse?.input }, cwd);
|
||||
}
|
||||
|
||||
/**
|
||||
* The result fields of a tool result, for a client with the given terminal
|
||||
* capabilities. The {@link AcpToolCallRenderer} builds them from the facts of
|
||||
* the tool reporter.
|
||||
*/
|
||||
export function toolUpdateFromToolResult(
|
||||
toolResult: ToolResultBlock,
|
||||
toolUse: any | undefined,
|
||||
supportsTerminalOutput: boolean = false,
|
||||
toolUseResult?: unknown,
|
||||
preferTerminalOutputDelta: boolean = false,
|
||||
): RenderedResult {
|
||||
const renderer = new AcpToolCallRenderer(
|
||||
new ClientCapabilities(supportsTerminalOutput, preferTerminalOutputDelta),
|
||||
);
|
||||
return renderer.resultFields(
|
||||
{ id: toolUse?.id, name: toolUse?.name ?? "", input: toolUse?.input },
|
||||
toolResult as Parameters<AcpToolCallRenderer["resultFields"]>[1],
|
||||
toolUseResult,
|
||||
);
|
||||
}
|
||||
|
||||
/** Whether `content` is only Claude Code's local-command metadata, which the agent drops. */
|
||||
export function isLocalCommandMetadata(content: unknown): boolean {
|
||||
return stripLocalCommandMetadata(content) === null;
|
||||
}
|
||||
@@ -26,12 +26,10 @@ import { AcpClient } from "../acp-client.js";
|
||||
import { toAcpNotifications, ToolUseCache } from "../sdk-to-acp.js";
|
||||
import { Logger } from "../logger.js";
|
||||
import {
|
||||
toolUpdateFromToolResult,
|
||||
createPostToolUseHook,
|
||||
createTaskHook,
|
||||
clearHookCallbacks,
|
||||
registerHookCallback,
|
||||
toolInfoFromToolUse,
|
||||
planEntries,
|
||||
applyTaskCreate,
|
||||
applyTaskList,
|
||||
@@ -43,6 +41,7 @@ import {
|
||||
} from "../tools.js";
|
||||
import { ClientCapabilities as ToolCallCapabilities } from "../tool-calls/client-capabilities.js";
|
||||
import { AcpToolCallRenderer } from "../tool-calls/renderer.js";
|
||||
import { toolInfoFromToolUse, toolUpdateFromToolResult } from "./tool-render.js";
|
||||
|
||||
/** An AIR client: it gets the ACP tool call contract (`docs/air-extensions.md`). */
|
||||
const AIR_CLIENT: ClientCapabilities = {
|
||||
|
||||
@@ -25,13 +25,7 @@ import {
|
||||
import type { AcpSessionNotification } from "../../acp-subagents.js";
|
||||
import { NativeSubagentRuntime } from "../../native-subagents.js";
|
||||
import { nodeToWebWritable, nodeToWebReadable } from "../../utils.js";
|
||||
import {
|
||||
createPostToolUseHook,
|
||||
markdownEscape,
|
||||
toolInfoFromToolUse,
|
||||
toDisplayPath,
|
||||
toolUpdateFromToolResult,
|
||||
} from "../../tools.js";
|
||||
import { createPostToolUseHook, markdownEscape, toDisplayPath } from "../../tools.js";
|
||||
import { toolUpdateFromDiffToolResponse } from "../../diff.js";
|
||||
import { ToolCallFieldTracker } from "../../tool-calls/field-tracker.js";
|
||||
import {
|
||||
@@ -50,7 +44,6 @@ import {
|
||||
} from "../../sdk-to-acp.js";
|
||||
import { promptToClaude } from "../../prompt-to-claude.js";
|
||||
import {
|
||||
isLocalCommandMetadata,
|
||||
isReplayHiddenMetaMessage,
|
||||
isSyntheticLoginMessage,
|
||||
stripLocalCommandMetadata,
|
||||
@@ -124,6 +117,11 @@ import type {
|
||||
BetaCodeExecutionToolResultBlockParam,
|
||||
} from "@anthropic-ai/sdk/resources/beta.mjs";
|
||||
import { validateRecorded } from "../acp-scenarios/schema.js";
|
||||
import {
|
||||
isLocalCommandMetadata,
|
||||
toolInfoFromToolUse,
|
||||
toolUpdateFromToolResult,
|
||||
} from "../tool-render.js";
|
||||
|
||||
/** The capabilities of an AIR client. Only AIR gets the AIR extensions of
|
||||
* `docs/air-extensions.md`. */
|
||||
|
||||
54
src/tools.ts
54
src/tools.ts
@@ -1,9 +1,4 @@
|
||||
import type {
|
||||
PlanEntry,
|
||||
ToolCallContent,
|
||||
ToolCallLocation,
|
||||
ToolKind,
|
||||
} from "@agentclientprotocol/sdk/unstable";
|
||||
import type { PlanEntry } from "@agentclientprotocol/sdk/unstable";
|
||||
import { HookCallback } from "@anthropic-ai/claude-agent-sdk";
|
||||
import type {
|
||||
TaskCreateInput,
|
||||
@@ -12,56 +7,9 @@ import type {
|
||||
TaskUpdateInput,
|
||||
TaskUpdateOutput,
|
||||
} from "@anthropic-ai/claude-agent-sdk/sdk-tools.js";
|
||||
import { ClientCapabilities } from "./tool-calls/client-capabilities.js";
|
||||
import type { ToolResultBlock } from "./tool-calls/content.js";
|
||||
import { AcpToolCallRenderer, type RenderedResult } from "./tool-calls/renderer.js";
|
||||
|
||||
export { markdownEscape, toDisplayPath } from "./tool-calls/content.js";
|
||||
|
||||
/**
|
||||
* The title, kind, content, and locations of a tool use, for a client with the
|
||||
* given terminal and patch capabilities. The {@link AcpToolCallRenderer} builds
|
||||
* them from the facts of the tool reporter.
|
||||
*/
|
||||
export function toolInfoFromToolUse(
|
||||
toolUse: any,
|
||||
supportsTerminalOutput: boolean = false,
|
||||
cwd?: string,
|
||||
supportsDiffPatch: boolean = false,
|
||||
): {
|
||||
title: string;
|
||||
kind: ToolKind;
|
||||
content: ToolCallContent[];
|
||||
locations?: ToolCallLocation[];
|
||||
} {
|
||||
const renderer = new AcpToolCallRenderer(
|
||||
new ClientCapabilities(supportsTerminalOutput, false, supportsDiffPatch),
|
||||
);
|
||||
return renderer.toolInfo({ id: toolUse?.id, name: toolUse?.name, input: toolUse?.input }, cwd);
|
||||
}
|
||||
|
||||
/**
|
||||
* The result fields of a tool result, for a client with the given terminal
|
||||
* capabilities. The {@link AcpToolCallRenderer} builds them from the facts of
|
||||
* the tool reporter.
|
||||
*/
|
||||
export function toolUpdateFromToolResult(
|
||||
toolResult: ToolResultBlock,
|
||||
toolUse: any | undefined,
|
||||
supportsTerminalOutput: boolean = false,
|
||||
toolUseResult?: unknown,
|
||||
preferTerminalOutputDelta: boolean = false,
|
||||
): RenderedResult {
|
||||
const renderer = new AcpToolCallRenderer(
|
||||
new ClientCapabilities(supportsTerminalOutput, preferTerminalOutputDelta),
|
||||
);
|
||||
return renderer.resultFields(
|
||||
{ id: toolUse?.id, name: toolUse?.name ?? "", input: toolUse?.input },
|
||||
toolResult as Parameters<AcpToolCallRenderer["resultFields"]>[1],
|
||||
toolUseResult,
|
||||
);
|
||||
}
|
||||
|
||||
export type ClaudePlanEntry = {
|
||||
content: string;
|
||||
status: "pending" | "in_progress" | "completed";
|
||||
|
||||
@@ -35,40 +35,36 @@
|
||||
* parent, and a notice at the end says that subagent history is missing, as
|
||||
* it does for a child whose transcript cannot be read.
|
||||
*/
|
||||
import * as fs from "node:fs";
|
||||
import type * as v2 from "@agentclientprotocol/sdk/experimental/v2/unstable";
|
||||
import type { SessionConfigOption } from "@agentclientprotocol/sdk/unstable";
|
||||
import type { SessionMessage } from "@anthropic-ai/claude-agent-sdk";
|
||||
import type { Logger } from "../logger.js";
|
||||
import type { AcpSessionNotification, SubagentState } from "../acp-subagents.js";
|
||||
import type { AcpSessionNotification } from "../acp-subagents.js";
|
||||
import {
|
||||
childModelOption,
|
||||
HANDBACK_TOOL,
|
||||
handbackMessage,
|
||||
isNativeSubagentControlTool,
|
||||
launchReply,
|
||||
linkedSessionMessages,
|
||||
nativeSubagentState,
|
||||
responseModelEntry,
|
||||
rfdEndState,
|
||||
rfdLabels,
|
||||
type ModelEntry,
|
||||
type SubagentModels,
|
||||
type WorkState,
|
||||
} from "../native-subagents.js";
|
||||
import {
|
||||
readSubagentMessages,
|
||||
subagentHistory,
|
||||
subagentLaunchIds,
|
||||
type SubagentHistory,
|
||||
type SubagentRecord,
|
||||
} from "../subagent-history.js";
|
||||
import {
|
||||
deliveredCall,
|
||||
firstResponseModel,
|
||||
isDelivery,
|
||||
isDirectory,
|
||||
missingHistoryNotice,
|
||||
replaySubagentTerminalState,
|
||||
ReplayTranscript,
|
||||
replayEndState,
|
||||
textOf,
|
||||
type ReplayChild as SharedReplayChild,
|
||||
} from "../subagent-replay.js";
|
||||
import { taskNotificationsOf } from "../task-notifications.js";
|
||||
import type { V2UpdateTranslator } from "./update-translator.js";
|
||||
@@ -96,53 +92,11 @@ export interface LiveChildren {
|
||||
|
||||
const NO_LIVE_CHILDREN: LiveChildren = { tracks: () => false, configuration: () => undefined };
|
||||
|
||||
type ReplayChild = {
|
||||
launchId: string;
|
||||
sessionId: string;
|
||||
record: SubagentRecord;
|
||||
parentSessionId: string;
|
||||
input: Record<string, unknown>;
|
||||
/** How the launch's tool result ended a foreground child's run. */
|
||||
launchEnd?: "completed" | "failed" | "cancelled";
|
||||
/** The state of the child's last persisted task notification. */
|
||||
notified?: SubagentState;
|
||||
announced: boolean;
|
||||
promptSent: boolean;
|
||||
/** Whether it handed a report to its parent with a SubagentHandback call. */
|
||||
handedBack?: boolean;
|
||||
/** The `model` option entry that the replay sent. */
|
||||
model?: ModelEntry;
|
||||
/** The model id of its latest replayed response. */
|
||||
responseModel?: string;
|
||||
/** The `cwd` it was announced with, when not its parent's. */
|
||||
cwd?: string;
|
||||
output?: SessionOutput;
|
||||
};
|
||||
|
||||
type SendMessageCall = {
|
||||
toolUseId: string;
|
||||
to: string;
|
||||
message: string;
|
||||
senderSessionId: string;
|
||||
/** The child the replay delivered the message to. */
|
||||
recipientSessionId?: string;
|
||||
/** The replay reached the call before the delivery, so it stays a tool call. */
|
||||
shownAsToolCall: boolean;
|
||||
/** The sender's view of the message went out, at the call. */
|
||||
outgoingSent: boolean;
|
||||
};
|
||||
/** A replayed child, with the output of its session once announced. */
|
||||
type ReplayChild = SharedReplayChild & { output?: SessionOutput };
|
||||
|
||||
export class SubagentReplay {
|
||||
/** The children, by the id of the call that launched each. */
|
||||
private readonly children = new Map<string, ReplayChild>();
|
||||
private readonly sendMessages: SendMessageCall[] = [];
|
||||
/** The SubagentHandback calls, with their report and whether their result failed. */
|
||||
private readonly handbacks = new Map<
|
||||
string,
|
||||
{ message: string; failed: boolean; sent: boolean }
|
||||
>();
|
||||
/** Launches without metadata, which the replay leaves out. */
|
||||
private readonly omitted = new Set<string>();
|
||||
private readonly transcript: ReplayTranscript<ReplayChild>;
|
||||
/** Children whose transcript could not be read: only their association is replayed. */
|
||||
private withoutHistory = 0;
|
||||
private rootOutput: SessionOutput | undefined;
|
||||
@@ -153,7 +107,9 @@ export class SubagentReplay {
|
||||
/** The transcript of each child, by agent id. */
|
||||
private readonly transcripts: Map<string, SessionMessage[]>,
|
||||
private readonly live: LiveChildren,
|
||||
) {}
|
||||
) {
|
||||
this.transcript = new ReplayTranscript(history);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the subagent history that `messages`, the session's transcript,
|
||||
@@ -183,7 +139,7 @@ export class SubagentReplay {
|
||||
/** The hooks of the replay of the root session's history (`messages`). */
|
||||
hooks(output: SessionOutput, messages: SessionMessage[]): ReplayHooks {
|
||||
this.rootOutput = output;
|
||||
this.scan(messages, output.sessionId);
|
||||
this.transcript.scan(messages, output.sessionId);
|
||||
return this.ownerHooks(output, undefined);
|
||||
}
|
||||
|
||||
@@ -192,24 +148,24 @@ export class SubagentReplay {
|
||||
* stream, and the notice when subagent history is missing.
|
||||
*/
|
||||
finish(): void {
|
||||
const announced = [...this.children.values()].filter((child) => child.announced);
|
||||
const announced = [...this.transcript.children.values()].filter((child) => child.announced);
|
||||
for (const child of [...announced].reverse()) {
|
||||
this.host.send({
|
||||
sessionId: child.parentSessionId,
|
||||
update: {
|
||||
sessionUpdate: "subagent_update",
|
||||
sessionId: child.sessionId,
|
||||
state: endState(child) as v2.StateUpdate,
|
||||
state: replayEndState(child) as v2.StateUpdate,
|
||||
},
|
||||
});
|
||||
}
|
||||
for (const child of announced) {
|
||||
this.host.send({
|
||||
sessionId: child.sessionId,
|
||||
update: { sessionUpdate: "state_update", ...endState(child) } as v2.SessionUpdate,
|
||||
update: { sessionUpdate: "state_update", ...replayEndState(child) } as v2.SessionUpdate,
|
||||
});
|
||||
}
|
||||
const missing = this.omitted.size + this.withoutHistory;
|
||||
const missing = this.transcript.omitted.size + this.withoutHistory;
|
||||
if (missing > 0) {
|
||||
this.rootOutput?.emitV1([
|
||||
{ sessionId: this.host.sessionId, update: missingHistoryNotice(missing) },
|
||||
@@ -227,7 +183,7 @@ export class SubagentReplay {
|
||||
* the session's model list.
|
||||
*/
|
||||
afterResponse(): void {
|
||||
for (const child of this.children.values()) {
|
||||
for (const child of this.transcript.children.values()) {
|
||||
if (!child.announced) continue;
|
||||
if (!this.live.tracks(child.sessionId)) this.sendEndState(child);
|
||||
this.sendConfiguration(child);
|
||||
@@ -249,7 +205,7 @@ export class SubagentReplay {
|
||||
}
|
||||
|
||||
private sendEndState(child: ReplayChild): void {
|
||||
const state = endState(child);
|
||||
const state = replayEndState(child);
|
||||
this.host.send({
|
||||
sessionId: child.parentSessionId,
|
||||
update: {
|
||||
@@ -280,63 +236,6 @@ export class SubagentReplay {
|
||||
* Registers the launches, the launch results, the SendMessage calls and the
|
||||
* SubagentHandback calls in `messages`, of the session `ownerSessionId`.
|
||||
*/
|
||||
private scan(messages: readonly SessionMessage[], ownerSessionId: string): void {
|
||||
for (const message of messages) {
|
||||
for (const block of contentBlocks(message)) {
|
||||
if (block.type === "tool_use" && typeof block.id === "string") {
|
||||
if (isNativeSubagentControlTool(block.name)) {
|
||||
this.registerLaunch(block.id, inputOf(block), ownerSessionId);
|
||||
} else if (block.name === HANDBACK_TOOL) {
|
||||
const report = handbackMessage(block.input);
|
||||
if (report)
|
||||
this.handbacks.set(block.id, { message: report, failed: false, sent: false });
|
||||
} else if (block.name === "SendMessage") {
|
||||
const input = inputOf(block);
|
||||
if (typeof input.to === "string" && typeof input.message === "string") {
|
||||
this.sendMessages.push({
|
||||
toolUseId: block.id,
|
||||
to: input.to,
|
||||
message: input.message,
|
||||
senderSessionId: ownerSessionId,
|
||||
shownAsToolCall: false,
|
||||
outgoingSent: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (block.type === "tool_result" && typeof block.tool_use_id === "string") {
|
||||
const handback = this.handbacks.get(block.tool_use_id);
|
||||
if (handback && block.is_error === true) handback.failed = true;
|
||||
const child = this.children.get(block.tool_use_id);
|
||||
// A background launch answers at once; its result is not the end.
|
||||
if (child && !child.record.background)
|
||||
child.launchEnd = replaySubagentTerminalState(block);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private registerLaunch(
|
||||
launchId: string,
|
||||
input: Record<string, unknown>,
|
||||
parentSessionId: string,
|
||||
): void {
|
||||
if (this.children.has(launchId)) return;
|
||||
const record = this.history.agents.get(launchId);
|
||||
if (!record) {
|
||||
this.omitted.add(launchId);
|
||||
return;
|
||||
}
|
||||
this.children.set(launchId, {
|
||||
launchId,
|
||||
sessionId: record.agentId,
|
||||
record,
|
||||
parentSessionId,
|
||||
input,
|
||||
announced: false,
|
||||
promptSent: false,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A replayed update of a tool call of `output`, the session of `owner`
|
||||
* when it is a child's. The client is not shown the launch of a replayed
|
||||
@@ -354,7 +253,9 @@ export class SubagentReplay {
|
||||
if (update.sessionUpdate !== "tool_call" && update.sessionUpdate !== "tool_call_update") {
|
||||
return [notification];
|
||||
}
|
||||
const sendMessage = this.sendMessages.find((call) => call.toolUseId === update.toolCallId);
|
||||
const sendMessage = this.transcript.sendMessages.find(
|
||||
(call) => call.toolUseId === update.toolCallId,
|
||||
);
|
||||
if (sendMessage) {
|
||||
if (sendMessage.recipientSessionId === undefined) {
|
||||
sendMessage.shownAsToolCall = true;
|
||||
@@ -370,7 +271,7 @@ export class SubagentReplay {
|
||||
);
|
||||
return [outgoing!];
|
||||
}
|
||||
const handback = owner ? this.handbacks.get(update.toolCallId) : undefined;
|
||||
const handback = owner ? this.transcript.handbacks.get(update.toolCallId) : undefined;
|
||||
if (owner && handback && !handback.failed) {
|
||||
if (handback.sent) return [];
|
||||
handback.sent = true;
|
||||
@@ -386,7 +287,7 @@ export class SubagentReplay {
|
||||
this.outputOf(owner.parentSessionId)?.emitV1([incoming!]);
|
||||
return [];
|
||||
}
|
||||
const child = this.children.get(update.toolCallId);
|
||||
const child = this.transcript.children.get(update.toolCallId);
|
||||
if (!child) return [notification];
|
||||
if (
|
||||
update.status === "completed" &&
|
||||
@@ -412,7 +313,9 @@ export class SubagentReplay {
|
||||
return;
|
||||
}
|
||||
for (const notification of taskNotificationsOf(inner?.content)) {
|
||||
const child = [...this.children.values()].find((c) => c.sessionId === notification.task_id);
|
||||
const child = [...this.transcript.children.values()].find(
|
||||
(c) => c.sessionId === notification.task_id,
|
||||
);
|
||||
if (child) child.notified = nativeSubagentState(notification.status) ?? child.notified;
|
||||
}
|
||||
}
|
||||
@@ -421,13 +324,13 @@ export class SubagentReplay {
|
||||
private replayed(message: SessionMessage, parent: SessionOutput): void {
|
||||
if (message.type !== "assistant") return;
|
||||
for (const launchId of launchIdsOf(message)) {
|
||||
const child = this.children.get(launchId);
|
||||
const child = this.transcript.children.get(launchId);
|
||||
if (!child || child.announced) continue;
|
||||
const messages = this.transcripts.get(child.sessionId) ?? [];
|
||||
if (messages.length === 0) this.withoutHistory++;
|
||||
this.announce(child);
|
||||
this.sendModel(child, firstResponseModel(messages));
|
||||
this.scan(messages, child.sessionId);
|
||||
this.transcript.scan(messages, child.sessionId);
|
||||
child.output!.replay(messages, this.ownerHooks(child.output!, child));
|
||||
if (!child.promptSent && typeof child.input.prompt === "string") {
|
||||
this.sendPrompt(child, parent, child.input.prompt);
|
||||
@@ -517,7 +420,7 @@ export class SubagentReplay {
|
||||
*/
|
||||
private sendDelivery(child: ReplayChild, message: SessionMessage, text: string): void {
|
||||
const call = deliveredCall(
|
||||
this.sendMessages,
|
||||
this.transcript.sendMessages,
|
||||
[child.sessionId, child.record.name, child.input.name],
|
||||
text,
|
||||
);
|
||||
@@ -553,63 +456,18 @@ export class SubagentReplay {
|
||||
|
||||
private outputOf(sessionId: string): SessionOutput | undefined {
|
||||
if (sessionId === this.host.sessionId) return this.rootOutput;
|
||||
return [...this.children.values()].find((child) => child.sessionId === sessionId)?.output;
|
||||
return [...this.transcript.children.values()].find((child) => child.sessionId === sessionId)
|
||||
?.output;
|
||||
}
|
||||
|
||||
private cwdOf(sessionId: string): string {
|
||||
const child = [...this.children.values()].find((c) => c.sessionId === sessionId);
|
||||
const child = [...this.transcript.children.values()].find((c) => c.sessionId === sessionId);
|
||||
if (!child) return this.host.cwd;
|
||||
return child.cwd ?? this.cwdOf(child.parentSessionId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* How a replayed child's work ended: the state of its last task
|
||||
* notification, else a stop by the user, else its foreground launch's result.
|
||||
*/
|
||||
function endState(child: ReplayChild): WorkState {
|
||||
const end =
|
||||
child.notified ??
|
||||
nativeSubagentState(child.record.stoppedByUser ? "cancelled" : child.launchEnd);
|
||||
return end ? rfdEndState(end, undefined) : { state: "idle" };
|
||||
}
|
||||
|
||||
function launchIdsOf(message: SessionMessage): string[] {
|
||||
if (message.type !== "assistant") return [];
|
||||
return subagentLaunchIds((message.message as { content?: unknown } | undefined)?.content);
|
||||
}
|
||||
|
||||
function contentBlocks(message: SessionMessage): Record<string, unknown>[] {
|
||||
const content = (message as { message?: { content?: unknown } }).message?.content;
|
||||
return Array.isArray(content)
|
||||
? content.filter(
|
||||
(block): block is Record<string, unknown> => typeof block === "object" && block !== null,
|
||||
)
|
||||
: [];
|
||||
}
|
||||
|
||||
function inputOf(block: Record<string, unknown>): Record<string, unknown> {
|
||||
return typeof block.input === "object" && block.input !== null
|
||||
? (block.input as Record<string, unknown>)
|
||||
: {};
|
||||
}
|
||||
|
||||
/** The text of user message content: a string, or its text blocks. */
|
||||
function textOf(content: unknown): string {
|
||||
if (typeof content === "string") return content;
|
||||
if (!Array.isArray(content)) return "";
|
||||
return content
|
||||
.flatMap((block) =>
|
||||
typeof block === "object" && block !== null && block.type === "text" ? [block.text] : [],
|
||||
)
|
||||
.filter((text): text is string => typeof text === "string")
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function isDirectory(dir: string): boolean {
|
||||
try {
|
||||
return fs.statSync(dir).isDirectory();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user