pingdotgg / pingdotgg/t3code

[Bug]: Claude provider leaks subagent (sidechain) stream into the parent thread — only the message_delta branch checks parent_tool_use_id

Open
#5,395 1 comment 0 reactions 0 assignees View on GitHub

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
  1. Start a thread with the Claude Agent provider (claudeAgent).
  2. Send a prompt that makes the main agent delegate via the Task tool, e.g. "Spawn a subagent to fix the dev server, and meanwhile summarize the roadmap docs yourself."
  3. 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_deltatext_delta / thinking_delta (:2098) → emits parent-thread content.delta
  • content_block_starttool_use (:2250) → emits parent-thread item.started
  • content_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.

  • assistantTextBlocks keyed by blockIndex (:1469, .set at :1501)
  • inFlightTools keyed by event.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_message item rather than a new one — the parent's final answer gets subagent prose glued onto it.
  • A subagent tool_use at index N overwrites the parent's in-flight tool at index N. The parent's tool then never completes (stuck inProgress), and handleUserMessage's toolUseId lookup (:2352) can no longer find it, so its tool_result is 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

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.