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
56 changes: 56 additions & 0 deletions src/renderer/src/rendering/__tests__/committed.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,3 +190,59 @@ describe('block-grain tool ownership mining (corpus new-bug fix)', () => {
expect(d.reason).toBe('committed-tool-use-owned')
})
})

describe('collapsed-running null-paint (corpus new-bug fix, claude churn tools)', () => {
const emptyOwnership = buildCommittedOwnership([])
const historyTool = (over: Partial<RenderCandidate>): RenderCandidate => ({
id: 'sem:turn_h:0',
owner: 'semantic-history',
provider: 'claude',
sourcePlane: 'semantic',
sessionId: 's1',
turnId: 'turn_h',
blockIndex: 0,
toolUseId: 'toolu_dangling',
contentKind: 'tool-use',
timestampMs: TS_MS,
sequence: 0,
...over,
})

it('hides an unresolved untraced churn tool in claude history', () => {
const d = decideLiveCandidate(historyTool({ toolName: 'Read' }), emptyOwnership, SUPPRESSION_POLICY.claude)
expect(d.selected).toBe(false)
expect(d.reason).toBe('collapsed-running')
})

it('non-churn tools paint even while unresolved (running Task chip is live signal)', () => {
const d = decideLiveCandidate(historyTool({ toolName: 'Task' }), emptyOwnership, SUPPRESSION_POLICY.claude)
expect(d.selected).toBe(true)
})

it('resolved churn tools paint (block-local result evidence)', () => {
const d = decideLiveCandidate(
historyTool({ toolName: 'Bash', resolved: true }),
emptyOwnership,
SUPPRESSION_POLICY.claude,
)
expect(d.selected).toBe(true)
})

it('codex is exempt by policy (MCP next-turn-output lifecycle)', () => {
const d = decideLiveCandidate(
historyTool({ provider: 'codex', toolName: 'Read' }),
emptyOwnership,
SUPPRESSION_POLICY.codex,
)
expect(d.selected).toBe(true)
})

it('current-turn tools always paint (streaming legitimately awaits results)', () => {
const d = decideLiveCandidate(
historyTool({ owner: 'semantic-current', toolName: 'Read' }),
emptyOwnership,
SUPPRESSION_POLICY.claude,
)
expect(d.selected).toBe(true)
})
})
69 changes: 66 additions & 3 deletions src/renderer/src/rendering/model/ownership.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,14 +54,41 @@ export type CommittedOwnership = {
*/
export type SuppressionPolicy = {
wholeTurnByMessageId: boolean
/**
* Collapsed-running null-paint (legacy semanticRenderUnitPaintsDom
* behavior; corpus new-bug 2026-06-29 1b2b5e96): hide a HISTORY tool
* block whose run never resolved — no result paired into the block AND
* no committed tool_use/tool_result trace. CLAUDE ONLY: its tool
* results always pair into the block (semantic reducer) or land as
* committed rows, so absence of both means the run truly died. Codex is
* excluded because its MCP lifecycle delivers function_call_output in a
* LATER semantic turn — block-local evidence is legitimately absent and
* committed reconstruction may lag; the corpus proved the rule
* over-fires there (15 fixtures went missing-in-next). Opencode's
* committed channel is assembled server truth; no dangling-chip bundle
* exists for it — revisit if one appears.
*/
hideUnresolvedHistoryTools: boolean
}

export const SUPPRESSION_POLICY: Record<AgentProviderKind, SuppressionPolicy> = {
claude: { wholeTurnByMessageId: true },
codex: { wholeTurnByMessageId: false },
opencode: { wholeTurnByMessageId: false },
claude: { wholeTurnByMessageId: true, hideUnresolvedHistoryTools: true },
codex: { wholeTurnByMessageId: false, hideUnresolvedHistoryTools: false },
opencode: { wholeTurnByMessageId: false, hideUnresolvedHistoryTools: false },
}

/** The legacy collapsed_activity churn set (helpers.ts
* classifySemanticToolActivity): the only tools whose RUNNING history
* blocks null-paint. Source of truth is that classifier until Stage 3
* absorbs it; keep in sync. */
const COLLAPSIBLE_CHURN_TOOLS: ReadonlySet<string> = new Set([
'Read',
'FileRead',
'Glob',
'Grep',
'Bash',
])

export function buildCommittedOwnership(
committed: readonly RenderCandidate[],
): CommittedOwnership {
Expand Down Expand Up @@ -176,6 +203,42 @@ export function decideLiveCandidate(
evidence.push(`committed response item ${candidate.itemId}`)
return { candidateId: candidate.id, selected: false, reason: 'committed-tool-use-owned', evidence }
}
// Collapsed-running null-paint (corpus new-bug, 2026-06-29 1b2b5e96;
// legacy semanticRenderUnitPaintsDom behavior): a HISTORY tool block
// whose run never resolved anywhere — no result paired into the block
// AND no committed tool_use or tool_result trace — is a permanently-
// dangling chip ("Read · running" forever). Legacy deliberately
// withheld it; we do too, with the decision on the record. Scoped to
// history ONLY: the current turn's unresolved tools are legitimately
// awaiting results (that is what streaming looks like), and codex's
// MCP next-turn-output lifecycle resolves via a LATER turn — when the
// result eventually lands (block pairing or committed row), the
// candidate re-decides and paints, so this is never a permanent hide
// of a live run.
if (
policy.hideUnresolvedHistoryTools &&
candidate.owner === 'semantic-history' &&
candidate.resolved !== true &&
// Legacy parity is EXACTLY the collapsed_activity churn set
// (classifySemanticToolActivity): Read/Glob/Grep/Bash fold into a
// collapsed run that null-paints while running. Everything else —
// Task, Edit, AskUserQuestion, MCP tools — paints even while
// unresolved (a running Task chip is live subagent signal, not
// churn). The first, broader version of this rule hid those and the
// corpus caught it within one run: 6 claude fixtures went
// missing-in-next on legitimate running chips.
candidate.toolName !== undefined &&
COLLAPSIBLE_CHURN_TOOLS.has(candidate.toolName)
) {
const key = candidate.toolUseId ?? candidate.callId
const committedTrace =
(key !== undefined && (ownership.toolUseIds.has(key) || ownership.toolResultIds.has(key))) ||
(candidate.itemId !== undefined && ownership.itemIds.has(candidate.itemId))
if (!committedTrace) {
evidence.push('churn tool, no block result, no committed tool_use/tool_result trace')
return { candidateId: candidate.id, selected: false, reason: 'collapsed-running', evidence }
}
}
}

// Tool OUTPUT yields only to a committed tool RESULT — never to the
Expand Down
8 changes: 8 additions & 0 deletions src/renderer/src/rendering/model/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,14 @@ export type RenderCandidate = {
*/
ownedToolUseIds?: readonly string[]
ownedToolResultIds?: readonly string[]
/** Tool-use candidates only: the block carries its own result evidence
* (resultContent/resultAt landed in the semantic block). Distinguishes
* a completed tool row from a permanently-dangling one when no
* committed trace exists either (the collapsed-running rule). */
resolved?: boolean
/** Tool candidates: the provider tool name (Read, Bash, Task, …). Feeds
* the collapsed-running churn-set check and future per-tool row policy. */
toolName?: string
}

/**
Expand Down
14 changes: 14 additions & 0 deletions src/renderer/src/rendering/observations/semantic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,12 @@ export type SemanticBlockLike = {
toolUseId?: string
callId?: string
itemId?: string
/** Tool blocks: result payload already paired into the block by the
* semantic reducer (tool_result arrival). Presence of either field is
* the block-local "this run resolved" evidence. */
resultContent?: string
resultAt?: number
resultIsError?: boolean
}

export type SemanticTurnLike = {
Expand Down Expand Up @@ -148,12 +154,20 @@ function collectTurn(
itemId: b.itemId,
toolUseId: b.toolUseId,
callId: b.callId,
toolName: b.toolName,
contentKind: kind,
timestampMs,
// Sequence preserves intra-turn block order under equal timestamps.
sequence: i,
textKey: finalizedText,
normalizedTextKey: finalizedText ? normalizeTextKey(finalizedText) : undefined,
// Block-local result evidence for the ledger's collapsed-running
// rule: a HISTORY tool block with neither this nor any committed
// trace is a permanently-dangling run legacy deliberately withheld.
resolved:
kind === 'tool-use'
? Boolean(b.resultContent) || typeof b.resultAt === 'number' || b.resultIsError === true
: undefined,
})
out.decisions.push({ candidateId: id, selected: true, reason: 'selected', evidence: [] })
})
Expand Down

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Large diffs are not rendered by default.