diff --git a/src/renderer/src/workspace/hook/ipc/useIpcSubscriptions.renderer.test.tsx b/src/renderer/src/workspace/hook/ipc/useIpcSubscriptions.renderer.test.tsx index c331327d6..a5ff4dd31 100644 --- a/src/renderer/src/workspace/hook/ipc/useIpcSubscriptions.renderer.test.tsx +++ b/src/renderer/src/workspace/hook/ipc/useIpcSubscriptions.renderer.test.tsx @@ -6,6 +6,7 @@ import recordedQueueHandoffBundle from '../../../../../../testing/fixtures/rende import recordedTaskNotificationBundle from '../../../../../../testing/fixtures/rendering-bundles/2026-06-21T20-14-23-131-62432945.json' import recordedCodexWorktreeWindow from '../../../../../../testing/fixtures/worktree-live-attribution/codex-0151-worktree-window.json' import recordedGitWorktrees from '../../../../../../testing/fixtures/worktree-live-attribution/git-worktree-identities.json' +import recordedDisconnectedClaude from '../../../../../../testing/fixtures/transcript-disconnect/claude-2026-06-17-a6c70f19.json' import { createFakeSessionFeed } from '@renderer/features/sessionFeed/FakeSessionFeed' import { emptyRuntime } from '@renderer/session-runtime/state' @@ -1664,4 +1665,166 @@ describe('useIpcSubscriptions with an injected SessionFeed', () => { }) expect(runtimes[sessionId]?.limitHit).toEqual({ at: resetTs + 500, source: 'api_error' }) }) + + describe('a Claude pane whose committed transcript never arrives (#290)', () => { + // Recorded: debug bundle 2026-06-17T11-50-56-053-a6c70f19. The pane + // streamed live turns while zero JSONL entries arrived, and it sat at + // transcriptStatus 'ready' the whole time. The disconnect marker only ran + // when the provider id was first seen, where zero entries is normal, and + // was built never to fire from 'ready'. So it could not fire at all. + const sessionId = 'claude-290' as SessionId + + function mount(meta: Record) { + vi.useFakeTimers() + const fake = createFakeSessionFeed() + const state = { + sessions: { [sessionId]: { cwd: '/repo', kind: 'claude', ...meta } }, + } as unknown as WorkspaceState + let runtimes: Record = { [sessionId]: emptyRuntime() } + Object.defineProperty(window, 'api', { configurable: true, value: { ghostAppend: vi.fn(), gitWorktrees: vi.fn(async () => ({ ok: false })) } }) + function Harness(): React.JSX.Element { + const refs = useRef(null) + if (refs.current === null) refs.current = makeRefs(state) + refs.current.latestRuntimesRef.current = runtimes + useIpcSubscriptions( + fake, + refs.current, + () => {}, + updater => { + runtimes = typeof updater === 'function' ? updater(runtimes) : updater + refs.current!.latestRuntimesRef.current = runtimes + }, + () => {}, + () => {}, + ) + return
+ } + const mounted = render() + const playRecordedTurn = () => act(() => { + for (const event of recordedDisconnectedClaude.turnEvents) fake.emitSemantic({ sessionId, event: event as never }) + }) + const playRecordedStartOnly = () => act(() => { + fake.emitSemantic({ sessionId, event: recordedDisconnectedClaude.turnEvents[0] as never }) + }) + return { + fake, + state, + runtime: () => runtimes[sessionId]!, + setRuntime: (next: SessionRuntime) => { runtimes = { ...runtimes, [sessionId]: next } }, + playRecordedTurn, + playRecordedStartOnly, + unmount: mounted.unmount, + } + } + + const provisional = { + providerSessionId: recordedDisconnectedClaude.providerSessionId, + providerSessionIdSource: 'proxy-header', + } + + it('says so once a finished turn has had time to commit and nothing has', () => { + const pane = mount(provisional) + pane.playRecordedTurn() + // Inside the grace period Claude may still be flushing: no alarm yet. + act(() => { vi.advanceTimersByTime(14_000) }) + expect(pane.runtime().transcriptStatus).toBe('ready') + act(() => { vi.advanceTimersByTime(1_000) }) + expect(pane.runtime().transcriptStatus).toBe('disconnected') + expect(pane.runtime().transcriptError).toContain(recordedDisconnectedClaude.providerSessionId) + }) + + it('stays quiet when the transcript lands within the grace period', () => { + const pane = mount(provisional) + pane.playRecordedTurn() + act(() => { + pane.fake.emitJsonlEntries({ + sessionId, + entries: [{ entry: { type: 'user', uuid: 'u1', message: { role: 'user', content: 'hi' } } as never, file: '/p/x.jsonl' }], + }) + }) + act(() => { vi.advanceTimersByTime(15_000) }) + expect(pane.runtime().transcriptStatus).toBe('ready') + }) + + it('keeps the first turn\'s deadline when more turns complete inside the grace', () => { + // One pending check per session (#1222 review): a busy pane completing + // a turn every few seconds must not push the verdict back forever, and + // must not stack a timer per turn. + const pane = mount(provisional) + const setTimeoutSpy = vi.spyOn(window, 'setTimeout') + pane.playRecordedTurn() + act(() => { vi.advanceTimersByTime(10_000) }) + pane.playRecordedTurn() + act(() => { vi.advanceTimersByTime(5_000) }) + expect(pane.runtime().transcriptStatus).toBe('disconnected') + // Only the commit checks use the 15 s grace; the hook's other timers do not. + expect(setTimeoutSpy.mock.calls.filter(call => call[1] === 15_000)).toHaveLength(1) + }) + + it('cancels a pending check when the subscriptions unmount', () => { + // A torn-down window must not keep a timer that later writes into + // state it no longer owns (#1222 review). + const pane = mount(provisional) + pane.playRecordedTurn() + pane.unmount() + act(() => { vi.advanceTimersByTime(15_000) }) + expect(pane.runtime().transcriptStatus).toBe('ready') + }) + + it('does not judge a turn that is still running', () => { + // #1222 review (Pi F1): the premise is "a healthy pane has entries by + // the END of a turn". A long first turn can run past 15 s before the + // tail watcher delivers the user row, so starting the clock at + // turn_started would alarm mid-turn on a pane that commits normally. + const pane = mount(provisional) + pane.playRecordedStartOnly() + act(() => { vi.advanceTimersByTime(60_000) }) + expect(pane.runtime().transcriptStatus).toBe('ready') + }) + + it('clears the warning when the transcript finally arrives', () => { + // #1222 review (Pi F4): the design leans on the record handler resetting + // the status, so a slow transcript clears the banner instead of leaving + // it stuck. Pin that here, against the real hook. + const pane = mount(provisional) + pane.playRecordedTurn() + act(() => { vi.advanceTimersByTime(15_000) }) + expect(pane.runtime().transcriptStatus).toBe('disconnected') + act(() => { + pane.fake.emitJsonlEntries({ + sessionId, + entries: [{ entry: { type: 'user', uuid: 'late-1', message: { role: 'user', content: 'hi' } } as never, file: '/p/x.jsonl' }], + }) + }) + expect(pane.runtime().transcriptStatus).toBe('ready') + }) + + it('does not warn the pane a provider switch put in its place', () => { + // #1222 review (Pi F2): a switch keeps the session id and resets the + // runtime. A Claude turn's pending check must not land a Claude-worded + // warning on the Codex pane that replaced it. + const pane = mount(provisional) + pane.playRecordedTurn() + ;(pane.state.sessions as Record)[sessionId]!.kind = 'codex' + pane.setRuntime(emptyRuntime()) + act(() => { vi.advanceTimersByTime(15_000) }) + expect(pane.runtime().transcriptStatus).toBe('ready') + }) + + it('leaves other providers alone', () => { + // The provisional proxy-header identity is a Claude mechanism; other + // providers commit on their own schedules and have their own faults. + const pane = mount({ ...provisional, kind: 'codex' }) + pane.playRecordedTurn() + act(() => { vi.advanceTimersByTime(15_000) }) + expect(pane.runtime().transcriptStatus).toBe('ready') + }) + + it('never marks a pane whose transcript identity is durable', () => { + const pane = mount({ providerSessionId: recordedDisconnectedClaude.providerSessionId }) + pane.playRecordedTurn() + act(() => { vi.advanceTimersByTime(15_000) }) + expect(pane.runtime().transcriptStatus).toBe('ready') + }) + }) }) diff --git a/src/renderer/src/workspace/hook/ipc/useIpcSubscriptions.ts b/src/renderer/src/workspace/hook/ipc/useIpcSubscriptions.ts index 14bced3ca..29ea0cdd5 100644 --- a/src/renderer/src/workspace/hook/ipc/useIpcSubscriptions.ts +++ b/src/renderer/src/workspace/hook/ipc/useIpcSubscriptions.ts @@ -421,6 +421,13 @@ const WALL_CLOCK_MS_FLOOR = 1_000_000_000_000 /** PiSession's marker for "the bridge never connected" (piSession.ts). */ +/** How long after a completed Claude turn a pane with zero committed JSONL + * entries is declared transcript-disconnected (#290). Generous on purpose: a + * healthy pane has entries before `turn_completed`, so this only has to + * cover the tail watcher's latency, and a false alarm clears itself on the + * first arriving record. */ +const TRANSCRIPT_COMMIT_GRACE_MS = 15_000 + export function useIpcSubscriptions( // WHY feed identity matters: `feed` sits in the effect's dep array, so an // unstable reference would tear down + re-attach every subscription each @@ -1212,29 +1219,19 @@ export function useIpcSubscriptions( }) setRuntimes(prev => { const current = prev[sessionId] ?? emptyRuntime() - const meta = refs.stateRef.current.sessions[sessionId] ?? null - const shouldMarkDisconnected = shouldMarkProviderSessionDisconnected(current, meta) - const next = appendFeedDebugLog( - { - ...current, - ...(shouldMarkDisconnected - ? { - transcriptStatus: 'disconnected' as const, - transcriptError: - `Claude session ${observedProvider.providerSessionId} was observed in proxy traffic, ` + - 'but no committed JSONL transcript has arrived yet.', - } - : {}), - }, - { - layer: 'SEM', - kind: 'provider_session_observed', - summary: shouldMarkDisconnected - ? 'provider session observed · transcript not yet committed' - : 'provider session observed', - data: observedProvider, - }, - ) + // No disconnect verdict here (#290). The first proxy header of a + // fresh pane arrives before Claude has written a single JSONL line, + // so "no committed entries yet" is the normal state at this moment; + // judging it here alarmed on every new pane, which is why the old + // predicate refused 'ready' and so could never fire in the real + // broken state either. The verdict is made after a completed turn + // plus a grace: see `scheduleTranscriptCommitCheck`. + const next = appendFeedDebugLog(current, { + layer: 'SEM', + kind: 'provider_session_observed', + summary: 'provider session observed', + data: observedProvider, + }) return { ...prev, [sessionId]: next } }) closeSpan({ @@ -1556,6 +1553,7 @@ export function useIpcSubscriptions( if (pendingStaleQueue !== null) { claudeQueueBySession.set(sessionId, pendingStaleQueue) } + if (semanticEvent.type === 'turn_completed') scheduleTranscriptCommitCheck(sessionId) closeSpan({ sessionId, eventType: typeof semanticEvent.type === 'string' ? semanticEvent.type : 'semantic', @@ -1565,6 +1563,63 @@ export function useIpcSubscriptions( const semanticEventQueue = new SemanticEventBackpressureQueue() let semanticFlushTimer: number | null = null + /** + * Pending "did this turn ever commit?" checks, one per session (#290). + * + * WHY at the end of a TURN and after a grace period: Claude writes its + * JSONL as the turn happens (the user row at submit, assistant rows as + * blocks finish), so by `turn_completed` a healthy pane has entries. A + * pane that has finished a turn, waited TRANSCRIPT_COMMIT_GRACE_MS more, + * and still has zero entries and no durable transcript id is the recorded + * #290 state: live streaming, nothing durable, a resume that will find + * nothing. The grace absorbs the tail watcher's latency; the first + * arriving record resets the status to 'ready' on its own, so a slow + * transcript clears the banner instead of leaving it stuck. + * + * One pending check per session (the first completed turn starts the + * clock; later turns do not push it back), cleared on unmount. + */ + const transcriptCommitChecks = new Map() + const scheduleTranscriptCommitCheck = (sessionId: SessionId): void => { + if (transcriptCommitChecks.has(sessionId)) return + const meta = refs.stateRef.current.sessions[sessionId] + // Claude only: the proxy-header provisional identity this guards is a + // Claude mechanism, and other providers commit on their own schedules. + if (!meta || (meta.kind ?? 'claude') !== 'claude') return + transcriptCommitChecks.set(sessionId, window.setTimeout(() => { + transcriptCommitChecks.delete(sessionId) + const current = refs.latestRuntimesRef.current[sessionId] + const latestMeta = refs.stateRef.current.sessions[sessionId] + // Re-checked at FIRE time too: a provider switch keeps the session id + // and resets the runtime, so a Claude turn's pending check must not + // put a Claude-worded warning on the Codex pane that replaced it. + if (!latestMeta || (latestMeta.kind ?? 'claude') !== 'claude') return + if (!current || !shouldMarkProviderSessionDisconnected(current, latestMeta)) return + setRuntimes(prev => { + const runtime = prev[sessionId] + if (!runtime || !shouldMarkProviderSessionDisconnected(runtime, refs.stateRef.current.sessions[sessionId])) return prev + const id = latestMeta?.providerSessionId + return { + ...prev, + [sessionId]: appendFeedDebugLog( + { + ...runtime, + transcriptStatus: 'disconnected', + transcriptError: + `Claude finished a turn${id ? ` in session ${id}` : ''}, ` + + 'but no committed JSONL transcript has arrived. This conversation may not resume.', + }, + { + layer: 'SEM', + kind: 'transcript_never_committed', + summary: 'turn completed · no committed transcript after grace', + data: { providerSessionId: id ?? null, graceMs: TRANSCRIPT_COMMIT_GRACE_MS }, + }, + ), + } + }) + }, TRANSCRIPT_COMMIT_GRACE_MS)) + } const flushSemanticEventQueue = (): void => { if (semanticFlushTimer !== null) { window.clearTimeout(semanticFlushTimer) @@ -2795,6 +2850,8 @@ export function useIpcSubscriptions( window.clearInterval(orphanSweepTimer) window.clearInterval(memoryGaugeTimer) if (semanticFlushTimer !== null) window.clearTimeout(semanticFlushTimer) + for (const timer of transcriptCommitChecks.values()) window.clearTimeout(timer) + transcriptCommitChecks.clear() semanticEventQueue.drain() offStarted() offInputReadiness() diff --git a/src/renderer/src/workspace/providerSessionIdentity.ts b/src/renderer/src/workspace/providerSessionIdentity.ts index 57a2c7b7a..95a420b47 100644 --- a/src/renderer/src/workspace/providerSessionIdentity.ts +++ b/src/renderer/src/workspace/providerSessionIdentity.ts @@ -167,7 +167,13 @@ export function shouldMarkProviderSessionDisconnected( ): boolean { if (!meta) return false if (hasDurableProviderSession(meta)) return false - if (runtime.transcriptStatus === 'loading' || runtime.transcriptStatus === 'ready') return false + // 'ready' IS eligible (#290): a fresh live pane sits at 'ready', which is + // exactly the state the recorded broken pane was in. The caller decides WHEN + // to ask (after a completed turn plus a grace period), which is what keeps + // this from alarming on a pane that simply has not written its first line. + // 'loading' is a history load in flight; 'error' and 'disconnected' already + // say something more specific. + if (runtime.transcriptStatus !== 'ready' && runtime.transcriptStatus !== 'idle') return false return runtime.lastJsonlEntryAt === null && runtime.totalEntries === 0 } diff --git a/src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.statusHeader.renderer.test.tsx b/src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.statusHeader.renderer.test.tsx index 959a83ed8..27601a80c 100644 --- a/src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.statusHeader.renderer.test.tsx +++ b/src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.statusHeader.renderer.test.tsx @@ -183,6 +183,25 @@ describe('AgentTerminalLeaf status header', () => { expect(container.querySelector('[data-terminal-transcript-error="true"]')).toBeNull() }) + it('warns in terminal view when a finished turn never committed a transcript', () => { + // #290 via #1222 review (A): terminal view is where a Claude pane is most + // often watched, and it never mounts TileLeaf's readiness text, so the + // disconnected marker was invisible here. It is a lifetime state (it lasts + // until a committed record arrives), so it earns the pane's banner, unlike + // the transient transcriptError diagnostics excluded above. + const disconnected = { + ...withStatus('idle'), + transcriptStatus: 'disconnected' as const, + transcriptError: 'Claude finished a turn, but no committed JSONL transcript has arrived. This conversation may not resume.', + } + const { container, rerender } = render(leaf(disconnected, true)) + expect(container.querySelector('[data-terminal-transcript-error="true"]')?.textContent) + .toContain('may not resume') + // The first committed record resets the status to 'ready'; the banner goes. + rerender(leaf({ ...disconnected, transcriptStatus: 'ready' as const }, true)) + expect(container.querySelector('[data-terminal-transcript-error="true"]')).toBeNull() + }) + it('leaves the header unlit while running when Status Mode is off', () => { // Status Mode is a user setting. Terminal view must honor it the same // way rendered panes do, rather than lighting unconditionally. diff --git a/src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.tsx b/src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.tsx index 5679b089a..bc91e2c55 100644 --- a/src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.tsx +++ b/src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.tsx @@ -60,6 +60,17 @@ type Props = { // xterm. This component is a temporary view over an agent session whose normal // owner remains the structured feed; toggling back remounts TileLeaf against // the same SessionRuntime and provider process. +/** The one warning a terminal-view pane stands over its terminal: a fault + * that lasts until something real changes. Transient transcriptError + * diagnostics are deliberately NOT here (see the banner's comment). */ +function terminalLifetimeWarning( + runtime: Pick, +): string | null { + return runtime.transcriptChannelError + ?? runtime.liveChannelWarning + ?? (runtime.transcriptStatus === 'disconnected' ? runtime.transcriptError : null) +} + export function AgentTerminalLeaf({ sessionId, paneLabel, @@ -712,7 +723,11 @@ export function AgentTerminalLeaf({ another session. Both stay true until something real changes. */} {/* liveChannelWarning is the same lifetime class for a live channel that never connected while the transcript works (Pi's bridge). */} - {(runtime.transcriptChannelError ?? runtime.liveChannelWarning) ? ( + {/* A 'disconnected' transcript is the same lifetime class (#290): it + lasts until a committed record arrives and says this conversation + may not resume. Terminal view never mounts TileLeaf's readiness + text, so without this line the marker was invisible here. */} + {terminalLifetimeWarning(runtime) ? (
- {runtime.transcriptChannelError ?? runtime.liveChannelWarning} + {terminalLifetimeWarning(runtime)}
) : null} diff --git a/testing/fixtures/transcript-disconnect/claude-2026-06-17-a6c70f19.json b/testing/fixtures/transcript-disconnect/claude-2026-06-17-a6c70f19.json new file mode 100644 index 000000000..8e4be6842 --- /dev/null +++ b/testing/fixtures/transcript-disconnect/claude-2026-06-17-a6c70f19.json @@ -0,0 +1,22 @@ +{ + "source": "debug bundle 2026-06-17T11-50-56-053-a6c70f19 (issue #290)", + "note": "Recorded Claude pane that streamed live turns while no committed JSONL ever arrived. state-snapshot at capture: totalEntries 0, lastJsonlEntryAt null, transcriptStatus 'ready'. The provider session id is the proxy's x-claude-code-session-id; no matching root JSONL existed under ~/.claude/projects.", + "providerSessionId": "184505cc-570f-4d45-8015-2145e839217c", + "turnEvents": [ + { + "type": "turn_started", + "turnId": "msg_01ENrwPGCqexzduzJnKcWJ2h", + "role": "assistant", + "source": "proxy", + "confidence": "high", + "ts": 1781696730879 + }, + { + "type": "turn_completed", + "turnId": "msg_01ENrwPGCqexzduzJnKcWJ2h", + "source": "proxy", + "confidence": "high", + "ts": 1781696731077 + } + ] +} \ No newline at end of file diff --git a/tsconfig.web.json b/tsconfig.web.json index 8ea09c50b..264b218b2 100644 --- a/tsconfig.web.json +++ b/tsconfig.web.json @@ -56,6 +56,7 @@ // Recorded Claude prompt-acceptance shapes: optimistic-prompt ownership // tests read the real pasted-prompt row rather than an invented one. "testing/fixtures/prompt-acceptance/*.json", + "testing/fixtures/transcript-disconnect/*.json", "src/control-sdk/**/*", "src/renderer/**/*", // Renderer specs use the DOM matchers installed by the Vitest setup module. The setup lived