mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-10-27 04:30:59 +00:00
Add structured Bash titles and nested subagent transcripts (#881)
## Motivation ACP clients currently have two gaps when rendering Claude Code activity: 1. Bash tool calls expose the shell command as the standard ACP title, but the concise human-readable `description` produced by Claude Code is lost. 2. ACP 1.2 has no standard subagent tool kind or nested-message relationship, so subagent prose and child tools are either flattened into the main conversation or filtered out entirely. This PR adds namespaced metadata and an explicit client capability so richer clients can render both cases without changing the standard ACP schema or regressing legacy clients. ## What changed ### Bash descriptions - Keeps the shell command in the standard ACP `title`, preserving command-oriented clients. - Exposes the optional Claude Code Bash `description` as `_meta.claudeCode.title`. - Applies the metadata consistently to eager permission tool calls, normal tool calls, and streamed input refinements. - Falls back to the command title when no description is available. ### Nested subagent transcripts - Adds the opt-in client capability `clientCapabilities._meta["subagent-transcript"] = true`. - Enables the Claude Agent SDK `forwardSubagentText` option when the client advertises support. - Preserves the pre-existing caller-provided `forwardSubagentText: true` option for compatibility. - Marks Agent and legacy Task launches with `_meta.claudeCode.subagent = true`. - Relates nested text, thinking, and child tool calls to the launching Agent/Task call through `_meta.claudeCode.parentToolUseId`. - Preserves parent attribution during session-history replay, not only during live streaming. - Handles nested Agent-to-Agent launches without inferring subagents from the generic ACP `think` kind. ## Compatibility Clients that do not advertise `subagent-transcript` retain the previous behavior: subagent text and thinking remain filtered from the top-level feed. The normal Agent/Task tool result is still emitted in every mode as the protocol-compatible fallback. No existing result or standard ACP field is removed or redefined. ## Validation - `npm run build` - `npm run check` - `npm run test:run` - 623 tests passed, 20 skipped Coverage includes live and replayed nested text/thinking, child tool attribution, Agent/Task launch markers, nested Agent launches, legacy filtering, and SDK-option compatibility.
This commit is contained in:
@@ -451,6 +451,9 @@ type Session = {
|
||||
* cancel. */
|
||||
forceCancelTimer?: ReturnType<typeof setTimeout>;
|
||||
emitRawSDKMessages: boolean | SDKMessageFilter[];
|
||||
/** Whether nested subagent text/thinking is forwarded to the ACP client.
|
||||
* Enabled by either the ACP capability or the pre-existing SDK option. */
|
||||
forwardSubagentText: boolean;
|
||||
/** Context window size of the session's current model, carried across
|
||||
* prompts so mid-stream usage_update notifications report a correct `size`
|
||||
* before the turn's first result message arrives. Seeded synchronously at
|
||||
@@ -798,6 +801,8 @@ export type ToolUpdateMeta = {
|
||||
claudeCode?: {
|
||||
/* The name of the tool that was used in Claude Code. */
|
||||
toolName: string;
|
||||
/* A human-readable title supplied by Claude Code for the tool call. */
|
||||
title?: string;
|
||||
/* The structured output provided by Claude Code. */
|
||||
toolResponse?: unknown;
|
||||
/* For a tool call made inside a subagent: the tool_use id of the
|
||||
@@ -813,6 +818,11 @@ export type ToolUpdateMeta = {
|
||||
/* Free-text the user supplied when rejecting the tool call, when the
|
||||
harness collected any. Only ever present alongside nonExecutionKind. */
|
||||
userFeedback?: string;
|
||||
/* Marks Agent/Task tool calls as subagent launches. ACP 1.2 has no
|
||||
standard subagent ToolKind yet, so clients that support nested
|
||||
transcripts need a namespaced marker instead of inferring from
|
||||
`toolName` or the generic `think` kind. */
|
||||
subagent?: true;
|
||||
};
|
||||
/* Terminal metadata for Bash tool execution, matching codex-acp's _meta protocol. */
|
||||
terminal_info?: {
|
||||
@@ -829,6 +839,28 @@ export type ToolUpdateMeta = {
|
||||
};
|
||||
};
|
||||
|
||||
const SUBAGENT_TRANSCRIPT_CAPABILITY = "subagent-transcript";
|
||||
|
||||
function supportsSubagentTranscript(capabilities?: ClientCapabilities | null): boolean {
|
||||
return capabilities?._meta?.[SUBAGENT_TRANSCRIPT_CAPABILITY] === true;
|
||||
}
|
||||
|
||||
function parentToolUseIdOf(message: { parent_tool_use_id?: unknown }): string | null {
|
||||
if (!("parent_tool_use_id" in message)) return null;
|
||||
return typeof message.parent_tool_use_id === "string" ? message.parent_tool_use_id : null;
|
||||
}
|
||||
|
||||
function stripSubagentTextAndThinking(content: unknown): unknown {
|
||||
if (!Array.isArray(content)) return content;
|
||||
return content.filter(
|
||||
(item) =>
|
||||
!item ||
|
||||
typeof item !== "object" ||
|
||||
!("type" in item) ||
|
||||
(item.type !== "text" && item.type !== "thinking"),
|
||||
);
|
||||
}
|
||||
|
||||
export type ToolUseCache = {
|
||||
[key: string]: {
|
||||
type: "tool_use" | "server_tool_use" | "mcp_tool_use";
|
||||
@@ -3664,11 +3696,15 @@ export class ClaudeAcpAgent {
|
||||
// Consumed: reset so the next message's blocks accumulate fresh and
|
||||
// the record stays bounded to the in-flight message.
|
||||
streamedBlocks.length = 0;
|
||||
} else if (message.type === "assistant") {
|
||||
// Subagent assistant message (`parent_tool_use_id !== null`). It is
|
||||
// never streamed live and its text/thinking is internal to the tool
|
||||
// call — keep dropping it so subagent prose doesn't leak into the
|
||||
// top-level feed.
|
||||
} else if (
|
||||
message.type === "assistant" &&
|
||||
!(session.forwardSubagentText || supportsSubagentTranscript(this.clientCapabilities))
|
||||
) {
|
||||
// Legacy clients don't understand nested transcripts. Keep the
|
||||
// historical behavior for them: subagent text/thinking remains
|
||||
// internal to the tool call instead of leaking into the top-level
|
||||
// feed. Capable clients opt into the branch above unchanged, with
|
||||
// `parentToolUseId` stamped by toAcpNotifications.
|
||||
content = message.message.content.filter(
|
||||
(item) => item.type !== "text" && item.type !== "thinking",
|
||||
);
|
||||
@@ -4270,6 +4306,9 @@ export class ClaudeAcpAgent {
|
||||
private async replaySessionHistory(sessionId: string): Promise<void> {
|
||||
const toolUseCache: ToolUseCache = {};
|
||||
const messages = await getSessionMessages(sessionId);
|
||||
const forwardSubagentText =
|
||||
this.sessions[sessionId]?.forwardSubagentText ??
|
||||
supportsSubagentTranscript(this.clientCapabilities);
|
||||
|
||||
for (const message of messages) {
|
||||
// Backfill the ACP messageId -> SDK uuid mapping for messages we didn't
|
||||
@@ -4291,6 +4330,10 @@ export class ClaudeAcpAgent {
|
||||
|
||||
// @ts-expect-error - untyped in SDK but we handle all of these
|
||||
let content: unknown = message.message.content;
|
||||
const parentToolUseId = parentToolUseIdOf(message);
|
||||
if (message.type === "assistant" && parentToolUseId && !forwardSubagentText) {
|
||||
content = stripSubagentTextAndThinking(content);
|
||||
}
|
||||
// @ts-expect-error - untyped in SDK but we handle all of these
|
||||
if (message.message.role === "user") {
|
||||
content = stripLocalCommandMetadata(content);
|
||||
@@ -4312,6 +4355,7 @@ export class ClaudeAcpAgent {
|
||||
cwd: this.sessions[sessionId]?.cwd,
|
||||
taskState: this.sessions[sessionId]?.taskState,
|
||||
messageId: replayMessageId,
|
||||
parentToolUseId,
|
||||
},
|
||||
)) {
|
||||
await this.client.sessionUpdate(notification);
|
||||
@@ -5192,6 +5236,9 @@ export class ClaudeAcpAgent {
|
||||
// Extract options from _meta if provided
|
||||
const sessionMeta = params._meta as NewSessionMeta | undefined;
|
||||
const userProvidedOptions = sessionMeta?.claudeCode?.options;
|
||||
const forwardSubagentText =
|
||||
supportsSubagentTranscript(this.clientCapabilities) ||
|
||||
userProvidedOptions?.forwardSubagentText === true;
|
||||
|
||||
// Configure thinking behavior from environment variable
|
||||
const thinking = resolveThinkingConfig(process.env.MAX_THINKING_TOKENS, this.logger);
|
||||
@@ -5270,6 +5317,7 @@ export class ClaudeAcpAgent {
|
||||
// Override certain fields that must be controlled by ACP
|
||||
cwd: params.cwd,
|
||||
includePartialMessages: true,
|
||||
forwardSubagentText,
|
||||
mcpServers: { ...(userProvidedOptions?.mcpServers || {}), ...mcpServers },
|
||||
// If we want bypassPermissions to be an option, we have to allow it here.
|
||||
// But it doesn't work in root mode, so we only activate it if it will work.
|
||||
@@ -5614,6 +5662,7 @@ export class ClaudeAcpAgent {
|
||||
fastModeEnabled,
|
||||
abortController,
|
||||
emitRawSDKMessages: sessionMeta?.claudeCode?.emitRawSDKMessages ?? false,
|
||||
forwardSubagentText,
|
||||
contextWindowSize: seededWindow.size,
|
||||
contextWindowAuthoritative: seededWindow.authoritative,
|
||||
providerCacheKey,
|
||||
@@ -6714,6 +6763,29 @@ function shouldEmitToolCall(toolName: string): boolean {
|
||||
return toolName !== "TodoWrite" && !isTaskTool(toolName);
|
||||
}
|
||||
|
||||
/** Build the Claude Code-specific metadata for a tool call. Bash descriptions
|
||||
* are kept out of ACP's standard `title`, which clients may use as the shell
|
||||
* command preview, while still giving clients access to Claude's concise
|
||||
* human-readable title. */
|
||||
function claudeCodeMetaFromToolUse(toolUse: {
|
||||
name: string;
|
||||
input?: unknown;
|
||||
}): NonNullable<ToolUpdateMeta["claudeCode"]> {
|
||||
const description =
|
||||
toolUse.name === "Bash" &&
|
||||
toolUse.input !== null &&
|
||||
typeof toolUse.input === "object" &&
|
||||
"description" in toolUse.input &&
|
||||
typeof toolUse.input.description === "string"
|
||||
? toolUse.input.description
|
||||
: undefined;
|
||||
return {
|
||||
toolName: toolUse.name,
|
||||
...(description ? { title: description } : {}),
|
||||
...((toolUse.name === "Agent" || toolUse.name === "Task") && { subagent: true as const }),
|
||||
};
|
||||
}
|
||||
|
||||
/** Build the `tool_call` (or, with `refine`, the `tool_call_update`)
|
||||
* notification for a tool_use. Shared by every site that surfaces a tool call:
|
||||
* the streamed tool_use path (first encounter → tool_call, later encounter →
|
||||
@@ -6730,7 +6802,7 @@ function toolCallNotification(
|
||||
): SessionNotification["update"] {
|
||||
if (refine) {
|
||||
return {
|
||||
_meta: { claudeCode: { toolName: toolUse.name } } satisfies ToolUpdateMeta,
|
||||
_meta: { claudeCode: claudeCodeMetaFromToolUse(toolUse) } satisfies ToolUpdateMeta,
|
||||
toolCallId: toolUse.id,
|
||||
sessionUpdate: "tool_call_update",
|
||||
rawInput,
|
||||
@@ -6739,7 +6811,7 @@ function toolCallNotification(
|
||||
}
|
||||
return {
|
||||
_meta: {
|
||||
claudeCode: { toolName: toolUse.name },
|
||||
claudeCode: claudeCodeMetaFromToolUse(toolUse),
|
||||
...(toolUse.name === "Bash" && supportsTerminalOutput
|
||||
? { terminal_info: { terminal_id: toolUse.id } }
|
||||
: {}),
|
||||
@@ -6774,7 +6846,9 @@ function streamedInputRefinement(
|
||||
cwd,
|
||||
);
|
||||
return {
|
||||
_meta: { claudeCode: { toolName: toolUse.name } } satisfies ToolUpdateMeta,
|
||||
_meta: {
|
||||
claudeCode: claudeCodeMetaFromToolUse({ ...toolUse, input }),
|
||||
} satisfies ToolUpdateMeta,
|
||||
toolCallId: toolUse.id,
|
||||
sessionUpdate: "tool_call_update",
|
||||
rawInput: input,
|
||||
|
||||
Reference in New Issue
Block a user