[Bug]: Claude provider leaks subagent (sidechain) stream into the parent thread — only the message_delta branch checks parent_tool_use_id
Nobody has claimed this yet.
- Dominant language
- TypeScript
- Stars
- 23k
- Forks
- 5.9k
- Avg merge
- 11h 14m
- Merged PRs (30d)
- 357
Description
Before submitting
- I searched existing issues and did not find a duplicate.
- I included enough detail to reproduce or investigate the problem.
Area
apps/server
Steps to reproduce
- Start a thread with the Claude Agent provider (
claudeAgent). - Send a prompt that makes the main agent delegate via the
Tasktool, e.g. "Spawn a subagent to fix the dev server, and meanwhile summarize the roadmap docs yourself." - Watch the parent thread while the subagent is running.
Expected behavior
Subagent (sidechain) output should be attributed to the subagent. At minimum it should not be emitted as parent-thread content; ideally it renders nested under the collab_agent_tool_call item that spawned it (this is the rendering half that #4456 / #538 ask for, but this report is about the correctness half).
The parent's own assistant message and its in-flight tool items should be unaffected by whatever a subagent is doing concurrently.
Actual behavior
The subagent's streaming narration is emitted as parent-thread content.delta, so subagent prose appears as a main-thread assistant bubble, and subagent tool calls appear as main-thread work-log items. Because the adapter keys per-turn state by raw content-block index, parent and child also corrupt each other's state.
Screenshot of the symptom: after the parent agent's final answer had already rendered ("Worked for 3m 25s"), a new bubble appeared in the parent thread containing the subagent's internal narration — Now add startGuidedChataftersendMessage — let me find the end of sendMessage. — followed by a work-log entry belonging to the subagent and a "Working for 30m 48s" indicator.
Root cause, all in apps/server/src/provider/Layers/ClaudeAdapter.ts (line numbers @ main = 9697b765e, v0.0.30):
1. handleStreamEvent (:2071) checks parent_tool_use_id in exactly one branch.
2081: if (event.type === "message_delta") {
2082: if (message.parent_tool_use_id !== null && message.parent_tool_use_id !== undefined) {
2083: return; // <-- the only guard in the whole adapter
The branches that actually produce user-visible content have no such guard:
content_block_delta→text_delta/thinking_delta(:2098) → emits parent-threadcontent.deltacontent_block_start→tool_use(:2250) → emits parent-threaditem.startedcontent_block_stop(:2320) → completes parent-thread items
includePartialMessages: true is set at :3544, and SDKPartialAssistantMessage carries parent_tool_use_id: string | null (@anthropic-ai/claude-agent-sdk@0.3.170, sdk.d.ts:3732). Note this leak happens even though forwardSubagentText is never set (default false) — that option gates subagent text on the assistant/user channel, but the partial-message stream is a separate channel and is not gated by it. This is the path that produced the bubble in the screenshot.
2. handleAssistantMessage (:2460) and handleUserMessage (:2338) never look at parent_tool_use_id either.
Per the SDK docs for forwardSubagentText, subagent tool_use/tool_result blocks are forwarded by default ("enough for a heartbeat counter"). Those arrive tagged with a non-null parent_tool_use_id and are processed as if they were parent messages. handleSdkMessage (:2920) dispatches on message.type only, so there is no chokepoint where sidechain messages could be split off.
3. Per-turn state is keyed by raw block index, so parent and subagent collide.
assistantTextBlockskeyed byblockIndex(:1469,.setat :1501)inFlightToolskeyed byevent.index(:2287, also :2174 / :2188)
Both parent and subagent start at content-block index 0, in one flat per-turn map. Consequences:
- Subagent text is appended into the parent's
assistant_messageitem rather than a new one — the parent's final answer gets subagent prose glued onto it. - A subagent
tool_useat index N overwrites the parent's in-flight tool at index N. The parent's tool then never completes (stuckinProgress), andhandleUserMessage'stoolUseIdlookup (:2352) can no longer find it, so itstool_resultis dropped.
4. handleAssistantMessage auto-creates a synthetic turn for messages with no active turn (:2468).
The comment explicitly names the case: // an active turn (e.g., background agent/subagent responses between user prompts). So a subagent message that arrives after the parent's result fabricates a whole extra turn in the parent thread. That matches the phantom bubble + work log + long-running "Working for..." indicator in the screenshot, which is also why this can look like a hang rather than only a cosmetic leak.
5. No test coverage for the sidechain case at all. In ClaudeAdapter.test.ts there are 40 occurrences of parent_tool_use_id: null and zero non-null ones, so nothing pins any of this down.
Impact
Major degradation or frequent failure
Version or commit
main @ 9697b765e (v0.0.30) — verified the code paths above are present on current origin/main
Environment
macOS 26.5 (Darwin 25.5.0); provider: Claude Agent (claudeAgent), @anthropic-ai/claude-agent-sdk 0.3.170
Logs or stack traces
# All parent_tool_use_id references in the adapter:
apps/server/src/provider/Layers/ClaudeAdapter.ts
912: parent_tool_use_id: null, # test/fixture literal
1270: "parent_tool_use_id", # SDK_MESSAGE_NOISE_KEYS (log preview only)
2082: if (message.parent_tool_use_id !== null && message.parent_tool_use_id !== undefined) {
# ^ message_delta branch only (token usage)
# Test coverage:
$ grep -c 'parent_tool_use_id: null' apps/server/src/provider/Layers/ClaudeAdapter.test.ts
40
$ grep -c 'parent_tool_use_id: "' apps/server/src/provider/Layers/ClaudeAdapter.test.ts
0
Screenshots, recordings, or supporting files
Described under "Actual behavior" — parent thread showing subagent narration as a main-thread bubble after the parent turn had already completed.
Workaround
None from the UI side. Avoiding Task/subagent delegation on the Claude provider is the only way to stay clear of it.
Possible fixes
Containment (small, testable). Add a single guard in handleSdkMessage (:2920): if parent_tool_use_id is non-null, do not emit parent-thread runtime events for stream_event / assistant / user. Also exclude sidechain messages from the synthetic-turn path at :2468. This stops the leak and the state corruption; the cost is that subagent internals become invisible (only the Task tool card remains), which is the pre-existing behavior users expect until nested rendering lands.
Correct fix. Partition per-turn state by sidechain instead of by raw index — e.g. Map<parentToolUseId ?? "root", { assistantTextBlocks, inFlightTools }> — and route non-root events as children of the owning collab_agent_tool_call item (classifyToolItemType at :596 already maps Task to that type). Once items are addressable per sidechain, forwardSubagentText: true can be enabled to get the full nested transcript that #4456 and #538 are asking for. The synthetic-turn exclusion is still needed either way.
Happy to open a PR for the containment fix if that direction is acceptable.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start in apps/server/src/provider/Layers/ClaudeAdapter.ts, tracing handleSdkMessage, handleStreamEvent, handleAssistantMessage, and handleUserMessage, then review the existing cases in ClaudeAdapter.test.ts. Add coverage using non-null parent_tool_use_id values and verify sidechain events do not become parent-thread content, corrupt per-turn state, or create a synthetic turn.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- typescript
- Domain
- backend
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 52/100