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

import {
decideGhostCandidate,
SIDECAR_GHOST_TEXT_MAX,
type GhostPredicateContext,
type GhostPredicateInput,
} from '@renderer/rendering/model/ghostPredicate'

// The canonical 10-case matrix from the 2026-05-07 predicate plan, plus the
// four documented failed-fix regressions as named cases. Fixed wall-clock
// anchors, never Date.now() (resume comparisons live in producer time).
const T_TAIL = 1_700_000_000_000
const NEWER = T_TAIL + 5_000
const OLDER = T_TAIL - 5_000

function ghost(over: Partial<GhostPredicateInput> = {}): GhostPredicateInput {
return {
id: 'g-1',
turnId: 'turn-1',
superseded: false,
orphaned: true,
updatedAtMs: NEWER,
assistantSingleTextLength: 500, // substantive by default
toolUse: false,
...over,
}
}

function ctx(over: Partial<GhostPredicateContext> = {}): GhostPredicateContext {
return {
lastJsonlEntryAtMs: T_TAIL,
semanticOwnedTurnIds: new Set<string>(),
...over,
}
}

describe('ghost five-rule predicate (2026-05-07 matrix)', () => {
it('case: superseded ghost never renders (rule 1)', () => {
const d = decideGhostCandidate(ghost({ superseded: true }), ctx())
expect(d).toMatchObject({ selected: false, reason: 'ghost-superseded' })
})

it('case: unorphaned ghost never renders — the turn_completed→JSONL race guard (rule 2)', () => {
const d = decideGhostCandidate(ghost({ orphaned: false }), ctx())
expect(d).toMatchObject({ selected: false, reason: 'ghost-not-orphaned' })
})

it('case: semantic-owned turn hides the ghost (rule 3 — dies with SemanticStreamingTurn at cutover)', () => {
const d = decideGhostCandidate(ghost(), ctx({ semanticOwnedTurnIds: new Set(['turn-1']) }))
expect(d).toMatchObject({ selected: false, reason: 'ghost-semantic-owned' })
})

it('case: sidecar leak older than the tail hides (rule 4) — the 686b94e regression', () => {
// 686b94e rendered ALL orphans: title-gen fragments from t-5000 parked
// at the feed bottom forever. Rule 4 kills the stale class.
const d = decideGhostCandidate(ghost({ updatedAtMs: OLDER, assistantSingleTextLength: 30 }), ctx())
expect(d).toMatchObject({ selected: false, reason: 'ghost-older-than-jsonl' })
})

it('case: equal-to-tail is NOT newer — strictly-greater comparison', () => {
const d = decideGhostCandidate(ghost({ updatedAtMs: T_TAIL }), ctx())
expect(d).toMatchObject({ selected: false, reason: 'ghost-older-than-jsonl' })
})

it('case: THE fallback case — committed stalled past live, substantive orphan RENDERS', () => {
const d = decideGhostCandidate(ghost(), ctx())
expect(d.selected).toBe(true)
})

it('case: tail sidecar defeats rule 4 and dies on rule 5 — why the single-rule design was overturned', () => {
// Real commit t=100, predict-next-prompt sidecar t=105, user walks
// away: updatedAt > tail yet it is garbage. The dominant production
// failure mode; rules 4 AND 5 stay together forever.
const d = decideGhostCandidate(ghost({ assistantSingleTextLength: 41 }), ctx())
expect(d).toMatchObject({ selected: false, reason: 'ghost-sidecar-shape' })
})

it('case: tool_use ghost renders even when short — structurally never sidecar-shaped', () => {
const d = decideGhostCandidate(
ghost({ toolUse: true, assistantSingleTextLength: null }),
ctx(),
)
expect(d.selected).toBe(true)
})

it('case: fresh session (null tail) does not gate on rule 4 but still runs the shape rule', () => {
const fresh = ctx({ lastJsonlEntryAtMs: null })
expect(decideGhostCandidate(ghost(), fresh).selected).toBe(true)
expect(decideGhostCandidate(ghost({ assistantSingleTextLength: 12 }), fresh)).toMatchObject({
selected: false,
reason: 'ghost-sidecar-shape',
})
})

it('documented trade-off: a real crashed short "Done." turn is invisibly lost — accepted, forever explicit', () => {
const d = decideGhostCandidate(ghost({ assistantSingleTextLength: 5 }), ctx())
expect(d).toMatchObject({ selected: false, reason: 'ghost-sidecar-shape' })
})

it('boundary: exactly SIDECAR_GHOST_TEXT_MAX hides; one char over renders', () => {
expect(decideGhostCandidate(ghost({ assistantSingleTextLength: SIDECAR_GHOST_TEXT_MAX }), ctx()).selected).toBe(false)
expect(decideGhostCandidate(ghost({ assistantSingleTextLength: SIDECAR_GHOST_TEXT_MAX + 1 }), ctx()).selected).toBe(true)
})

it('every rejection carries evidence — the ledger explains itself (#344)', () => {
const rejected = [
decideGhostCandidate(ghost({ superseded: true }), ctx()),
decideGhostCandidate(ghost({ updatedAtMs: OLDER }), ctx()),
decideGhostCandidate(ghost({ assistantSingleTextLength: 10 }), ctx()),
]
for (const d of rejected) expect(d.evidence.length).toBeGreaterThan(0)
})
})
123 changes: 123 additions & 0 deletions src/renderer/src/rendering/model/ghostPredicate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import type { OwnershipDecision } from '@renderer/rendering/model/types'

// ---------------------------------------------------------------------------
// The ghost five-rule render predicate, ported as ledger decisions.
//
// Provenance: docs/design/ghost-system.md + the 2026-05-07 findings/predicate
// docs. Every rule is the scar of a shipped regression (four documented
// failed fixes: 10e4fc5 hid all → lost fallback; 686b94e rendered orphans →
// sidecar pileup; 2a83978 shape filter alone → longer sidecars leak;
// hide-all v2 → lost stuck-mid-turn recovery). Do not "simplify" any rule
// without reading research-2026-07/historical-plans-extraction.md first.
//
// Ghost fallback exists for exactly one condition: committed truth stalled
// PAST the live stream (JSONL stuck mid-turn, or crash+resume with ghost
// log newer than the transcript tail). Everything else must reject.
//
// Rule 3 (`semanticOwnedTurnIds`) carries a death sentence: it exists only
// because SemanticStreamingTurn owns the live turn in the legacy renderer.
// At Stage 3 cutover it is deleted TOGETHER with SemanticStreamingTurn
// (plan D6) — the ledger then owns live turns itself and the double-render
// hazard rule 3 guards against disappears structurally.
// ---------------------------------------------------------------------------

export type GhostPredicateInput = {
id: string
turnId: string
/** _atp.supersededBy — authoritative row landed. */
superseded: boolean
/** _atp.orphanedAt — TTL (30s) elapsed without a committed match. TTL
* semantics: "how long before we conclude committed had its chance",
* NOT "how fast we paint" — a 5000-line Read produces a quiet window
* that a short TTL mis-orphaned (that is why 3s failed in production). */
orphaned: boolean
/** Producer wall-clock of the ghost's last update. */
updatedAtMs: number
/** Assistant role + exactly one text block → sidecar-shaped candidate. */
assistantSingleTextLength: number | null
toolUse: boolean
}

export type GhostPredicateContext = {
/** Newest committed entry timestamp observed — producer clock, null on a
* fresh session. NULL IS NOT ZERO: a 0 sentinel makes rule 4 always pass
* and renders ghosts on brand-new sessions (documented invariant). */
lastJsonlEntryAtMs: number | null
/** Turn ids owned by semantic current/history (legacy rule 3). */
semanticOwnedTurnIds: ReadonlySet<string>
}

/**
* Empirical anchor from bundle 2026-05-07T08-26-35-212: max observed sidecar
* turn 41 chars, min real assistant turn 76 chars → 200 leaves margin both
* ways. The documented accepted trade-off rides with it: a real crashed
* turn that was a single short text block ("Done.") is invisibly lost —
* rare loss accepted over daily sidecar pileup. tool_use ghosts are exempt:
* structurally never sidecar-shaped, render even when short.
*/
export const SIDECAR_GHOST_TEXT_MAX = 200

export function decideGhostCandidate(
ghost: GhostPredicateInput,
ctx: GhostPredicateContext,
): OwnershipDecision {
const base = { candidateId: ghost.id }

// Rule 1 — superseded: authoritative row exists; never resurrect a
// provisional copy after the real one landed.
if (ghost.superseded) {
return { ...base, selected: false, reason: 'ghost-superseded', evidence: ['supersededBy set'] }
}
// Rule 2 — not yet orphaned: committed still has its chance. This is also
// the ONLY guard in the ~100ms turn_completed→JSONL race window — rules
// 1/3/4 all pass in that window; TTL is what prevents flicker there.
if (!ghost.orphaned) {
return { ...base, selected: false, reason: 'ghost-not-orphaned', evidence: ['TTL not elapsed'] }
}
// Rule 3 — semantic owns the turn (legacy dual-owner guard; dies at cutover).
if (ctx.semanticOwnedTurnIds.has(ghost.turnId)) {
return { ...base, selected: false, reason: 'ghost-semantic-owned', evidence: [`turn ${ghost.turnId} live`] }
}
// Rule 4 — timestamp gate: render only when the ghost is NEWER than the
// committed tail (committed stalled past live). Strictly-greater on
// producer clocks; null tail (fresh session) does not gate — falls
// through to rule 5, never auto-renders.
if (ctx.lastJsonlEntryAtMs !== null && ghost.updatedAtMs <= ctx.lastJsonlEntryAtMs) {
return {
...base,
selected: false,
reason: 'ghost-older-than-jsonl',
evidence: [`ghost ${ghost.updatedAtMs} <= tail ${ctx.lastJsonlEntryAtMs}`],
}
}
// Rule 5 — sidecar shape backstop. Rule 4 alone CANNOT distinguish the
// tail-sidecar case: real commit at t=100, predict-next-prompt sidecar at
// t=105, user walks away — updatedAt > tail, yet it is garbage. This was
// the dominant production failure mode and the reason the single-rule
// (timestamp-only) design from the findings doc was overturned. Both
// rules stay, forever, together.
if (
!ghost.toolUse &&
ghost.assistantSingleTextLength !== null &&
ghost.assistantSingleTextLength <= SIDECAR_GHOST_TEXT_MAX
) {
return {
...base,
selected: false,
reason: 'ghost-sidecar-shape',
evidence: [`single text block ${ghost.assistantSingleTextLength} <= ${SIDECAR_GHOST_TEXT_MAX}`],
}
}

return {
...base,
selected: true,
reason: 'selected',
evidence: [
'orphaned, unsuperseded, not semantic-owned',
ctx.lastJsonlEntryAtMs === null
? 'fresh session (null tail)'
: `newer than committed tail by ${ghost.updatedAtMs - ctx.lastJsonlEntryAtMs}ms`,
],
}
}