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
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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<string, unknown>) {
vi.useFakeTimers()
const fake = createFakeSessionFeed()
const state = {
sessions: { [sessionId]: { cwd: '/repo', kind: 'claude', ...meta } },
} as unknown as WorkspaceState
let runtimes: Record<SessionId, SessionRuntime> = { [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<WorkspaceRefs | null>(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 <div />
}
const mounted = render(<Harness />)
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<string, { kind?: string }>)[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')
})
})
})
103 changes: 80 additions & 23 deletions src/renderer/src/workspace/hook/ipc/useIpcSubscriptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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',
Expand All @@ -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<SessionId, number>()
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)
Expand Down Expand Up @@ -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()
Expand Down
8 changes: 7 additions & 1 deletion src/renderer/src/workspace/providerSessionIdentity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
19 changes: 17 additions & 2 deletions src/renderer/src/workspace/tile-tree/AgentTerminalLeaf.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<SessionRuntime, 'transcriptChannelError' | 'liveChannelWarning' | 'transcriptStatus' | 'transcriptError'>,
): string | null {
return runtime.transcriptChannelError
?? runtime.liveChannelWarning
?? (runtime.transcriptStatus === 'disconnected' ? runtime.transcriptError : null)
}

export function AgentTerminalLeaf({
sessionId,
paneLabel,
Expand Down Expand Up @@ -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) ? (
<div
data-terminal-transcript-error="true"
role="status"
Expand All @@ -721,7 +736,7 @@ export function AgentTerminalLeaf({
bg-warning-soft px-2 py-1 text-[10px] leading-snug text-warning
"
>
{runtime.transcriptChannelError ?? runtime.liveChannelWarning}
{terminalLifetimeWarning(runtime)}
</div>
) : null}

Expand Down
Loading
Loading