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
115 changes: 115 additions & 0 deletions src/renderer/src/rendering/__tests__/semantic.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { describe, expect, it } from 'vitest'

import {
collectSemanticCandidates,
type SemanticTurnLike,
} from '@renderer/rendering/observations/semantic'

const T0 = 1_700_000_000_000

function turn(over: Partial<SemanticTurnLike> = {}): SemanticTurnLike {
return {
turnId: 'turn-1',
source: 'proxy',
startedAtMs: T0,
endedAtMs: T0 + 100,
blocks: [{ blockIndex: 0, kind: 'text', text: 'answer', finalized: true }],
...over,
}
}

describe('semantic collector', () => {
it('history orders by endedAt, current by startedAt (the D4 timestamp choice)', () => {
const { candidates } = collectSemanticCandidates(
turn({ turnId: 'cur', startedAtMs: T0 + 500, endedAtMs: null }),
[turn({ turnId: 'old', startedAtMs: T0, endedAtMs: T0 + 100 })],
'codex',
's1',
)
expect(candidates.find(c => c.turnId === 'old')?.timestampMs).toBe(T0 + 100)
expect(candidates.find(c => c.turnId === 'cur')?.timestampMs).toBe(T0 + 500)
expect(candidates.find(c => c.turnId === 'cur')?.owner).toBe('semantic-current')
})

it('#345: compaction synthesis yields ZERO candidates — every block rejected with reason', () => {
const { candidates, decisions } = collectSemanticCandidates(
turn({ isCompactionSynthesis: true, blocks: [
{ blockIndex: 0, kind: 'text', text: '<analysis>…internal…</analysis>' },
{ blockIndex: 1, kind: 'text', text: '<summary>…</summary>' },
] }),
[],
'claude',
's1',
)
expect(candidates).toHaveLength(0)
expect(decisions.every(d => d.reason === 'compaction-synthesis')).toBe(true)
})

it('dump invariant 14: a history turn with the current turn id is dropped with reason', () => {
const { candidates, decisions } = collectSemanticCandidates(
turn({ turnId: 'same' }),
[turn({ turnId: 'same' })],
'codex',
's1',
)
// Only the current turn's block survives.
expect(candidates).toHaveLength(1)
expect(candidates[0].owner).toBe('semantic-current')
expect(decisions.filter(d => d.reason === 'duplicate-turn-in-history')).toHaveLength(1)
})

it('empty thinking is not a renderable unit; substantive thinking is', () => {
const { candidates, decisions } = collectSemanticCandidates(
turn({ blocks: [
{ blockIndex: 0, kind: 'thinking', text: ' ' },
{ blockIndex: 1, kind: 'reasoning', text: 'real reasoning' },
] }),
[],
'opencode',
's1',
)
expect(candidates.map(c => c.blockIndex)).toEqual([1])
expect(decisions.find(d => d.candidateId === 'sem:turn-1:0')?.reason).toBe('empty-thinking')
})

it('empty write_stdin is Codex poll noise; non-empty renders (dump invariants 12/13)', () => {
const { candidates } = collectSemanticCandidates(
turn({ blocks: [
{ blockIndex: 0, kind: 'custom_tool_call', toolName: 'write_stdin', text: '' },
{ blockIndex: 1, kind: 'custom_tool_call', toolName: 'write_stdin', text: 'y\n' },
] }),
[],
'codex',
's1',
)
expect(candidates.map(c => c.blockIndex)).toEqual([1])
})

it('text keys only on FINALIZED text — streaming text must not be suppressible mid-growth', () => {
const { candidates } = collectSemanticCandidates(
turn({ blocks: [
{ blockIndex: 0, kind: 'text', text: 'still growing', finalized: false },
{ blockIndex: 1, kind: 'text', text: 'done text', finalized: true },
] }),
[],
'codex',
's1',
)
expect(candidates.find(c => c.blockIndex === 0)?.textKey).toBeUndefined()
expect(candidates.find(c => c.blockIndex === 1)?.textKey).toBe('done text')
})

it('tool identity keys (toolUseId/callId/itemId) ride the candidate for ownership matching', () => {
const { candidates } = collectSemanticCandidates(
turn({ blocks: [
{ blockIndex: 0, kind: 'function_call', callId: 'call_1', itemId: 'item_1' },
{ blockIndex: 1, kind: 'tool_use', toolUseId: 'tu_1' },
] }),
[],
'codex',
's1',
)
expect(candidates[0]).toMatchObject({ contentKind: 'tool-use', callId: 'call_1', itemId: 'item_1' })
expect(candidates[1]).toMatchObject({ contentKind: 'tool-use', toolUseId: 'tu_1' })
})
})
170 changes: 170 additions & 0 deletions src/renderer/src/rendering/observations/semantic.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
import type { AgentProviderKind } from '@shared/types/providerKind'
import { normalizeTextKey } from '@renderer/rendering/observations/committed'
import type {
OwnershipDecision,
RenderCandidate,
} from '@renderer/rendering/model/types'

// ---------------------------------------------------------------------------
// Semantic candidate collector — live turns (current + history bridge) into
// BLOCK-level candidates (plan D2). Like the committed collector, the input
// is structural-narrow: this module owns exactly the fields it reads, and
// the shadow slice verifies the translation against real runtime state.
//
// Model-level guards that used to live scattered in React (renderUnits /
// StreamingTurn null-returns) become decisions here, so debug can never
// claim a semantic row exists that would paint nothing (first-principles:
// "debug must not lie about ownership"):
// - compaction synthesis never yields candidates (#345: raw <analysis>/
// <summary> XML rendered as assistant prose when the proxy detector's
// request signature drifted — the model now refuses it regardless of
// how the flag was detected)
// - empty thinking blocks are not renderable units
// - empty write_stdin is Codex poll noise, not content
// - a history turn with the current turn's id is dropped (dump invariant
// 14 — history/current must never double-own one turn)
// ---------------------------------------------------------------------------

export type SemanticBlockLike = {
blockIndex: number
kind: string
text?: string
finalized?: boolean
toolName?: string
toolUseId?: string
callId?: string
itemId?: string
}

export type SemanticTurnLike = {
turnId: string
source?: string
startedAtMs: number | null
endedAtMs: number | null
isCompactionSynthesis?: boolean
blocks: readonly SemanticBlockLike[]
}

export type SemanticCollection = {
candidates: RenderCandidate[]
decisions: OwnershipDecision[]
}

const TOOL_USE_KINDS = new Set([
'tool_use', 'server_tool_use', 'mcp_tool_use', 'function_call', 'custom_tool_call',
])
const TOOL_RESULT_KINDS = new Set([
'tool_result', 'function_call_output', 'custom_tool_call_output', 'tool_search_output',
])
const THINKING_KINDS = new Set(['thinking', 'reasoning'])

function blockContentKind(b: SemanticBlockLike): RenderCandidate['contentKind'] {
if (TOOL_USE_KINDS.has(b.kind)) return 'tool-use'
if (TOOL_RESULT_KINDS.has(b.kind)) return 'tool-result'
if (THINKING_KINDS.has(b.kind)) return 'thinking'
return 'assistant-text'
}

function collectTurn(
turn: SemanticTurnLike,
owner: 'semantic-current' | 'semantic-history',
provider: AgentProviderKind,
sessionId: string,
out: SemanticCollection,
): void {
// WHY the timestamp choice (plan D4 + feed-render-item-plan): history
// orders by when the turn ENDED (its content is complete; the ordering
// law compares endedAt against prompt timestamps), current orders by when
// it STARTED (it is still growing; started-after-the-prompt is what puts
// it below the prompt).
const timestampMs =
owner === 'semantic-history'
? turn.endedAtMs ?? turn.startedAtMs
: turn.startedAtMs

if (turn.isCompactionSynthesis === true) {
for (const b of turn.blocks) {
out.decisions.push({
candidateId: `sem:${turn.turnId}:${b.blockIndex}`,
selected: false,
reason: 'compaction-synthesis',
evidence: ['turn flagged isCompactionSynthesis — raw synthesis never renders (#345)'],
})
}
return
}

turn.blocks.forEach((b, i) => {
const id = `sem:${turn.turnId}:${b.blockIndex}`
const kind = blockContentKind(b)

if (kind === 'thinking' && !(b.text && b.text.trim().length > 0)) {
out.decisions.push({ candidateId: id, selected: false, reason: 'empty-thinking', evidence: [] })
return
}
// Codex uses empty write_stdin as poll/continuation noise (dump
// invariant 12/13): empty renders nothing, non-empty renders.
if (b.toolName === 'write_stdin' && !(b.text && b.text.length > 0)) {
out.decisions.push({ candidateId: id, selected: false, reason: 'empty-write-stdin', evidence: [] })
return
}

// Text ownership keys ONLY on finalized text (dump: suppression applies
// to "finalized/completed semantic text only"). A still-streaming block
// that currently equals committed text may legitimately keep growing —
// suppressing it mid-stream blanked live output in production.
const finalizedText =
kind === 'assistant-text' && b.finalized === true && b.text ? b.text : undefined

out.candidates.push({
id,
owner,
provider,
sourcePlane: 'semantic',
source: turn.source,
sessionId,
turnId: turn.turnId,
blockIndex: b.blockIndex,
itemId: b.itemId,
toolUseId: b.toolUseId,
callId: b.callId,
contentKind: kind,
timestampMs,
// Sequence preserves intra-turn block order under equal timestamps.
sequence: i,
textKey: finalizedText,
normalizedTextKey: finalizedText ? normalizeTextKey(finalizedText) : undefined,
})
out.decisions.push({ candidateId: id, selected: true, reason: 'selected', evidence: [] })
})
}

export function collectSemanticCandidates(
current: SemanticTurnLike | null,
history: readonly SemanticTurnLike[],
provider: AgentProviderKind,
sessionId: string,
): SemanticCollection {
const out: SemanticCollection = { candidates: [], decisions: [] }

for (const turn of history) {
// Dump invariant 14: semantic history must drop the current turn's id —
// otherwise history and current double-own one turn and the feed paints
// it twice. Rejection recorded per block so the ledger explains it.
if (current && turn.turnId === current.turnId) {
for (const b of turn.blocks) {
out.decisions.push({
candidateId: `sem:${turn.turnId}:${b.blockIndex}`,
selected: false,
reason: 'duplicate-turn-in-history',
evidence: [`turn ${turn.turnId} is the live current turn`],
})
}
continue
}
collectTurn(turn, 'semantic-history', provider, sessionId, out)
}
if (current) collectTurn(current, 'semantic-current', provider, sessionId, out)

return out
}