From 83679214f1cf4ef35364c07ca93f4ee38c7d73a5 Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Thu, 24 Sep 2026 22:55:42 -0700 Subject: [PATCH 1/3] fix(claude): say so when a finished turn never commits a transcript A Claude pane can stream live turns while no committed JSONL ever arrives (debug bundle 2026-06-17T11-50-56-053-a6c70f19: totalEntries 0, transcriptStatus 'ready'). Resume then finds nothing. The disconnect marker existed but could not fire: it was only evaluated when the provider id was first seen, where zero entries is normal, so it was written to refuse 'ready', which is the state the broken pane was in. The check now runs after a completed Claude turn plus a 15 s grace: a healthy pane has JSONL before turn_completed, so still-zero entries and no durable identity at that point is the recorded failure. The first arriving record already resets the status to 'ready', so a slow transcript clears the banner instead of leaving it stuck. Claude only, one pending check per session, cleared on unmount. Tests replay the recorded turn from the bundle (fixture testing/fixtures/transcript-disconnect/): marked after the grace and not before, quiet if a record lands in time, never for a durable identity or another provider. Each guard is mutation-checked (ready excluded, no schedule, any provider, no grace). Refs #290 (the marker slice only; duplicate provider ids and earlier live-stream detection stay open there) Co-Authored-By: Claude Opus 5.5 --- .../ipc/useIpcSubscriptions.renderer.test.tsx | 87 +++++++++++++++++++ .../workspace/hook/ipc/useIpcSubscriptions.ts | 73 +++++++++++++++- .../src/workspace/providerSessionIdentity.ts | 8 +- .../claude-2026-06-17-a6c70f19.json | 22 +++++ tsconfig.web.json | 1 + 5 files changed, 189 insertions(+), 2 deletions(-) create mode 100644 testing/fixtures/transcript-disconnect/claude-2026-06-17-a6c70f19.json 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..031080984 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,90 @@ 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
+ } + render() + const playRecordedTurn = () => act(() => { + for (const event of recordedDisconnectedClaude.turnEvents) fake.emitSemantic({ sessionId, event: event as never }) + }) + return { fake, runtime: () => runtimes[sessionId]!, playRecordedTurn } + } + + 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('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..e1e3a66de 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 @@ -1213,7 +1220,15 @@ export function useIpcSubscriptions( setRuntimes(prev => { const current = prev[sessionId] ?? emptyRuntime() const meta = refs.stateRef.current.sessions[sessionId] ?? null - const shouldMarkDisconnected = shouldMarkProviderSessionDisconnected(current, meta) + // NOT evaluated here any more (#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. + // Marking it would alarm on every new pane, which is why the old + // predicate refused 'ready', and that made it unable to fire in the + // real broken state too. The decision moved to the end of a turn: + // see `scheduleTranscriptCommitCheck`. + void meta + const shouldMarkDisconnected = false const next = appendFeedDebugLog( { ...current, @@ -1556,6 +1571,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 +1581,59 @@ 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] + 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 +2864,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/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 From c2a2df0c2d02f5d913d4000ca93842ce207bb0fd Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Thu, 24 Sep 2026 23:07:49 -0700 Subject: [PATCH 2/3] fix(claude): show the disconnected transcript in terminal view; pin the check's timer invariants Review round 1 on #1222 (2 Codex; the Pi report is pending): - Major (A): terminal view never mounts TileLeaf's readiness text, and its banner showed only transcriptChannelError/liveChannelWarning, so the new marker was invisible where Claude panes are often watched. A 'disconnected' transcript is the same lifetime class (it lasts until a committed record arrives), so it now takes the banner; transient transcriptError diagnostics still do not. Fail-first renderer test on the real leaf, including that the banner clears on 'ready'. - Test gaps (A, B): one pending check per session and cleanup on unmount were each removable with tests green. Now pinned: a second turn inside the grace keeps the first deadline and schedules no second 15 s timer; an unmount before the deadline leaves the pane untouched. Deferred (B, recorded on #290): the marker lives in renderer runtime state, so a window transfer or full reload before the NEXT completed turn loses it for a pane with no provider id. Carrying it across adoption is part of #290's remaining identity work, not this slice. Co-Authored-By: Claude Opus 5.5 --- .../ipc/useIpcSubscriptions.renderer.test.tsx | 29 +++++++++++++++++-- ...erminalLeaf.statusHeader.renderer.test.tsx | 19 ++++++++++++ .../workspace/tile-tree/AgentTerminalLeaf.tsx | 19 ++++++++++-- 3 files changed, 63 insertions(+), 4 deletions(-) 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 031080984..c838d6494 100644 --- a/src/renderer/src/workspace/hook/ipc/useIpcSubscriptions.renderer.test.tsx +++ b/src/renderer/src/workspace/hook/ipc/useIpcSubscriptions.renderer.test.tsx @@ -1699,11 +1699,11 @@ describe('useIpcSubscriptions with an injected SessionFeed', () => { ) return
} - render() + const mounted = render() const playRecordedTurn = () => act(() => { for (const event of recordedDisconnectedClaude.turnEvents) fake.emitSemantic({ sessionId, event: event as never }) }) - return { fake, runtime: () => runtimes[sessionId]!, playRecordedTurn } + return { fake, runtime: () => runtimes[sessionId]!, playRecordedTurn, unmount: mounted.unmount } } const provisional = { @@ -1735,6 +1735,31 @@ describe('useIpcSubscriptions with an injected SessionFeed', () => { 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('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. 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} From bdb68d79a418a41a597b143414a49477f96fdf15 Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Thu, 24 Sep 2026 23:22:08 -0700 Subject: [PATCH 3/3] fix(claude): re-check the provider when the commit check fires; pin the trigger and the self-clearing Review round 1 (Pi) on #1222, all fixed: - F1 (major, test gap): nothing distinguished the turn_completed trigger from turn_started. A long first turn can run past 15 s before the tail watcher delivers the user row, so a turn_started clock would alarm mid-turn. New test: a started-but-unfinished recorded turn never marks. - F2: the provider kind was checked only when scheduling. A provider switch keeps the session id and resets the runtime, so the Claude check could land a Claude-worded warning on the replacing Codex pane. The kind is re-checked at fire time; tested with a switch inside the grace. - F4: the 'clears itself on the first record' claim is now a test. - F6: the first-sight site no longer carries a permanently-false branch; it just logs the observation. (F3's dedupe/unmount gaps were already pinned in c2a2df0c, after the Pi reviewer's snapshot.) Each new test is mutation-checked (turn_started trigger, no fire-time kind). Co-Authored-By: Claude Opus 5.5 --- .../ipc/useIpcSubscriptions.renderer.test.tsx | 53 ++++++++++++++++++- .../workspace/hook/ipc/useIpcSubscriptions.ts | 46 ++++++---------- 2 files changed, 68 insertions(+), 31 deletions(-) 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 c838d6494..a5ff4dd31 100644 --- a/src/renderer/src/workspace/hook/ipc/useIpcSubscriptions.renderer.test.tsx +++ b/src/renderer/src/workspace/hook/ipc/useIpcSubscriptions.renderer.test.tsx @@ -1703,7 +1703,18 @@ describe('useIpcSubscriptions with an injected SessionFeed', () => { const playRecordedTurn = () => act(() => { for (const event of recordedDisconnectedClaude.turnEvents) fake.emitSemantic({ sessionId, event: event as never }) }) - return { fake, runtime: () => runtimes[sessionId]!, playRecordedTurn, unmount: mounted.unmount } + 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 = { @@ -1760,6 +1771,46 @@ describe('useIpcSubscriptions with an injected SessionFeed', () => { 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. diff --git a/src/renderer/src/workspace/hook/ipc/useIpcSubscriptions.ts b/src/renderer/src/workspace/hook/ipc/useIpcSubscriptions.ts index e1e3a66de..29ea0cdd5 100644 --- a/src/renderer/src/workspace/hook/ipc/useIpcSubscriptions.ts +++ b/src/renderer/src/workspace/hook/ipc/useIpcSubscriptions.ts @@ -1219,37 +1219,19 @@ export function useIpcSubscriptions( }) setRuntimes(prev => { const current = prev[sessionId] ?? emptyRuntime() - const meta = refs.stateRef.current.sessions[sessionId] ?? null - // NOT evaluated here any more (#290). The first proxy header of a + // 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. - // Marking it would alarm on every new pane, which is why the old - // predicate refused 'ready', and that made it unable to fire in the - // real broken state too. The decision moved to the end of a turn: - // see `scheduleTranscriptCommitCheck`. - void meta - const shouldMarkDisconnected = false - 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, - }, - ) + // 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({ @@ -1608,6 +1590,10 @@ export function useIpcSubscriptions( 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]