Skip to content

Subagent output leaks into the main feed (isSidechain unfiltered + live proxy-flow multiplexing) #477

Closed

Description

@Juliusolsson05

Symptom (observed live, captured in a session recording)

While a subagent (Task/Agent) runs, its own internal work — Read/Bash tool calls, narration text, even its final report — paints as rows in the parent's main feed. Only the parent's output belongs there; the subagent's activity should be sealed inside its TaskSubagentRow card. Confirmed by watching a live fan-out and by the session recorder (#467).

Recording evidence (this is the fixture)

A session recording of a real subagent fan-out (~/.config/agent-code/session-recordings/…, ~1065 events) shows the leak in data. One parent session:semantic-event stream contained 13 distinct turns, all src=proxy, interleaving the parent and its subagents:

turn …  src=proxy tools=['Agent','Agent']          :: "Perfect — you're recording this pane…"   ← parent (real)
turn …  src=proxy tools=['Read','Read','Read']       :: "I'll investigate the subagent transcript…" ← SUBAGENT (leak)
turn …  src=proxy tools=['Read','Read','Read','Read'] :: "Let me read the remaining key files."      ← SUBAGENT (leak)
turn …  src=proxy tools=['Bash']                     :: "Let me confirm the Codex parent tails only…" ← SUBAGENT (leak)
turn …  src=proxy tools=[]                           :: "I have a complete picture. Here is the data-flow map…" ← SUBAGENT final report (leak)

Parent-stream tool totals: Read ×18, Bash ×8, Agent ×4 — the Read/Bash counts are almost entirely subagent-internal. flow_selected: 10 / flow_ignored: 23 shows the semantic adapter trying to attribute flows but leaking subagent ones through.

Root cause — TWO carriers, neither filtered

The subagent-detection code-read initially concluded "child activity is structurally isolated" — true for one plane, wrong for the feed. There are two:

1. Committed plane (JSONL) — the persistent leak. Claude tags a subagent's turns isSidechain: true and interleaves them into the parent transcript. The field is real (src/providers/claude/types/claudeTranscript.ts:57), parsed (src/providers/claude/renderer/transcript/history.ts:71), and preserved through the codec (packages/agent-transcript-parser/src/codecs/claude.ts:95,114). But the only consumer is the session picker (src/providers/claude/runtime/sessionList.ts:160,339-340), which skips sidechain lines — proof they're in the parent transcript. The feed pipeline never skips them:

  • RawCommittedEntry (src/renderer/src/rendering/observations/committed.ts:24-37) has no isSidechain field — structurally blind.
  • collectCommittedCandidates (committed.ts:186-267) only rejects not-conversation / meta-entry / task-notification / synthetic-user. A sidechain assistant turn is type:'assistant', not meta, doesn't start with < → selected, and minedToolIds even stamps its inner Read/Bash as owned tools.
  • forwarder.ts:39-42 sends the same sidechain jsonl-entry to BOTH the feed and the watcher; jsonlCoalescer / historyLoader apply no sidechain filter.

2. Live semantic plane (proxy) — the visible streaming leak. The live feed's semantic turns are built from the mitmproxy stream, which intercepts every api.anthropic.com call — parent AND subagent — all tagged src=proxy with no isSidechain marker (it's a JSONL flag, not on the wire). So the adapter's flow attribution can't cleanly separate them and subagent turns render as parent turns while streaming (the recording proof above).

The activity already renders correctly inside the card via the separate watcher channel (SubAgentWatcher → SubAgentState → session:sub-agents → TaskSubagentRow). So the leaked rows are pure duplication — filtering them loses nothing.

The new ledger pipeline reproduces the leak — committed.ts and semantic.ts have zero sidechain/parent-linkage awareness.

Related

Resolution plan

See the first comment.

Activity

  1. Juliusolsson05 commented on Jul 7, 2026

    @Juliusolsson05
    OwnerAuthor

    Resolution plan — fixture-first (TDD), two tracks

    The whole point of the new pipeline: encode the bug as a failing test (a candidate that should be rejected but is selected), state the expected decision, then fix until green. The decision-reason system makes this a clean assertion.

    Track A — Committed plane (clean, test-lockable, ship first)

    This kills the persistent leak (what stays after a turn commits) and is a tight, well-diagnosed change.

    A1. Fixture / failing tests first.

    • Unit (observations/committed.test.ts): a sidechain assistant entry ({type:'assistant', isSidechain:true, message:{…Read/Bash…}}) must produce a rejected OwnershipDecision with a new reason sidechain-filtered, NOT selected. Assert minedToolIds does NOT stamp its inner tools as committed-owned. This test fails today (currently selected) — that failure IS the bug captured.
    • e2e (__tests__/fixtures.sidechainLeak.test.ts): a parent turn + an interleaved sidechain turn → the ledger paints only the parent rows; the sidechain candidate carries the rejection with evidence. Seed it from the real recording's shape.

    A2. Fix.

    • Add isSidechain?: boolean to RawCommittedEntry (committed.ts:24-37); populate it in the adapter's committed partition (collectLedgerInput.ts) and wherever RawCommittedEntry is built from Entry (the field already flows through the codec + history.ts).
    • In collectCommittedCandidates, add a rejection branch beside meta-entry / synthetic-user-filtered: if (e.isSidechain === true) → reason 'sidechain-filtered' with evidence ['subagent child turn — rendered inside the Task card, not the main feed']. Skip minedToolIds for it.
    • Add 'sidechain-filtered' to the RenderReason enum (model/types.ts) — the fixture-gated enum churns once.
    • Safe by construction: the same activity already renders in TaskSubagentRow via the watcher channel, so rejecting these entries loses no information. Verify with the existing subagent tests.

    A3. Corpus. Extract the real recording (via extract-rendering-recordings.mjs, structure-only redaction) into testing/fixtures/rendering-recordings/ so the leak is regression-locked against real captured data, not only hand-built fixtures. Bless against the fixed output; the sidechain turns must be absent from the painted rows.

    Track B — Live semantic plane (harder, follow-up, needs proxy attribution)

    This is the streaming leak (what you see while a subagent runs). The semantic turns are proxy-derived with no isSidechain marker, so the fix is flow attribution, not a field filter:

    B1. Identify subagent proxy flows by request fingerprint — the subagent request carries a distinct system prompt / tool set. Reuse the isSidecarFlow / request_shape machinery (#174/#178). The proxy already captures outbound request bodies (request_shape + body_b64), so the flow can be classified at turn_started.
    B2. Route classified subagent flows to SubAgentState (feed the card's live view) instead of emitting them as parent semantic-current turns. The flow_ignored path already exists (23 ignored in the recording) — extend it to attribute rather than merely ignore.
    B3. Fixture: the session recording replays the semantic stream deterministically — assert the parent's rendered turns exclude the subagent-fingerprinted flows.

    Sequencing

    • A first — clean, test-locked, kills the persistent leak, small blast radius. One PR to integration/rendering-pipeline.
    • B second — the live streaming attribution; larger, touches the proxy adapter; its own PR. Note that once the desktop pipeline cutover lands, B is where the remaining visible glitch lives.

    Why this is the pipeline working as designed

    The recorder captured the live symptom, the ownership-decision system gives us the exact assertion surface (reason: 'sidechain-filtered'), and the recording becomes the regression fixture. Bug → recording → failing test → fix → green → locked.

  2. added 2 commits that reference this issue on Jul 7, 2026
  3. Juliusolsson05 commented on Jul 7, 2026

    @Juliusolsson05
    OwnerAuthor

    Fixed — both leak carriers the issue identified are now filtered:

    Subagent activity now renders only inside its Task card. Closing.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions