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
26 changes: 17 additions & 9 deletions docs/rendering/rendering-rewrite-plan-2026-07.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,19 +13,27 @@ Read this first. Then read `research-2026-07/` for the receipts.

## Stage 1 progress (updated 2026-07-06, integration branch)

Landed (slices 1-8, PRs #424-#433 into `integration/rendering-pipeline`):
Landed (slices 1-11, PRs #424-#437 into `integration/rendering-pipeline`):
ledger core (types / ordering law / ownership / identity stability), committed
collector with the #338 synthetic-user filter, ghost five-rule predicate with
the 10-case matrix and failed-fix regressions, semantic collector with the
#345 compaction kill, local collectors + optimistic→committed handoff,
unknown-behavior registry with structural redaction, and four end-to-end
fixtures: buried-prompt-239, dead-committed-channel-159,
opencode-interleave-87f0eeef, plus the ghost matrix. Suite: 50 pipeline tests;
full unit project 209/209 on the branch.

Remaining before Stage 2: sidecar-tail e2e variant (unit-covered already),
unknowns wired into the live ledger pass, fold-policy yield-hatch absorption,
queue-handoff race e2e, and the runtime→collector adapter (the shadow seam).
unknown-behavior registry with structural redaction, ghost plane wired into
the ledger pass (#435), the runtime→collector adapter — the shadow seam —
with ghost collector, optimistic partition, plane-level reference stability,
and compile-time seam assertions against real runtime types (#436), the
fold-policy yield-hatch absorption (`policy/foldPolicy.ts` — the two
proxy-literal hatches from legacy foldEvent encoded as policy data with
parity tests) and six end-to-end fixtures: buried-prompt-239,
dead-committed-channel-159, opencode-interleave-87f0eeef, the ghost matrix,
sidecar-tail-ghost, and queue-handoff-race (#437). Suite: 67 pipeline tests.

**Stage 1 is COMPLETE.** Next: Stage 2 shadow wiring — call the adapter +
ledger beside the legacy renderer behind `AGENT_CODE_RENDER_SHADOW=1`, diff
outputs, divergences become fixtures. (Unknowns plumbing note: the registry
exists and `LedgerInput.unknowns` passes through; POPULATING it from real
IPC events is inherently a Stage 2 concern — unknowns are discovered at the
shadow seam, not in fixtures.)

---

Expand Down
142 changes: 142 additions & 0 deletions src/renderer/src/rendering/__tests__/fixtures.queueHandoff.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import { describe, expect, it } from 'vitest'

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

// ---------------------------------------------------------------------------
// FIXTURE: queue → optimistic → committed handoff race (#172 comment 6).
//
// D1: the queue is a COMPOSER lane, not feed rows — a queued message never
// reaches the adapter at all, so it cannot occupy a feed position it would
// later have to vacate (the legacy bug class: queued text painted as a
// pseudo-row, then jumped when dispatch re-minted it).
//
// The race this pins: dispatch mints the optimistic row and the committed
// twin lands one tick later. The invariant is POSITIONAL — the committed
// row must take exactly the slot the optimistic row held (chronological
// merge on submit wall-clock vs producer timestamp), never re-bury below
// stale history and never duplicate. Ticks simulate the real runtime
// sequence: reducers replace the arrays they touch, everything else keeps
// its reference.
// ---------------------------------------------------------------------------

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

const prompt1 = {
uuid: 'u1',
type: 'user',
timestamp: iso(T),
permissionMode: 'default',
message: { role: 'user', content: 'first prompt' },
}
const answer1 = {
uuid: 'a1',
type: 'assistant',
timestamp: iso(T + 100),
message: { id: 'msg_1', role: 'assistant', content: 'first answer' },
}

const base: Omit<RuntimeLedgerSlices, 'entries' | 'streamPhase'> = {
provider: 'claude',
sessionId: 's1',
semanticCurrent: null,
semanticHistory: [],
ghosts: new Map(),
lastJsonlEntryAtMs: T + 100,
}

describe('fixture: queue-handoff race (queued → optimistic → committed)', () => {
it('holds one position across the whole handoff, no re-bury, no duplicate', () => {
const adapter = createLedgerInputAdapter()
const ledger = createSessionLedger()

// Tick 1 — turn done, a message SITS IN THE QUEUE. The queue is a
// composer lane (D1): the adapter never sees it, so the feed shows
// exactly the two committed rows. This absence IS the assertion.
const tick1 = ledger(
adapter({ ...base, entries: [prompt1, answer1], streamPhase: 'idle' }).input,
)
expect(tick1.rows.map(r => r.candidate.id)).toEqual(['entry:u1', 'entry:a1'])

// Tick 2 — dispatch: the runtime appends the optimistic row to
// entries (submit wall-clock T+500) and enters 'submitting'. The
// optimistic row must paint AFTER all committed content — tail, not
// buried — plus the work indicator.
const optimisticRow = {
uuid: 'optimistic-codex-user:777',
type: 'user',
timestamp: iso(T + 500),
message: { role: 'user', content: 'queued follow-up' },
}
const tick2 = ledger(
adapter({ ...base, entries: [prompt1, answer1, optimisticRow], streamPhase: 'submitting' })
.input,
)
expect(tick2.rows.map(r => r.candidate.id)).toEqual([
'entry:u1',
'entry:a1',
'optimistic:optimistic-codex-user:777',
'work',
])
const optimisticIndex = tick2.rows.findIndex(
r => r.candidate.id === 'optimistic:optimistic-codex-user:777',
)

// Tick 3 — the committed twin lands (producer timestamp T+600) and
// the runtime drops the optimistic row from entries. The committed
// row must occupy EXACTLY the position its optimistic stand-in held.
const committedTwin = {
uuid: 'u2',
type: 'user',
timestamp: iso(T + 600),
permissionMode: 'default',
message: { role: 'user', content: 'queued follow-up' },
}
const tick3 = ledger(
adapter({ ...base, entries: [prompt1, answer1, committedTwin], streamPhase: 'submitting' })
.input,
)
const ids3 = tick3.rows.map(r => r.candidate.id)
expect(ids3).toEqual(['entry:u1', 'entry:a1', 'entry:u2', 'work'])
expect(ids3.indexOf('entry:u2')).toBe(optimisticIndex)
// No duplicate: the optimistic id is gone, not coexisting.
expect(ids3).not.toContain('optimistic:optimistic-codex-user:777')
})

it('handoff overlap tick (both rows present) suppresses the optimistic copy', () => {
// The runtime's optimistic-removal and JSONL-append are separate
// reducers — one tick can legitimately contain BOTH rows. The
// ownership pass must pick the committed twin and reject the
// optimistic copy by normalized text, not paint both.
const adapter = createLedgerInputAdapter()
const committedTwin = {
uuid: 'u2',
type: 'user',
timestamp: iso(T + 600),
permissionMode: 'default',
message: { role: 'user', content: 'queued follow-up ' }, // whitespace differs — normalized match
}
const optimisticRow = {
uuid: 'optimistic-codex-user:777',
type: 'user',
timestamp: iso(T + 500),
message: { role: 'user', content: 'queued follow-up' },
}
const { input } = adapter({
...base,
entries: [prompt1, answer1, committedTwin, optimisticRow],
streamPhase: 'submitting',
})
const ledger = createSessionLedger()(input)
const ids = ledger.rows.map(r => r.candidate.id)
expect(ids).toEqual(['entry:u1', 'entry:a1', 'entry:u2', 'work'])
const rejected = ledger.decisions.find(
d => d.candidateId === 'optimistic:optimistic-codex-user:777',
)
expect(rejected?.selected).toBe(false)
})
})
87 changes: 87 additions & 0 deletions src/renderer/src/rendering/__tests__/foldPolicy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { describe, expect, it } from 'vitest'

import {
canReplaceMismatchedLiveTurn,
FOLD_POLICY,
isClaimableEmptyShell,
isTerminalYieldReady,
type LiveTurnFacts,
} from '@renderer/rendering/policy/foldPolicy'

// ---------------------------------------------------------------------------
// Parity tests for the absorbed yield hatches. Every case below is a
// documented legacy behavior (foldEvent.ts + the 2026-05-16 bundles) —
// these pin the POLICY encoding to the exact literal behavior it replaced.
// ---------------------------------------------------------------------------

const codex = FOLD_POLICY.codex

const turn = (over: Partial<LiveTurnFacts>): LiveTurnFacts => ({
source: 'proxy',
endedAtMs: null,
textLength: 0,
blocks: [],
...over,
})

describe('fold policy — terminal-yield hatch (2026-05-16T19:08 class)', () => {
it('yields when every known block is terminal, even without turn_completed', () => {
const t = turn({
blocks: [
{ finalized: true }, // completed message
{ status: 'completed' }, // completed function_call — the "pending tool" trap
],
})
expect(isTerminalYieldReady(codex, t)).toBe(true)
expect(canReplaceMismatchedLiveTurn(codex, t, 'proxy')).toBe(true)
})

it('hybrid terminal markers count (18:42 class: finalized OR status completed)', () => {
expect(isTerminalYieldReady(codex, turn({ blocks: [{ finalized: true, status: 'completed' }] }))).toBe(true)
expect(isTerminalYieldReady(codex, turn({ blocks: [{ status: 'in_progress' }] }))).toBe(false)
})

it('does NOT yield while any block is still streaming (flicker defense)', () => {
const streaming = turn({ blocks: [{ finalized: true }, { status: 'in_progress' }], textLength: 40 })
expect(canReplaceMismatchedLiveTurn(codex, streaming, 'proxy')).toBe(false)
})

it('only sources in terminalYieldSources are eligible', () => {
expect(isTerminalYieldReady(codex, turn({ source: 'screen', blocks: [{ finalized: true }] }))).toBe(false)
// opencode encodes the hatch as EMPTY policy, not inherited behavior.
expect(
isTerminalYieldReady(FOLD_POLICY.opencode, turn({ source: 'opencode-sse', blocks: [{ finalized: true }] })),
).toBe(false)
})
})

describe('fold policy — empty-shell hatch (rollout placeholder class)', () => {
it('proxy may claim an empty non-proxy shell', () => {
const shell = turn({ source: 'rollout' })
expect(isClaimableEmptyShell(codex, shell)).toBe(true)
expect(canReplaceMismatchedLiveTurn(codex, shell, 'proxy')).toBe(true)
})

it('a content-bearing non-proxy turn keeps full flicker protection', () => {
expect(isClaimableEmptyShell(codex, turn({ source: 'rollout', textLength: 12 }))).toBe(false)
expect(isClaimableEmptyShell(codex, turn({ source: 'rollout', blocks: [{}] }))).toBe(false)
})

it('an empty PROXY shell is not claimable through this hatch', () => {
expect(isClaimableEmptyShell(codex, turn({ source: 'proxy' }))).toBe(false)
})
})

describe('fold policy — shared replacement rules', () => {
it('ended turns are replaceable for every provider (MCP next-turn lifecycle)', () => {
for (const p of [FOLD_POLICY.claude, FOLD_POLICY.codex, FOLD_POLICY.opencode]) {
expect(canReplaceMismatchedLiveTurn(p, turn({ endedAtMs: 123, textLength: 50 }), null)).toBe(true)
}
})

it('untrusted event sources never displace a live turn', () => {
const t = turn({ blocks: [{ finalized: true }] })
expect(canReplaceMismatchedLiveTurn(codex, t, 'screen')).toBe(false)
expect(canReplaceMismatchedLiveTurn(codex, t, null)).toBe(false)
})
})
Loading