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:
12
README.md
12
README.md
@@ -12,12 +12,24 @@ This tool implements an ACP agent by using the official [Claude Agent SDK](https
|
||||
- Following
|
||||
- Edit review
|
||||
- TODO lists
|
||||
- Nested subagent transcripts
|
||||
- Interactive (and background) terminals
|
||||
- Custom [Slash commands](https://docs.anthropic.com/en/docs/claude-code/slash-commands)
|
||||
- Client MCP servers
|
||||
|
||||
Learn more about the [Agent Client Protocol](https://agentclientprotocol.com/).
|
||||
|
||||
### Nested subagent transcripts
|
||||
|
||||
ACP 1.2 has no standard subagent tool kind or nested-message relationship. Clients that can render
|
||||
nested transcripts can opt in with `clientCapabilities._meta["subagent-transcript"] = true`.
|
||||
The agent then forwards subagent text, thinking, and tool calls, relating nested updates to the
|
||||
launching Agent/Task call through `_meta.claudeCode.parentToolUseId`. Agent/Task calls are marked
|
||||
with `_meta.claudeCode.subagent = true`.
|
||||
|
||||
Clients that do not advertise the capability retain the legacy flattened behavior. In both modes,
|
||||
the normal Agent/Task tool result is preserved as the protocol-compatible fallback.
|
||||
|
||||
## Contribution Policy
|
||||
|
||||
This project does not require a Contributor License Agreement (CLA). Instead, contributions are accepted under the following terms:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -156,6 +156,7 @@ function mockSessionState(overrides: Record<string, any> = {}) {
|
||||
currentAgent: "default",
|
||||
abortController: new AbortController(),
|
||||
emitRawSDKMessages: false,
|
||||
forwardSubagentText: false,
|
||||
contextWindowSize: 200000,
|
||||
contextWindowAuthoritative: false,
|
||||
providerCacheKey: "default",
|
||||
@@ -530,6 +531,22 @@ describe("tool conversions", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should use the Bash command as the title when no description is provided", () => {
|
||||
const tool_use = {
|
||||
type: "tool_use",
|
||||
id: "toolu_01VtsS2mxUFwpBJZYd7BmbC9",
|
||||
name: "Bash",
|
||||
input: {
|
||||
command: "git diff",
|
||||
},
|
||||
};
|
||||
|
||||
expect(toolInfoFromToolUse(tool_use)).toMatchObject({
|
||||
kind: "execute",
|
||||
title: "git diff",
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle Glob nicely", () => {
|
||||
const tool_use = {
|
||||
type: "tool_use",
|
||||
@@ -576,6 +593,58 @@ describe("tool conversions", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("marks Agent and legacy Task tool calls as subagent launches", () => {
|
||||
for (const name of ["Agent", "Task"]) {
|
||||
const notifications = toAcpNotifications(
|
||||
[
|
||||
{
|
||||
type: "tool_use",
|
||||
id: `toolu_${name}`,
|
||||
name,
|
||||
input: { description: "Explore", prompt: "Inspect the project" },
|
||||
},
|
||||
] as any,
|
||||
"assistant",
|
||||
"test-session",
|
||||
{},
|
||||
{} as AcpClient,
|
||||
console,
|
||||
);
|
||||
|
||||
expect(notifications[0]?.update).toMatchObject({
|
||||
sessionUpdate: "tool_call",
|
||||
_meta: { claudeCode: { toolName: name, subagent: true } },
|
||||
});
|
||||
}
|
||||
|
||||
const nestedAgent = toAcpNotifications(
|
||||
[
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "nested-agent",
|
||||
name: "Agent",
|
||||
input: { description: "Review tests", prompt: "Inspect tests" },
|
||||
},
|
||||
] as any,
|
||||
"assistant",
|
||||
"test-session",
|
||||
{},
|
||||
{} as AcpClient,
|
||||
console,
|
||||
{ parentToolUseId: "outer-agent" },
|
||||
);
|
||||
expect(nestedAgent[0]?.update).toMatchObject({
|
||||
sessionUpdate: "tool_call",
|
||||
_meta: {
|
||||
claudeCode: {
|
||||
toolName: "Agent",
|
||||
subagent: true,
|
||||
parentToolUseId: "outer-agent",
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle Grep tool calls", () => {
|
||||
const tool_use = {
|
||||
type: "tool_use",
|
||||
@@ -1568,6 +1637,90 @@ describe("synthetic login message (issue #863)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("subagent transcript replay", () => {
|
||||
const replayHistory = [
|
||||
{
|
||||
type: "assistant",
|
||||
uuid: "subagent-message",
|
||||
session_id: "s1",
|
||||
parent_tool_use_id: "parent-agent-call",
|
||||
parent_agent_id: "agent-1",
|
||||
message: {
|
||||
id: "api-subagent-message",
|
||||
model: "claude-sonnet-4-5",
|
||||
role: "assistant",
|
||||
type: "message",
|
||||
stop_reason: "tool_use",
|
||||
content: [
|
||||
{ type: "text", text: "nested report" },
|
||||
{ type: "tool_use", id: "child-tool", name: "Bash", input: { command: "pwd" } },
|
||||
],
|
||||
},
|
||||
},
|
||||
] as Awaited<ReturnType<typeof getSessionMessages>>;
|
||||
|
||||
async function replay(capable: boolean): Promise<SessionNotification[]> {
|
||||
const updates: SessionNotification[] = [];
|
||||
const client = {
|
||||
sessionUpdate: async (update: SessionNotification) => updates.push(update),
|
||||
} as unknown as AcpClient;
|
||||
const agent = new ClaudeAcpAgent(client, { log: () => {}, error: () => {} });
|
||||
(agent as any).clientCapabilities = capable ? { _meta: { "subagent-transcript": true } } : {};
|
||||
vi.mocked(getSessionMessages).mockResolvedValueOnce(replayHistory);
|
||||
|
||||
await (
|
||||
agent as unknown as { replaySessionHistory(sessionId: string): Promise<void> }
|
||||
).replaySessionHistory("s1");
|
||||
return updates;
|
||||
}
|
||||
|
||||
it("preserves nested text and child tool attribution for capable clients", async () => {
|
||||
const updates = await replay(true);
|
||||
expect(updates).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
update: expect.objectContaining({
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
content: { type: "text", text: "nested report" },
|
||||
_meta: expect.objectContaining({
|
||||
claudeCode: expect.objectContaining({ parentToolUseId: "parent-agent-call" }),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
update: expect.objectContaining({
|
||||
sessionUpdate: "tool_call",
|
||||
toolCallId: "child-tool",
|
||||
_meta: expect.objectContaining({
|
||||
claudeCode: expect.objectContaining({ parentToolUseId: "parent-agent-call" }),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps legacy text filtering without losing child tool attribution", async () => {
|
||||
const updates = await replay(false);
|
||||
expect(updates.some(({ update }) => update.sessionUpdate === "agent_message_chunk")).toBe(
|
||||
false,
|
||||
);
|
||||
expect(updates).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
update: expect.objectContaining({
|
||||
sessionUpdate: "tool_call",
|
||||
toolCallId: "child-tool",
|
||||
_meta: expect.objectContaining({
|
||||
claudeCode: expect.objectContaining({ parentToolUseId: "parent-agent-call" }),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("escape markdown", () => {
|
||||
it("should escape markdown characters", () => {
|
||||
let text = "Hello *world*!";
|
||||
@@ -1948,6 +2101,7 @@ describe("permission request cancellation", () => {
|
||||
fastModeEnabled: false,
|
||||
abortController: new AbortController(),
|
||||
emitRawSDKMessages: false,
|
||||
forwardSubagentText: false,
|
||||
contextWindowSize: 200000,
|
||||
contextWindowAuthoritative: false,
|
||||
providerCacheKey: "default",
|
||||
@@ -2046,11 +2200,15 @@ describe("tool_call emitted before permission request", () => {
|
||||
it("emits the tool_call (then asks permission) when the stream hasn't yet", async () => {
|
||||
const { agent, events, updates, session } = setup();
|
||||
|
||||
const result = await agent.canUseTool("session-1")("Bash", { command: "ls" }, {
|
||||
signal: new AbortController().signal,
|
||||
suggestions: [],
|
||||
toolUseID: "tool-1",
|
||||
} as any);
|
||||
const result = await agent.canUseTool("session-1")(
|
||||
"Bash",
|
||||
{ command: "git diff", description: "Show current diff" },
|
||||
{
|
||||
signal: new AbortController().signal,
|
||||
suggestions: [],
|
||||
toolUseID: "tool-1",
|
||||
} as any,
|
||||
);
|
||||
|
||||
// tool_call is sent before the permission request is raised.
|
||||
expect(events).toEqual(["update:tool_call", "permission"]);
|
||||
@@ -2058,6 +2216,10 @@ describe("tool_call emitted before permission request", () => {
|
||||
sessionUpdate: "tool_call",
|
||||
toolCallId: "tool-1",
|
||||
status: "pending",
|
||||
title: "git diff",
|
||||
_meta: {
|
||||
claudeCode: { toolName: "Bash", title: "Show current diff" },
|
||||
},
|
||||
});
|
||||
expect(session.emittedToolCalls.has("tool-1")).toBe(true);
|
||||
expect(result).toMatchObject({ behavior: "allow" });
|
||||
@@ -3790,6 +3952,7 @@ describe("session/close", () => {
|
||||
fastModeEnabled: false,
|
||||
abortController: new AbortController(),
|
||||
emitRawSDKMessages: false,
|
||||
forwardSubagentText: false,
|
||||
contextWindowSize: 200000,
|
||||
contextWindowAuthoritative: false,
|
||||
providerCacheKey: "default",
|
||||
@@ -3882,6 +4045,7 @@ describe("session/delete", () => {
|
||||
fastModeEnabled: false,
|
||||
abortController: new AbortController(),
|
||||
emitRawSDKMessages: false,
|
||||
forwardSubagentText: false,
|
||||
contextWindowSize: 200000,
|
||||
contextWindowAuthoritative: false,
|
||||
providerCacheKey: "default",
|
||||
@@ -3991,6 +4155,7 @@ describe("getOrCreateSession param change detection", () => {
|
||||
fastModeEnabled: false,
|
||||
abortController: new AbortController(),
|
||||
emitRawSDKMessages: false,
|
||||
forwardSubagentText: false,
|
||||
contextWindowSize: 200000,
|
||||
contextWindowAuthoritative: false,
|
||||
providerCacheKey: "default",
|
||||
@@ -5908,6 +6073,39 @@ describe("assembled assistant text fallback", () => {
|
||||
expect(thoughtChunkTexts(updates)).toEqual([]);
|
||||
});
|
||||
|
||||
it("forwards subagent text and thinking as nested chunks for capable clients", async () => {
|
||||
const { agent, updates } = createMockAgentWithCapture();
|
||||
(agent as any).clientCapabilities = { _meta: { "subagent-transcript": true } };
|
||||
injectSession(agent, [
|
||||
assistantMessage(
|
||||
"msg-subagent",
|
||||
[
|
||||
{ type: "thinking", thinking: "checking" },
|
||||
{ type: "text", text: "nested report" },
|
||||
],
|
||||
"tool_use_1",
|
||||
),
|
||||
result(),
|
||||
idle,
|
||||
]);
|
||||
|
||||
await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "hi" }] });
|
||||
|
||||
const nestedUpdates = updates.filter(
|
||||
({ update }) =>
|
||||
update.sessionUpdate === "agent_message_chunk" ||
|
||||
update.sessionUpdate === "agent_thought_chunk",
|
||||
);
|
||||
expect(nestedUpdates).toHaveLength(2);
|
||||
for (const { update } of nestedUpdates) {
|
||||
expect(update._meta).toMatchObject({
|
||||
claudeCode: { parentToolUseId: "tool_use_1" },
|
||||
});
|
||||
}
|
||||
expect(messageChunkTexts(updates)).toContain("nested report");
|
||||
expect(thoughtChunkTexts(updates)).toContain("checking");
|
||||
});
|
||||
|
||||
it("forwards distinct blocks that a gateway splits across same-id messages", async () => {
|
||||
const { agent, updates } = createMockAgentWithCapture();
|
||||
// Observed with OpenAI-compatible gateways: one response id split into an
|
||||
@@ -6815,6 +7013,7 @@ describe("post-error recovery", () => {
|
||||
fastModeEnabled: false,
|
||||
abortController: new AbortController(),
|
||||
emitRawSDKMessages: false,
|
||||
forwardSubagentText: false,
|
||||
contextWindowSize: 200000,
|
||||
contextWindowAuthoritative: false,
|
||||
providerCacheKey: "default",
|
||||
@@ -9726,6 +9925,7 @@ describe("session/cancel wedge recovery (issue #680)", () => {
|
||||
fastModeEnabled: false,
|
||||
abortController: new AbortController(),
|
||||
emitRawSDKMessages: false,
|
||||
forwardSubagentText: false,
|
||||
contextWindowSize: 200000,
|
||||
contextWindowAuthoritative: false,
|
||||
providerCacheKey: "default",
|
||||
@@ -10307,6 +10507,14 @@ describe("streamEventToAcpNotifications", () => {
|
||||
title: "Research dependencies",
|
||||
rawInput: { description: "Research dependencies" },
|
||||
},
|
||||
{
|
||||
case: "Bash description",
|
||||
name: "Bash",
|
||||
partialJson: '{"command":"git diff","description":"Show current diff","timeout":',
|
||||
title: "git diff",
|
||||
metaTitle: "Show current diff",
|
||||
rawInput: { command: "git diff", description: "Show current diff" },
|
||||
},
|
||||
{
|
||||
case: "Bash command with comma and escaped quotes",
|
||||
name: "Bash",
|
||||
@@ -10424,6 +10632,11 @@ describe("streamEventToAcpNotifications", () => {
|
||||
title: testCase.title,
|
||||
rawInput: testCase.rawInput,
|
||||
});
|
||||
if ("metaTitle" in testCase) {
|
||||
expect(refined[0].update._meta).toMatchObject({
|
||||
claudeCode: { title: testCase.metaTitle },
|
||||
});
|
||||
}
|
||||
// Refinements never carry `content`: content built from partial input is
|
||||
// misleading (an Edit missing new_string renders as a deletion) or
|
||||
// invalid (a Write diff without content lacks the required newText).
|
||||
@@ -10997,6 +11210,7 @@ describe("agent selection config option", () => {
|
||||
fastModeEnabled: false,
|
||||
abortController: new AbortController(),
|
||||
emitRawSDKMessages: false,
|
||||
forwardSubagentText: false,
|
||||
contextWindowSize: 200000,
|
||||
contextWindowAuthoritative: false,
|
||||
providerCacheKey: "default",
|
||||
|
||||
@@ -263,6 +263,38 @@ describe("createSession options merging", () => {
|
||||
expect(capturedOptions!.tools).toEqual([]);
|
||||
});
|
||||
|
||||
describe("subagent transcript forwarding", () => {
|
||||
it("keeps the legacy default when neither the client nor caller opts in", async () => {
|
||||
await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
||||
|
||||
expect(capturedOptions!.forwardSubagentText).toBe(false);
|
||||
});
|
||||
|
||||
it("preserves the pre-existing caller-provided SDK option", async () => {
|
||||
await agent.newSession({
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [],
|
||||
_meta: { claudeCode: { options: { forwardSubagentText: true } } },
|
||||
});
|
||||
|
||||
expect(capturedOptions!.forwardSubagentText).toBe(true);
|
||||
});
|
||||
|
||||
it("enables SDK forwarding when the ACP client advertises support", async () => {
|
||||
await agent.initialize({
|
||||
protocolVersion: 1,
|
||||
clientCapabilities: { _meta: { "subagent-transcript": true } },
|
||||
});
|
||||
await agent.newSession({
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [],
|
||||
_meta: { claudeCode: { options: { forwardSubagentText: false } } },
|
||||
});
|
||||
|
||||
expect(capturedOptions!.forwardSubagentText).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("systemPrompt via _meta", () => {
|
||||
it("defaults to the claude_code preset when not provided", async () => {
|
||||
await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
||||
|
||||
Reference in New Issue
Block a user