Fix Claude subagent narration leaking into chat (#1807)

* fix(claude): keep subagent text out of parent transcript

Sidechain tool activity is folded into the parent Task/Agent row, but sidechain assistant text was still going through the top-level text assembler. That made the chat show subagent narration without its matching tool rows.

* test(claude): simplify subagent sidechain assertion
This commit is contained in:
Mohamed Boudra
2026-06-29 19:40:15 +02:00
committed by GitHub
parent b5559dc1de
commit a1afdfee51
4 changed files with 209 additions and 148 deletions
@@ -211,6 +211,20 @@ describe("ClaudeAgentSession sub-agent sidechain updates", () => {
parent_tool_use_id: "task-call-1",
elapsed_time_seconds: 1,
},
{
type: "assistant",
parent_tool_use_id: "task-call-1",
message: {
id: "subagent-message-1",
role: "assistant",
content: [
{
type: "text",
text: "Sub-agent narration belongs inside the Task row, not the parent transcript.",
},
],
},
},
{
type: "assistant",
parent_tool_use_id: null,
@@ -295,6 +309,46 @@ describe("ClaudeAgentSession sub-agent sidechain updates", () => {
expect(projectedTaskCalls).toHaveLength(1);
});
test("keeps sidechain assistant text out of the parent transcript", async () => {
const session = await new ClaudeAgentClient({
logger,
queryFactory,
resolveBinary: async () => "/test/claude/bin",
}).createSession({
provider: "claude",
cwd: process.cwd(),
});
const events = await collectUntilTerminal(streamSession(session, "delegate work"));
await session.close();
const visibleAssistantText = events
.flatMap((event) =>
event.type === "timeline" && event.item.type === "assistant_message"
? [event.item.text]
: [],
)
.join("");
expect(visibleAssistantText).not.toContain("Sub-agent narration");
const latestSubAgentUpdate = events
.filter(
(event): event is Extract<AgentStreamEvent, { type: "timeline" }> =>
event.type === "timeline" &&
event.item.type === "tool_call" &&
event.item.callId === "task-call-1" &&
event.item.detail.type === "sub_agent",
)
.map((event) => event.item)
.at(-1);
expect(latestSubAgentUpdate?.detail).toMatchObject({
type: "sub_agent",
log: expect.stringContaining("[Read] README.md"),
});
});
test("tails sub-agent actions instead of dropping latest entries at cap", async () => {
queryFactory.mockImplementation(() => buildQueryMock(buildTailScenarioEvents(205)));
@@ -1731,6 +1731,14 @@ function isClaudeSubagentToolName(name: string | undefined): boolean {
return name === "Task" || name === "Agent";
}
function readClaudeParentToolUseId(message: SDKMessage): string | null {
if (!("parent_tool_use_id" in message)) {
return null;
}
const parentToolUseId = (message as { parent_tool_use_id?: unknown }).parent_tool_use_id;
return typeof parentToolUseId === "string" && parentToolUseId.length > 0 ? parentToolUseId : null;
}
class ClaudeContextUsageState {
private contextWindowMaxTokens: number | undefined;
private streamRequestInputTokens: number | undefined;
@@ -3495,20 +3503,22 @@ class ClaudeAgentSession implements AgentSession {
suppressAssistantText: true,
suppressReasoning: true,
});
const assistantTimelineEvents = this.timelineAssembler
.consume({
message,
runId: turnId,
messageIdHint,
})
.map(
(item) =>
({
type: "timeline",
item,
provider: "claude",
}) satisfies AgentStreamEvent,
);
const assistantTimelineEvents = readClaudeParentToolUseId(message)
? []
: this.timelineAssembler
.consume({
message,
runId: turnId,
messageIdHint,
})
.map(
(item) =>
({
type: "timeline",
item,
provider: "claude",
}) satisfies AgentStreamEvent,
);
return [...messageEvents, ...assistantTimelineEvents];
}
@@ -3582,10 +3592,7 @@ class ClaudeAgentSession implements AgentSession {
suppressReasoning?: boolean;
},
): AgentStreamEvent[] {
const parentToolUseId =
"parent_tool_use_id" in message
? (message as { parent_tool_use_id: string | null }).parent_tool_use_id
: null;
const parentToolUseId = readClaudeParentToolUseId(message);
if (parentToolUseId) {
return this.sidechainTracker.handleMessage(message, parentToolUseId);
}