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
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"testing:rendering:build": "electron-vite build --config testing/rendering/electron.vite.config.ts",
"testing:rendering:preview": "electron testing/rendering/out/main/main.js",
"test:work-context": "tsx --tsconfig tsconfig.web.json scripts/test-work-context.ts",
"test:ghost-fallback": "tsx --tsconfig tsconfig.web.json scripts/test-ghost-fallback.ts",
"proxy-demo-bootstrap": "npm --prefix packages/claude-code-headless run proxy-test-bootstrap",
"proxy-demo": "npm --prefix packages/claude-code-headless run build && electron experiments/claude-proxy-stream-app/main.mjs",
"postinstall": "electron-rebuild -f -w node-pty"
Expand Down
100 changes: 100 additions & 0 deletions scripts/test-ghost-fallback.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import assert from 'node:assert/strict'

import {
createGhost,
orphanGhost,
supersedeGhost,
type ClaudeEntry,
} from 'agent-transcript-parser/ghost'

import { orphanStale } from '../src/renderer/src/workspace/ghosts'
import { selectMergedEntries } from '../src/renderer/src/workspace/mergedEntries'
import { emptyRuntime } from '../src/renderer/src/workspace/workspaceState'

function baseEntry(uuid: string): ClaudeEntry {
return {
type: 'assistant',
uuid,
parentUuid: null,
sessionId: 'session-1',
timestamp: '2026-04-24T00:00:00.000Z',
message: {
id: `msg-${uuid}`,
role: 'assistant',
content: [{ type: 'text', text: `committed ${uuid}` }],
},
} as ClaudeEntry
}

function textGhost(turnId: string, text: string) {
return createGhost({
sessionId: 'session-1',
turnId,
blockIndex: 0,
role: 'assistant',
content: [{ type: 'text', text }],
})
}

{
const runtime = emptyRuntime()
runtime.entries = [baseEntry('real-1')]
const merged = selectMergedEntries(runtime, null)
assert.equal(merged, runtime.entries)
}

{
const runtime = emptyRuntime()
runtime.entries = [baseEntry('real-1')]
const ghost = textGhost('turn-a', 'not ready yet')
runtime.ghosts = new Map([[ghost.uuid, ghost]])

const merged = selectMergedEntries(runtime, null)
assert.equal(merged, runtime.entries)
}

{
const runtime = emptyRuntime()
runtime.entries = [baseEntry('real-1')]
const ghost = orphanGhost(textGhost('turn-a', 'jsonl never wrote'), 2000)
runtime.ghosts = new Map([[ghost.uuid, ghost]])

const merged = selectMergedEntries(runtime, null)
assert.equal(merged.length, 2)
assert.equal(merged[1]?.uuid, ghost.uuid)
}

{
const runtime = emptyRuntime()
runtime.entries = [baseEntry('real-1')]
const ghost = orphanGhost(textGhost('turn-a', 'current live turn'), 2000)
runtime.ghosts = new Map([[ghost.uuid, ghost]])

const merged = selectMergedEntries(runtime, 'turn-a')
assert.equal(merged, runtime.entries)
}

{
const runtime = emptyRuntime()
runtime.entries = [baseEntry('real-1')]
const ghost = orphanGhost(
supersedeGhost(textGhost('turn-a', 'already committed'), 'real-1', 2000),
3000,
)
runtime.ghosts = new Map([[ghost.uuid, ghost]])

const merged = selectMergedEntries(runtime, null)
assert.equal(merged, runtime.entries)
}

{
const fresh = textGhost('turn-a', 'fresh')
const freshMap = new Map([[fresh.uuid, fresh]])
assert.equal(orphanStale(freshMap, fresh._atp.updatedAt + 500, 1000), freshMap)

const staleMap = orphanStale(freshMap, fresh._atp.updatedAt + 2000, 1000)
assert.notEqual(staleMap, freshMap)
assert.ok(staleMap.get(fresh.uuid)?._atp.orphanedAt !== undefined)
}

console.log('ghost fallback tests passed')
9 changes: 5 additions & 4 deletions src/renderer/src/workspace/ghosts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -449,15 +449,16 @@ export function orphanStale(
now: number,
ttlMs: number,
): Map<string, GhostEntry> {
if (prev.size === 0) return new Map(prev)
const next = new Map(prev)
for (const [uuid, ghost] of next) {
if (prev.size === 0) return prev as Map<string, GhostEntry>
let next: Map<string, GhostEntry> | null = null
for (const [uuid, ghost] of prev) {
if (ghost._atp.supersededBy !== undefined) continue
if (ghost._atp.orphanedAt !== undefined) continue
if (ghost._atp.updatedAt + ttlMs >= now) continue
if (next === null) next = new Map(prev)
next.set(uuid, orphanGhostRecord(ghost, now))
}
return next
return next ?? (prev as Map<string, GhostEntry>)
}

// -----------------------------------------------------------------------------
Expand Down
36 changes: 36 additions & 0 deletions src/renderer/src/workspace/hook/ipc/useIpcSubscriptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import { pickerEqual } from '@renderer/workspace/layout/helpers'
import {
ghostsFromSemanticTurn,
ghostsToPersist,
orphanStale,
reconcileUpstream,
} from '@renderer/workspace/ghosts'
import type { StreamPhase } from '@renderer/workspace/workspaceState'
Expand Down Expand Up @@ -88,6 +89,8 @@ type WorktreeCacheEntry = {

const WORKTREE_CACHE_TTL_MS = 5000
const WORK_CONTEXT_RECENT_RAW_LIMIT = 500
const GHOST_ORPHAN_TTL_MS = 3000
const GHOST_ORPHAN_SWEEP_MS = 1000

function canReduceWorkContext(raw: unknown, hasWorktreeCache: boolean): boolean {
const type = (raw as { type?: unknown })?.type
Expand Down Expand Up @@ -137,6 +140,38 @@ export function useIpcSubscriptions(
useEffect(() => {
const worktreeCache = new Map<string, WorktreeCacheEntry>()
const recentWorkContextRawBySession = new Map<SessionId, unknown[]>()
const orphanSweepTimer = window.setInterval(() => {
const now = Date.now()
setRuntimes(prev => {
let changed = false
const next = { ...prev }
for (const [sessionId, runtime] of Object.entries(prev)) {
if (runtime.ghosts.size === 0) continue
const nextGhosts = orphanStale(runtime.ghosts, now, GHOST_ORPHAN_TTL_MS)
if (nextGhosts === runtime.ghosts) continue
for (const ghost of ghostsToPersist(runtime.ghosts, nextGhosts)) {
window.api.ghostAppend(sessionId, ghost)
}
next[sessionId] = appendFeedDebugLog(
{ ...runtime, ghosts: nextGhosts },
{
layer: 'STATE',
kind: 'ghost_orphan_sweep',
summary: 'stale ghosts marked orphaned',
data: {
ghostCount: nextGhosts.size,
orphanedCount: [...nextGhosts.values()].filter(
ghost => ghost._atp.orphanedAt !== undefined &&
runtime.ghosts.get(ghost.uuid)?._atp.orphanedAt === undefined,
).length,
},
},
)
changed = true
}
return changed ? next : prev
})
}, GHOST_ORPHAN_SWEEP_MS)
const refreshWorktrees = (cwd: string | null | undefined): void => {
if (!cwd) return
const cached = worktreeCache.get(cwd)
Expand Down Expand Up @@ -1149,6 +1184,7 @@ export function useIpcSubscriptions(
})

return () => {
window.clearInterval(orphanSweepTimer)
offStarted()
offScreen()
// No singular offEntry() — see the deleted-handler comment
Expand Down
54 changes: 21 additions & 33 deletions src/renderer/src/workspace/mergedEntries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@

import type { Entry } from '@shared/types/transcript'
import type { SessionRuntime } from '@renderer/workspace/workspaceState'
import type { GhostEntry } from 'agent-transcript-parser/ghost'
import { mergeWithUpstream } from 'agent-transcript-parser/ghost'

/**
* Merged entry list for rendering. Superseded ghosts are dropped;
Expand Down Expand Up @@ -49,45 +51,31 @@ export function selectMergedEntries(
const { ghosts, entries } = runtime
if (ghosts.size === 0) return entries

// 2026-04-20 immediate feed-order sanity fix:
// Render ghosts only after the orphan timeout has fired. Plain
// unsuperseded ghosts are the normal handoff window between live
// semantic streaming and JSONL; showing them immediately was the
// source of duplicate/wrong-order feed rows. Orphaned ghosts are
// different: the authoritative JSONL has not arrived in time, so
// the ghost is the only surviving record of what the proxy observed.
//
// The old merge path still let NON-current ghosts render through
// `mergeWithUpstream`, which appends unsuperseded ghosts after the
// upstream tail. In practice that meant an older assistant answer
// still represented by a ghost could appear BELOW newer committed
// tool rows and even below the user's latest message. The session
// log for 69e61aa3-38af-... showed exactly that:
//
// entry:g-019dab01-... (older ghost answer)
// semantic:019dab02-... (current live turn)
//
// That ordering is irreparably wrong for the main feed. Until we
// implement ordered ghost insertion (place ghosts near their logical
// transcript position instead of blindly tail-appending), the feed
// takes the conservative route:
//
// - current turn: rendered ONLY by SemanticStreamingTurn
// - non-current ghosts: hidden from the main feed
//
// Ghosts still exist in runtime state, persist to disk, and remain
// visible in debug tooling; we are only suppressing them from the
// user-facing transcript surface to preserve chronological order.
//
// This means `selectMergedEntries` currently returns the committed
// transcript only. Keeping the ghost scan + commentary here makes the
// intended next step explicit instead of pretending ghosts are gone.
let hasCurrentGhost = false
let hasNonCurrentGhost = false
for (const ghost of ghosts.values()) {
// Current-turn ghosts are still excluded because
// SemanticStreamingTurn owns the active live view. When that turn
// completes and currentTurn becomes null, orphaned ghosts can step
// in as the fallback transcript.
const visibleGhosts = new Map<string, GhostEntry>()
for (const [uuid, ghost] of ghosts) {
if (ghost._atp.supersededBy !== undefined) continue
if (ghost._atp.orphanedAt === undefined) continue
if (currentTurnId !== null && ghost._atp.turnId === currentTurnId) {
hasCurrentGhost = true
continue
}
hasNonCurrentGhost = true
visibleGhosts.set(uuid, ghost)
}
if (!hasCurrentGhost && !hasNonCurrentGhost) return entries
return entries
if (visibleGhosts.size === 0) return entries

return mergeWithUpstream(entries, visibleGhosts, {
trustSupersededFlag: true,
}) as Entry[]
}

/**
Expand Down