Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions src/renderer/src/rendering/__tests__/committed.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -328,3 +328,55 @@ describe('task-notification carve-out (residue plan P0b — cutover blocker)', (
expect(c?.toolUseId).toBeUndefined()
})
})

describe('sidechain (subagent) entry filtering (issue #477 — feed leak)', () => {
// Claude interleaves a subagent's OWN turns into the PARENT transcript
// tagged isSidechain:true. Their activity already renders inside the Task
// card (watcher channel); painting them as main-feed rows is duplication.
// These tests encode the FIX: sidechain entries must be rejected with
// 'sidechain-filtered', and their inner tools must NOT be mined as
// committed-owned (which would suppress the parent's real live tools).
const sidechainAssistant = {
uuid: 'sc-a1',
type: 'assistant',
isSidechain: true,
timestamp: TS,
message: {
id: 'msg_sc',
role: 'assistant',
content: [
{ type: 'text', text: 'Let me read the key files.' },
{ type: 'tool_use', id: 'toolu_sc', name: 'Read', input: { file_path: '/x.ts' } },
],
},
}

it('rejects a sidechain assistant turn instead of painting it in the main feed', () => {
const { candidates, decisions } = collectCommittedCandidates(
[sidechainAssistant],
'claude',
's1',
)
expect(candidates.find(c => c.id === 'entry:sc-a1')).toBeUndefined()
const d = decisions.find(x => x.candidateId === 'entry:sc-a1')
expect(d?.selected).toBe(false)
expect(d?.reason).toBe('sidechain-filtered')
})

it('does NOT mine a sidechain turn\'s inner tools as committed-owned', () => {
const { candidates } = collectCommittedCandidates([sidechainAssistant], 'claude', 's1')
const ownership = buildCommittedOwnership(candidates)
// toolu_sc belongs to the subagent — it must not suppress a parent live
// tool block that happens to share nothing, and must not be claimed here.
expect(ownership.toolUseIds.has('toolu_sc')).toBe(false)
})

it('a normal (non-sidechain) assistant turn still paints', () => {
const { candidates } = collectCommittedCandidates(
[{ ...sidechainAssistant, uuid: 'normal', isSidechain: false }],
'claude',
's1',
)
expect(candidates.find(c => c.id === 'entry:normal')).toBeDefined()
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { describe, expect, it } from 'vitest'

import {
createLedgerInputAdapter,
type RuntimeLedgerSlices,
} from '@renderer/rendering/adapter/collectLedgerInput'
import { createSessionLedger } from '@renderer/rendering/model/ledger'

// ---------------------------------------------------------------------------
// FIXTURE: subagent sidechain leak (issue #477) — end to end through the
// adapter. Shape lifted from a real session recording of a subagent fan-out:
// the parent transcript interleaves the subagent's OWN turns (isSidechain:
// true) with the parent's. Only the parent's rows must paint; the subagent's
// activity renders inside the Task card (watcher channel), not the main feed.
// Proves the isSidechain field flows adapter → collector → ledger, not just
// that the collector honors it in isolation.
// ---------------------------------------------------------------------------

const T = 1_700_000_000_000
const iso = (ms: number) => new Date(ms).toISOString()

describe('fixture: subagent sidechain leak (#477, full pipeline)', () => {
it('paints the parent turns and rejects the interleaved subagent turns', () => {
const entries = [
// Parent user prompt.
{
uuid: 'p-user',
type: 'user',
timestamp: iso(T),
permissionMode: 'default',
message: { role: 'user', content: 'spawn a subagent to explore' },
},
// Parent assistant turn that spawns the Agent.
{
uuid: 'p-asst',
type: 'assistant',
timestamp: iso(T + 100),
message: {
id: 'msg_parent',
role: 'assistant',
content: [{ type: 'tool_use', id: 'toolu_agent', name: 'Agent', input: {} }],
},
},
// SUBAGENT's own interleaved turns (isSidechain:true) — the leak.
{
uuid: 'sc-1',
type: 'assistant',
isSidechain: true,
timestamp: iso(T + 200),
message: {
id: 'msg_sc1',
role: 'assistant',
content: [
{ type: 'text', text: 'Let me read the key files.' },
{ type: 'tool_use', id: 'toolu_child_read', name: 'Read', input: { file_path: '/a.ts' } },
],
},
},
{
uuid: 'sc-2',
type: 'user',
isSidechain: true,
timestamp: iso(T + 300),
message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'toolu_child_read', content: '…' }] },
},
]
const slices: RuntimeLedgerSlices = {
provider: 'claude',
sessionId: 's1',
entries,
semanticCurrent: null,
semanticHistory: [],
ghosts: new Map(),
streamPhase: 'idle',
lastJsonlEntryAtMs: T + 300,
}
const bundle = createLedgerInputAdapter()(slices)
const ledger = createSessionLedger()(bundle.input)
const paintedIds = ledger.rows.map(r => r.candidate.id)

// Only the parent's rows paint; neither sidechain turn does.
expect(paintedIds).toEqual(['entry:p-user', 'entry:p-asst'])
expect(paintedIds).not.toContain('entry:sc-1')
expect(paintedIds).not.toContain('entry:sc-2')

// The rejection is on the record with the right reason. Committed-collector
// rejections ride the adapter's collectorDecisions (the ledger's own
// `decisions` cover only what REACHED it) — both halves make the
// ledger=paint=debug story whole.
const scDecision = bundle.collectorDecisions.find(d => d.candidateId === 'entry:sc-1')
expect(scDecision?.selected).toBe(false)
expect(scDecision?.reason).toBe('sidechain-filtered')
})
})
1 change: 1 addition & 0 deletions src/renderer/src/rendering/model/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,7 @@ export type RenderReason =
| 'claude-whole-turn-suppressed'
| 'meta-entry'
| 'synthetic-user-filtered'
| 'sidechain-filtered'
| 'not-conversation'
| 'ghost-not-orphaned'
| 'ghost-superseded'
Expand Down
20 changes: 20 additions & 0 deletions src/renderer/src/rendering/observations/committed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,13 @@ export type RawCommittedEntry = {
}
/** Present on real Claude user prompts; absent on synthetic scaffolding. */
permissionMode?: string
/** Claude tags a SUBAGENT's own turns isSidechain:true and interleaves them
* into the PARENT transcript (issue #477). Their activity already renders
* inside the Task card via the watcher channel (SubAgentState), so painting
* them as main-feed rows is pure duplication. The field flows through the
* claude codec + history.ts; the feed just never honored it (only the
* session picker did). */
isSidechain?: boolean
}

export type CommittedCollection = {
Expand Down Expand Up @@ -239,6 +246,19 @@ export function collectCommittedCandidates(
})
return
}
// Sidechain (subagent) turn (issue #477): reject BEFORE mining tools —
// its inner Read/Bash belong to the child, not the parent, and mining
// them as committed-owned would wrongly suppress parent live tools. The
// same activity renders inside the Task card, so nothing is lost.
if (e.isSidechain === true) {
decisions.push({
candidateId: id,
selected: false,
reason: 'sidechain-filtered',
evidence: ['subagent child turn — renders inside the Task card, not the main feed'],
})
return
}

const text = textOf(e)
const mined = minedToolIds(e)
Expand Down