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
8 changes: 8 additions & 0 deletions src/main/ipc/devDebug.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,13 @@ import { readRecentPasteSessions } from '../pasteDebugJournal.js'

export type DevDebugConfig = {
enabled: boolean
/** AGENT_CODE_RENDER_SHADOW=1 — Stage 2 of the rendering rewrite: run
* the new ownership-ledger pipeline beside the legacy renderer and
* record divergences (src/renderer/src/rendering/shadow/). Rides the
* dev-debug config channel instead of minting its own because this
* flag is exactly as temporary as the shadow itself — both are
* DELETE-fated at Stage 3 cutover. */
renderShadowEnabled: boolean
}

function envFlag(name: string): boolean {
Expand All @@ -25,6 +32,7 @@ export function registerDevDebugIpc(): void {
// runtime switch that works in Electron dev without requiring a
// Vite-prefixed renderer variable or rebuild-time config.
enabled: isDevDebugEnabled(),
renderShadowEnabled: envFlag('AGENT_CODE_RENDER_SHADOW'),
}
})

Expand Down
3 changes: 3 additions & 0 deletions src/preload/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ export type Unsub = () => void

export type DevDebugConfig = {
enabled: boolean
/** AGENT_CODE_RENDER_SHADOW=1 — rendering-rewrite Stage 2 shadow mode.
* Mirrors src/main/ipc/devDebug.ts (the source of truth). */
renderShadowEnabled: boolean
}

// Caffeinate payloads now live in @shared/types/caffeinate so main can own
Expand Down
137 changes: 137 additions & 0 deletions src/renderer/src/rendering/__tests__/shadowDiff.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import { describe, expect, it } from 'vitest'

import { createSessionLedger } from '@renderer/rendering/model/ledger'
import type { RenderCandidate } from '@renderer/rendering/model/types'
import {
diffShadowUnits,
ledgerUnits,
legacyUnits,
type LegacyItemLike,
} from '@renderer/rendering/shadow/shadowDiff'

// ---------------------------------------------------------------------------
// Shadow diff: the normalization must collapse REPRESENTATION differences
// (block-level vs turn-level, plane prefixes vs folded uuids) while
// preserving REAL differences (a row missing, duplicated, mis-ordered).
// A false divergence here means Stage 2 drowns in noise; a missed one
// means the cutover ships a regression the shadow was built to catch.
// ---------------------------------------------------------------------------

const T = 1_700_000_000_000

const cand = (over: Partial<RenderCandidate> & Pick<RenderCandidate, 'id' | 'sourcePlane'>): RenderCandidate => ({
owner: 'committed',
provider: 'claude',
sessionId: 's1',
contentKind: 'user-text',
timestampMs: T,
sequence: 0,
...over,
})

describe('shadow diff — normalization', () => {
it('equivalent representations produce zero divergences', () => {
// Legacy: two entries, one whole semantic turn, work row.
const legacy: LegacyItemLike[] = [
{ type: 'entry', entryUuid: 'u1' },
{ type: 'entry', entryUuid: 'a1' },
{ type: 'semantic-current', turnId: 'turn_live' },
{ type: 'work' },
]
// New: same content but the semantic turn is TWO block candidates —
// must collapse to one turn unit.
const ledger = createSessionLedger()({
provider: 'claude',
committed: [
cand({ id: 'entry:u1', sourcePlane: 'committed', timestampMs: T }),
cand({ id: 'entry:a1', sourcePlane: 'committed', timestampMs: T + 100, sequence: 1 }),
],
live: [
cand({
id: 'sem:turn_live:0',
sourcePlane: 'semantic',
owner: 'semantic-current',
turnId: 'turn_live',
contentKind: 'thinking',
timestampMs: T + 200,
sequence: 2,
}),
cand({
id: 'sem:turn_live:1',
sourcePlane: 'semantic',
owner: 'semantic-current',
turnId: 'turn_live',
contentKind: 'assistant-text',
timestampMs: T + 200,
sequence: 3,
}),
],
statics: [
cand({ id: 'work', sourcePlane: 'process', owner: 'work', contentKind: 'work', timestampMs: null, sequence: 4 }),
],
unknowns: [],
})
const result = diffShadowUnits(legacyUnits(legacy), ledgerUnits(ledger))
expect(result.divergences).toEqual([])
expect(result.next).toEqual(['row:u1', 'row:a1', 'turn:turn_live', 'work'])
})

it('folded ghost/optimistic uuids and plane prefixes meet at the same unit key', () => {
const legacy: LegacyItemLike[] = [
{ type: 'entry', entryUuid: 'optimistic-codex-user:9' },
{ type: 'entry', entryUuid: 'g-turn-0' },
]
const ledger = createSessionLedger()({
provider: 'claude',
committed: [],
live: [
cand({
id: 'optimistic:optimistic-codex-user:9',
sourcePlane: 'local-submit',
owner: 'optimistic-submit',
timestampMs: T,
}),
],
statics: [
cand({
id: 'ghost:g-turn-0',
sourcePlane: 'ghost',
owner: 'ghost-fallback',
contentKind: 'tool-use',
timestampMs: T + 10,
sequence: 1,
}),
],
unknowns: [],
})
const result = diffShadowUnits(legacyUnits(legacy), ledgerUnits(ledger))
expect(result.divergences).toEqual([])
})
})

describe('shadow diff — real divergences', () => {
const L = (ids: string[]): ReturnType<typeof legacyUnits> =>
legacyUnits(ids.map(id => ({ type: 'entry', entryUuid: id })))

it('reports missing and extra rows by set difference', () => {
const result = diffShadowUnits(L(['a', 'b']), L(['b', 'c']))
expect(result.divergences).toEqual([
{ class: 'missing-in-next', unit: 'row:a' },
{ class: 'extra-in-next', unit: 'row:c' },
])
})

it('reports only the FIRST order mismatch when sets agree (no cascade)', () => {
const result = diffShadowUnits(L(['a', 'b', 'c']), L(['c', 'a', 'b']))
expect(result.divergences).toEqual([
{ class: 'order-mismatch', index: 0, legacy: 'row:a', next: 'row:c' },
])
})

it('reports duplicate-count drift as a tail mismatch', () => {
const result = diffShadowUnits(L(['a', 'b']), L(['a', 'b', 'b']))
expect(result.divergences).toEqual([
{ class: 'order-mismatch', index: 2, legacy: '<end>', next: 'row:b' },
])
})
})
173 changes: 173 additions & 0 deletions src/renderer/src/rendering/shadow/shadowDiff.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
import type { RenderLedger } from '@renderer/rendering/model/types'

// ---------------------------------------------------------------------------
// Stage 2 shadow diff: legacy render model vs new-pipeline ledger,
// normalized to comparable units.
//
// WHY normalization exists: the two models legitimately disagree on
// granularity — the legacy renderer paints one item per SEMANTIC TURN
// (SemanticStreamingTurn owns the whole live turn) while the ledger is
// block-level; legacy folds ghosts into `entries` in a pre-pass while the
// ledger keeps them as a distinct plane. Diffing raw shapes would drown
// real divergences in representation noise. A ShadowUnit is the coarsest
// grain BOTH sides can express: a committed/optimistic/ghost row by its
// stable id, a semantic turn by turnId, and the work/empty lifecycle
// flags. Anything the two sides disagree about at THIS grain is a real
// user-visible difference — a row missing, duplicated, or mis-ordered.
//
// This module dies with the shadow at Stage 3 cutover — it deliberately
// imports legacy types nowhere (callers pass pre-projected unit inputs)
// so its tests stay hand-fixture JSON like the rest of the pipeline.
// ---------------------------------------------------------------------------

export type ShadowUnit =
| { kind: 'row'; id: string }
| { kind: 'turn'; id: string }
| { kind: 'work' }
| { kind: 'empty' }

export function unitKey(u: ShadowUnit): string {
switch (u.kind) {
case 'row':
return `row:${u.id}`
case 'turn':
return `turn:${u.id}`
case 'work':
return 'work'
case 'empty':
return 'empty'
}
}

/** Legacy FeedRenderItem → unit. Callers hand us the two fields we read
* (type + the relevant id) so this module needs no legacy imports; the
* hook does the field plucking at the call site. */
export type LegacyItemLike =
| { type: 'entry'; entryUuid: string | null }
| { type: 'semantic-history' | 'semantic-current'; turnId: string }
| { type: 'work' }
| { type: 'empty' }

export function legacyUnits(items: readonly LegacyItemLike[]): ShadowUnit[] {
const out: ShadowUnit[] = []
for (const item of items) {
switch (item.type) {
case 'entry':
// Legacy folds ghosts and optimistic rows into entries; their
// uuids (g-…, optimistic-codex-user:…) keep them distinguishable
// at the unit grain without any special-casing here.
out.push({ kind: 'row', id: item.entryUuid ?? 'no-uuid' })
break
case 'semantic-history':
case 'semantic-current':
out.push({ kind: 'turn', id: item.turnId })
break
case 'work':
out.push({ kind: 'work' })
break
case 'empty':
out.push({ kind: 'empty' })
break
}
}
return out
}

export function ledgerUnits(ledger: RenderLedger): ShadowUnit[] {
const out: ShadowUnit[] = []
let lastTurnKey: string | null = null
for (const row of ledger.rows) {
const c = row.candidate
switch (c.sourcePlane) {
case 'committed':
out.push({ kind: 'row', id: c.id.replace(/^entry:/, '') })
lastTurnKey = null
break
case 'local-submit':
out.push({ kind: 'row', id: c.id.replace(/^optimistic:/, '') })
lastTurnKey = null
break
case 'ghost':
out.push({ kind: 'row', id: c.id.replace(/^ghost:/, '') })
lastTurnKey = null
break
case 'semantic': {
// Collapse consecutive blocks of one turn into a single turn unit —
// the legacy side renders whole turns, so block-level detail is
// representation, not divergence.
const turnId = c.turnId ?? 'unknown-turn'
if (lastTurnKey !== turnId) {
out.push({ kind: 'turn', id: turnId })
lastTurnKey = turnId
}
break
}
case 'process':
out.push(c.contentKind === 'work' ? { kind: 'work' } : { kind: 'empty' })
lastTurnKey = null
break
default:
break
}
}
return out
}

export type ShadowDivergence =
| { class: 'missing-in-next'; unit: string }
| { class: 'extra-in-next'; unit: string }
| { class: 'order-mismatch'; index: number; legacy: string; next: string }

export type ShadowDiffResult = {
divergences: ShadowDivergence[]
legacy: string[]
next: string[]
}

/**
* Set difference first, order second: a missing/extra unit ALWAYS also
* shifts order, and reporting the cascade as N order-mismatches buries
* the one real finding. Order is only compared when the sets agree.
*/
export function diffShadowUnits(
legacy: readonly ShadowUnit[],
next: readonly ShadowUnit[],
): ShadowDiffResult {
const legacyKeys = legacy.map(unitKey)
const nextKeys = next.map(unitKey)
const divergences: ShadowDivergence[] = []

const legacySet = new Set(legacyKeys)
const nextSet = new Set(nextKeys)
for (const k of legacySet) {
if (!nextSet.has(k)) divergences.push({ class: 'missing-in-next', unit: k })
}
for (const k of nextSet) {
if (!legacySet.has(k)) divergences.push({ class: 'extra-in-next', unit: k })
}

if (divergences.length === 0) {
const n = Math.min(legacyKeys.length, nextKeys.length)
for (let i = 0; i < n; i++) {
if (legacyKeys[i] !== nextKeys[i]) {
divergences.push({ class: 'order-mismatch', index: i, legacy: legacyKeys[i], next: nextKeys[i] })
// First mismatch only — everything after it is the same shift.
break
}
}
// Duplicate-count drift (same set, different lengths) surfaces as an
// order mismatch at the first divergent index; if lengths differ but
// the common prefix matched, report the tail explicitly.
if (divergences.length === 0 && legacyKeys.length !== nextKeys.length) {
const i = Math.min(legacyKeys.length, nextKeys.length)
divergences.push({
class: 'order-mismatch',
index: i,
legacy: legacyKeys[i] ?? '<end>',
next: nextKeys[i] ?? '<end>',
})
}
}

return { divergences, legacy: legacyKeys, next: nextKeys }
}
Loading