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:
nikita-ashihmin
2026-07-27 11:22:07 +04:00
committed by GitHub
parent 53a0c36ce3
commit 8663170f18
4 changed files with 345 additions and 13 deletions

View File

@@ -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,