From 686b94e1143e57aa3a0360b57f8cc15895af3c16 Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Fri, 24 Apr 2026 18:52:38 +0200 Subject: [PATCH] fix(workspace): render orphaned ghost fallback entries --- package.json | 1 + scripts/test-ghost-fallback.ts | 100 ++++++++++++++++++ src/renderer/src/workspace/ghosts.ts | 9 +- .../workspace/hook/ipc/useIpcSubscriptions.ts | 36 +++++++ src/renderer/src/workspace/mergedEntries.ts | 54 ++++------ 5 files changed, 163 insertions(+), 37 deletions(-) create mode 100644 scripts/test-ghost-fallback.ts diff --git a/package.json b/package.json index 11a662f13..e6025a0c8 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/scripts/test-ghost-fallback.ts b/scripts/test-ghost-fallback.ts new file mode 100644 index 000000000..790e40691 --- /dev/null +++ b/scripts/test-ghost-fallback.ts @@ -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') diff --git a/src/renderer/src/workspace/ghosts.ts b/src/renderer/src/workspace/ghosts.ts index 176cb3fec..90c4fe440 100644 --- a/src/renderer/src/workspace/ghosts.ts +++ b/src/renderer/src/workspace/ghosts.ts @@ -449,15 +449,16 @@ export function orphanStale( now: number, ttlMs: number, ): Map { - 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 + let next: Map | 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) } // ----------------------------------------------------------------------------- diff --git a/src/renderer/src/workspace/hook/ipc/useIpcSubscriptions.ts b/src/renderer/src/workspace/hook/ipc/useIpcSubscriptions.ts index 8d323be1e..2addaba80 100644 --- a/src/renderer/src/workspace/hook/ipc/useIpcSubscriptions.ts +++ b/src/renderer/src/workspace/hook/ipc/useIpcSubscriptions.ts @@ -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' @@ -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 @@ -137,6 +140,38 @@ export function useIpcSubscriptions( useEffect(() => { const worktreeCache = new Map() const recentWorkContextRawBySession = new Map() + 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) @@ -1149,6 +1184,7 @@ export function useIpcSubscriptions( }) return () => { + window.clearInterval(orphanSweepTimer) offStarted() offScreen() // No singular offEntry() — see the deleted-handler comment diff --git a/src/renderer/src/workspace/mergedEntries.ts b/src/renderer/src/workspace/mergedEntries.ts index 368604f46..3879a376c 100644 --- a/src/renderer/src/workspace/mergedEntries.ts +++ b/src/renderer/src/workspace/mergedEntries.ts @@ -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; @@ -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() + 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[] } /**