From 10360830ea747cc80e631f3b3b0318383f528755 Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Thu, 18 Jun 2026 13:41:00 +0200 Subject: [PATCH 1/3] fix(workspace): surface disconnected provider transcripts --- packages/claude-code-headless | 2 +- .../agent-status/model/formatAgentStatus.ts | 2 +- .../src/workspace/hook/actions/session.ts | 51 ++++++++- .../workspace/hook/ipc/useIpcSubscriptions.ts | 102 +++++++++++++++++- .../workspace/hook/persistence/rehydrate.ts | 13 ++- .../src/workspace/tile-tree/TileLeaf.tsx | 2 + src/renderer/src/workspace/types.ts | 26 ++--- src/renderer/src/workspace/workspaceState.ts | 2 +- .../semanticChannel.promptSuggestion.test.ts | 30 ++++++ .../proxy/promptSuggestionDetection.test.ts | 67 ++++++++++++ 10 files changed, 268 insertions(+), 29 deletions(-) diff --git a/packages/claude-code-headless b/packages/claude-code-headless index 400b58353..235eee478 160000 --- a/packages/claude-code-headless +++ b/packages/claude-code-headless @@ -1 +1 @@ -Subproject commit 400b5835352707d092174abf17eae7111c36286a +Subproject commit 235eee4787ec80b232b7e8dd0b28e2e2d4466bec diff --git a/src/renderer/src/features/agent-status/model/formatAgentStatus.ts b/src/renderer/src/features/agent-status/model/formatAgentStatus.ts index fc4bdb987..8eb6f45b9 100644 --- a/src/renderer/src/features/agent-status/model/formatAgentStatus.ts +++ b/src/renderer/src/features/agent-status/model/formatAgentStatus.ts @@ -153,7 +153,7 @@ function processTone(value: string): AgentStatusField['tone'] { function transcriptTone(value: string): AgentStatusField['tone'] { if (value === 'error') return 'bad' - if (value === 'loading') return 'warn' + if (value === 'loading' || value === 'disconnected') return 'warn' if (value === 'ready' || value === 'idle') return 'good' return 'neutral' } diff --git a/src/renderer/src/workspace/hook/actions/session.ts b/src/renderer/src/workspace/hook/actions/session.ts index 84aa50352..c534e2ec7 100644 --- a/src/renderer/src/workspace/hook/actions/session.ts +++ b/src/renderer/src/workspace/hook/actions/session.ts @@ -78,6 +78,27 @@ export type SessionActions = { } function softReloadRuntime(current: SessionRuntime, hasProviderSession: boolean): SessionRuntime { + if (!hasProviderSession) { + // WHY no-provider soft reload is non-destructive: + // + // Soft reload used to mean "discard renderer-derived state, then replay the + // committed transcript from disk." That only works when a providerSessionId + // points at a transcript we can load. In the #290 failure mode Claude is + // still alive through proxy/semantic/screen, but the committed JSONL is + // missing and the pane may have no provider id yet. Resetting entries, + // ghosts, and semantic state in that condition erases the only visible + // progress and then labels the empty pane "ready." Preserve the live-only + // state and make the durability problem explicit instead. + return { + ...current, + scrollToLatestRequest: current.scrollToLatestRequest + 1, + hasOlderHistory: false, + transcriptStatus: 'disconnected', + transcriptError: + 'Cannot soft reload this agent view because no committed provider transcript is known yet.', + } + } + const reset = emptyRuntime() // WHY this is a selective reset instead of a fresh `emptyRuntime()`: @@ -121,8 +142,8 @@ function softReloadRuntime(current: SessionRuntime, hasProviderSession: boolean) turnStartedAt: current.turnStartedAt, phaseChangedAt: current.phaseChangedAt, submittedAt: current.submittedAt, - hasOlderHistory: hasProviderSession, - transcriptStatus: hasProviderSession ? 'loading' : 'ready', + hasOlderHistory: true, + transcriptStatus: 'loading', transcriptError: null, } } @@ -189,7 +210,10 @@ export function useSessionActions( kind, ...(tmuxName ? { tmuxName } : {}), ...(kind !== 'terminal' && opts?.resumeSessionId - ? { providerSessionId: opts.resumeSessionId } + ? { + providerSessionId: opts.resumeSessionId, + providerSessionIdSource: 'resume-request' as const, + } : {}), ...(kind !== 'terminal' && builtInMcpDomains ? { builtInMcpDomains } @@ -445,7 +469,12 @@ export function useSessionActions( ...(sessions[newId] ?? { cwd, kind: nextKind }), cwd, kind: nextKind, - ...(spawnOpts.resumeSessionId ? { providerSessionId: spawnOpts.resumeSessionId } : {}), + ...(spawnOpts.resumeSessionId + ? { + providerSessionId: spawnOpts.resumeSessionId, + providerSessionIdSource: 'resume-request' as const, + } + : {}), ...(builtInMcpDomains ? { builtInMcpDomains } : {}), } const detachedSessions = { ...prev.detachedSessions } @@ -607,7 +636,9 @@ export function useSessionActions( restored.hasOlderHistory = Boolean(existing?.hasOlderHistory) || Boolean(freshSessions[newId]?.providerSessionId) restored.transcriptStatus = - existing?.transcriptStatus === 'ready' || existing?.transcriptStatus === 'error' + existing?.transcriptStatus === 'ready' || + existing?.transcriptStatus === 'error' || + existing?.transcriptStatus === 'disconnected' ? existing.transcriptStatus : freshSessions[newId]?.providerSessionId ? 'loading' : 'ready' restored.transcriptError = existing?.transcriptError ?? null @@ -747,6 +778,16 @@ export function useSessionActions( if (kind !== 'claude' && kind !== 'codex') return null const hasProviderSession = Boolean(meta.providerSessionId) + if (!hasProviderSession) { + setRuntimes(prev => { + const current = prev[sessionId] ?? emptyRuntime() + return { + ...prev, + [sessionId]: softReloadRuntime(current, false), + } + }) + return sessionId + } const timer = refs.bootstrapTimersRef.current.get(sessionId) if (timer) { diff --git a/src/renderer/src/workspace/hook/ipc/useIpcSubscriptions.ts b/src/renderer/src/workspace/hook/ipc/useIpcSubscriptions.ts index 5a24a9105..f5437a379 100644 --- a/src/renderer/src/workspace/hook/ipc/useIpcSubscriptions.ts +++ b/src/renderer/src/workspace/hook/ipc/useIpcSubscriptions.ts @@ -96,6 +96,22 @@ function entryUuid(entry: Entry): string | null { return typeof entry.uuid === 'string' ? entry.uuid : null } +function providerSessionObservedEvent(event: unknown): { + providerSessionId: string + flowId: string | null +} | null { + const record = asRecord(event) + if (!record || record.type !== 'provider_session_observed') return null + const providerSessionId = typeof record.providerSessionId === 'string' + ? record.providerSessionId + : '' + if (!providerSessionId) return null + return { + providerSessionId, + flowId: typeof record.flowId === 'string' ? record.flowId : null, + } +} + type WorktreeCacheEntry = { worktrees: WorktreeIdentity[] refreshedAt: number @@ -669,6 +685,58 @@ export function useIpcSubscriptions( span.end(data) } const semanticEvent = asRecord(event) ?? {} + const observedProvider = providerSessionObservedEvent(event) + if (observedProvider) { + setState(prev => { + const meta = prev.sessions[sessionId] + if (!meta) return prev + if (meta.providerSessionId) return prev + return { + ...prev, + sessions: { + ...prev.sessions, + [sessionId]: { + ...meta, + providerSessionId: observedProvider.providerSessionId, + providerSessionIdSource: 'proxy-header', + }, + }, + } + }) + setRuntimes(prev => { + const current = prev[sessionId] ?? emptyRuntime() + const shouldMarkDisconnected = + current.lastJsonlEntryAt === null && current.totalEntries === 0 + 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, + }, + ) + return { ...prev, [sessionId]: next } + }) + closeSpan({ + sessionId, + eventType: 'provider_session_observed', + changed: true, + }) + return + } setRuntimes(prev => { const current = prev[sessionId] ?? emptyRuntime() // WHY `?? 'claude'` default: Claude is the pre-fix @@ -1014,14 +1082,44 @@ export function useIpcSubscriptions( setState(prev => { const meta = prev.sessions[sessionId] if (!meta) return prev - if (meta.providerSessionId) return prev const id = capturedClaudeId ?? capturedCodexId if (!id) return prev + const source = 'jsonl-entry' as const + if (meta.providerSessionId === id && meta.providerSessionIdSource === source) { + return prev + } + if ( + meta.providerSessionId && + meta.providerSessionId !== id && + meta.providerSessionIdSource !== 'proxy-header' + ) { + // WHY a conflicting durable id is not auto-adopted: + // + // The #290 symptom is not just "missing transcript"; it is a pane + // becoming attached to the wrong committed provider transcript and + // then rendering another agent's conversation as if it were its own. + // Once a resume request or earlier JSONL entry has established a + // durable identity, a later different id is more likely an ambiguous + // attach/replay bug than useful recovery data. The one exception is + // proxy-header state: that is intentionally provisional, so the + // first JSONL-backed id is allowed to replace it. + console.warn('[workspace] ignoring conflicting providerSessionId from JSONL', { + sessionId, + current: meta.providerSessionId, + incoming: id, + currentSource: meta.providerSessionIdSource ?? null, + }) + return prev + } return { ...prev, sessions: { ...prev.sessions, - [sessionId]: { ...meta, providerSessionId: id }, + [sessionId]: { + ...meta, + providerSessionId: id, + providerSessionIdSource: source, + }, }, } }) diff --git a/src/renderer/src/workspace/hook/persistence/rehydrate.ts b/src/renderer/src/workspace/hook/persistence/rehydrate.ts index c379f1b88..9e5a43a2a 100644 --- a/src/renderer/src/workspace/hook/persistence/rehydrate.ts +++ b/src/renderer/src/workspace/hook/persistence/rehydrate.ts @@ -381,9 +381,12 @@ export async function rehydrateWorkspace( ...base, ...(draft && !base.draftInput ? { draftInput: draft } : {}), hasOlderHistory: Boolean(freshSessions[newId]?.providerSessionId), - transcriptStatus: base.transcriptStatus === 'ready' || base.transcriptStatus === 'error' - ? base.transcriptStatus - : freshSessions[newId]?.providerSessionId ? 'loading' : 'ready', + transcriptStatus: + base.transcriptStatus === 'ready' || + base.transcriptStatus === 'error' || + base.transcriptStatus === 'disconnected' + ? base.transcriptStatus + : freshSessions[newId]?.providerSessionId ? 'loading' : 'ready', transcriptError: base.transcriptError, // WHY preserve an already-observed lifecycle state: // @@ -410,7 +413,9 @@ export async function rehydrateWorkspace( ...(existing ?? emptyRuntime()), hasOlderHistory: Boolean(freshSessions[id]?.providerSessionId), transcriptStatus: - existing?.transcriptStatus === 'ready' || existing?.transcriptStatus === 'error' + existing?.transcriptStatus === 'ready' || + existing?.transcriptStatus === 'error' || + existing?.transcriptStatus === 'disconnected' ? existing.transcriptStatus : freshSessions[id]?.providerSessionId ? 'loading' : 'ready', transcriptError: existing?.transcriptError ?? null, diff --git a/src/renderer/src/workspace/tile-tree/TileLeaf.tsx b/src/renderer/src/workspace/tile-tree/TileLeaf.tsx index 876175125..b789b05f4 100644 --- a/src/renderer/src/workspace/tile-tree/TileLeaf.tsx +++ b/src/renderer/src/workspace/tile-tree/TileLeaf.tsx @@ -330,6 +330,8 @@ export function TileLeaf({ ? 'loading transcript' : runtime.transcriptStatus === 'error' ? `transcript unavailable${runtime.transcriptError ? `: ${runtime.transcriptError}` : ''}` + : runtime.transcriptStatus === 'disconnected' + ? `transcript disconnected${runtime.transcriptError ? `: ${runtime.transcriptError}` : ''}` // WHY exited beats "not input ready": // // A resumed agent can die before the renderer finishes the diff --git a/src/renderer/src/workspace/types.ts b/src/renderer/src/workspace/types.ts index 9c5ae83f5..259fb7f48 100644 --- a/src/renderer/src/workspace/types.ts +++ b/src/renderer/src/workspace/types.ts @@ -78,25 +78,21 @@ export type SessionMeta = { */ kind?: SessionKind /** - * CC's own session UUID (distinct from Agent Code's SessionId which is - * a per-launch routing key). Captured from the `sessionId` field on - * the first JSONL entry that lands for this session, and persisted - * to disk so we can pass `--resume ` on the next launch and - * rehydrate the conversation history, tool calls, and everything - * else CC tracks in its transcript file. + * Provider's own session UUID (distinct from Agent Code's SessionId which is + * a per-launch routing key). For Claude this is either confirmed by the + * committed JSONL `sessionId` field, supplied by an explicit resume request, + * or provisionally observed from the proxy's `x-claude-code-session-id` + * header before the root JSONL file exists. * - * Without this, a workspace reload / hot-reload / app crash lands - * the user in a new blank session every time — the tile tree comes - * back but every pane's conversation is gone, which was causing - * real frustration during development. See the load path in - * workspaceStore.rehydrate() and the jsonl-entry handler that - * captures it. + * WHY proxy-observed identity is allowed here: * - * Only meaningful for kind === 'claude'. Terminal sessions don't - * have a JSONL transcript so there's nothing to resume; a reload - * just spawns a fresh shell in the same cwd. + * The proxy header proves Claude has assigned a session id, but it does not + * prove the committed transcript is durable/reloadable. The optional source + * tag below lets the renderer persist that provisional identity while still + * keeping transcriptStatus disconnected until JSONL confirms durability. */ providerSessionId?: string + providerSessionIdSource?: 'jsonl-entry' | 'proxy-header' | 'resume-request' /** * For tmux-backed terminals (P1): the registry-managed tmux * session name. Captured from the spawn IPC response and passed diff --git a/src/renderer/src/workspace/workspaceState.ts b/src/renderer/src/workspace/workspaceState.ts index 08b876487..4d7c0702c 100644 --- a/src/renderer/src/workspace/workspaceState.ts +++ b/src/renderer/src/workspace/workspaceState.ts @@ -257,7 +257,7 @@ export type SemanticRuntimeState = { export type SessionStatus = 'idle' | 'running' | 'exited' export type SessionStatusSource = 'none' | 'submit' | 'process' | 'semantic' | 'exit' -export type TranscriptStatus = 'idle' | 'loading' | 'ready' | 'error' +export type TranscriptStatus = 'idle' | 'loading' | 'ready' | 'error' | 'disconnected' export type ProcessStatus = 'idle' | 'spawning' | 'started' | 'failed' | 'exited' /** In-feed "what is the agent doing" phase. Mirrors the upstream diff --git a/testing/unit/channels/semanticChannel.promptSuggestion.test.ts b/testing/unit/channels/semanticChannel.promptSuggestion.test.ts index 62083e116..6451b3b66 100644 --- a/testing/unit/channels/semanticChannel.promptSuggestion.test.ts +++ b/testing/unit/channels/semanticChannel.promptSuggestion.test.ts @@ -29,3 +29,33 @@ describe('publishPromptSuggestion', () => { expect(typeof (named[0] as { ts: number }).ts).toBe('number') }) }) + +describe('publishProviderSessionObserved', () => { + it('emits provider_session_observed on both the named and catch-all channels', () => { + const channel = new SemanticChannel() + const named: SemanticEvent[] = [] + const all: SemanticEvent[] = [] + channel.on('provider_session_observed', e => named.push(e)) + channel.on('event', e => all.push(e)) + + channel.publishProviderSessionObserved({ + provider: 'claude', + providerSessionId: 'claude-session-1', + flowId: 'flow-1', + source: 'proxy', + confidence: 'high', + }) + + expect(named).toHaveLength(1) + expect(all).toHaveLength(1) + expect(named[0]).toMatchObject({ + type: 'provider_session_observed', + provider: 'claude', + providerSessionId: 'claude-session-1', + flowId: 'flow-1', + source: 'proxy', + confidence: 'high', + }) + expect(typeof (named[0] as { ts: number }).ts).toBe('number') + }) +}) diff --git a/testing/unit/proxy/promptSuggestionDetection.test.ts b/testing/unit/proxy/promptSuggestionDetection.test.ts index d3b5008fa..e591145e2 100644 --- a/testing/unit/proxy/promptSuggestionDetection.test.ts +++ b/testing/unit/proxy/promptSuggestionDetection.test.ts @@ -60,6 +60,73 @@ const TOOLS = new Array(10).fill({ name: 'Bash' }) const SYSTEM = [{ type: 'text', text: 'You are Claude Code' }] describe('prompt-suggestion flow routing', () => { + it('emits a provisional provider-session observation from Claude Code headers', () => { + const calls: Call[] = [] + const adapter = new ClaudeProxyAdapter({ + channel: makeChannel(calls), + getSessionModel: () => 'claude-opus-4-8', + }) + + adapter.handleTransportEvent({ + kind: 'request', + flow_id: 101, + method: 'POST', + url: 'https://api.anthropic.com/v1/messages', + host: 'api.anthropic.com', + path: '/v1/messages', + headers: { + 'x-claude-code-session-id': 'claude-session-1', + }, + body_b64: b64({ + model: 'claude-opus-4-8', + max_tokens: 64000, + tools: TOOLS, + system: SYSTEM, + messages: [{ role: 'user', content: 'fix the bug' }], + }), + } as never) + + const observed = calls.find(c => c.method === 'publishProviderSessionObserved') + expect(observed?.arg).toMatchObject({ + provider: 'claude', + providerSessionId: 'claude-session-1', + flowId: '101', + source: 'proxy', + confidence: 'high', + }) + }) + + it('dedupes repeated provider-session header observations', () => { + const calls: Call[] = [] + const adapter = new ClaudeProxyAdapter({ + channel: makeChannel(calls), + getSessionModel: () => 'claude-opus-4-8', + }) + const event = { + kind: 'request', + flow_id: 102, + method: 'POST', + url: 'https://api.anthropic.com/v1/messages', + host: 'api.anthropic.com', + path: '/v1/messages', + headers: { + 'X-Claude-Code-Session-Id': 'claude-session-1', + }, + body_b64: b64({ + model: 'claude-opus-4-8', + max_tokens: 64000, + tools: TOOLS, + system: SYSTEM, + messages: [{ role: 'user', content: 'fix the bug' }], + }), + } as never + + adapter.handleTransportEvent(event) + adapter.handleTransportEvent({ ...event, flow_id: 103 } as never) + + expect(calls.filter(c => c.method === 'publishProviderSessionObserved')).toHaveLength(1) + }) + it('does NOT emit turn_started for a suggestion flow, and emits prompt_suggestion with the text', () => { const calls: Call[] = [] const adapter = new ClaudeProxyAdapter({ From a820f6a2e11214783c68b2d45b51cd9c8007d08a Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Thu, 18 Jun 2026 14:04:56 +0200 Subject: [PATCH 2/3] fix(workspace): keep proxy provider ids provisional --- .../workspace/hook/actions/initialHistory.ts | 10 ++- .../src/workspace/hook/actions/provider.ts | 15 ++-- .../src/workspace/hook/actions/session.ts | 24 +++-- .../src/workspace/hook/actions/undoClose.ts | 9 +- .../workspace/hook/ipc/useIpcSubscriptions.ts | 30 +++---- .../workspace/hook/persistence/rehydrate.ts | 17 ++-- .../src/workspace/providerSessionIdentity.ts | 90 +++++++++++++++++++ .../applyPromptSuggestionToRuntime.test.ts | 84 +++++++++++++++++ 8 files changed, 239 insertions(+), 40 deletions(-) create mode 100644 src/renderer/src/workspace/providerSessionIdentity.ts diff --git a/src/renderer/src/workspace/hook/actions/initialHistory.ts b/src/renderer/src/workspace/hook/actions/initialHistory.ts index c19e84ac9..b3f543561 100644 --- a/src/renderer/src/workspace/hook/actions/initialHistory.ts +++ b/src/renderer/src/workspace/hook/actions/initialHistory.ts @@ -31,6 +31,7 @@ import { import type { WorkspaceSetRuntimes } from '@renderer/workspace/hook/context' import type { WorkspaceRefs } from '@renderer/workspace/hook/refs' import * as perf from '@renderer/performance/client' +import { hasDurableProviderSession } from '@renderer/workspace/providerSessionIdentity' import { codexEventType, codexTurnIdFromEventPayload, @@ -94,15 +95,18 @@ export async function loadInitialHistoryForSession({ const kind = meta?.kind ?? 'claude' if (!meta || (kind !== 'claude' && kind !== 'codex')) return - if (!meta.providerSessionId) { + if (!hasDurableProviderSession(meta)) { setRuntimes(prev => { const current = prev[sessionId] ?? emptyRuntime() + const isProvisional = meta.providerSessionIdSource === 'proxy-header' return { ...prev, [sessionId]: { ...current, - transcriptStatus: 'ready', - transcriptError: null, + transcriptStatus: isProvisional ? 'disconnected' : 'ready', + transcriptError: isProvisional + ? 'Provider session was observed in proxy traffic, but no committed transcript is known yet.' + : null, }, } }) diff --git a/src/renderer/src/workspace/hook/actions/provider.ts b/src/renderer/src/workspace/hook/actions/provider.ts index 2cfbe816e..8bd488eda 100644 --- a/src/renderer/src/workspace/hook/actions/provider.ts +++ b/src/renderer/src/workspace/hook/actions/provider.ts @@ -7,6 +7,7 @@ import { commandTargetSessionIdForState } from '@renderer/workspace/hook/selecto import type { WorkspaceSetRuntimes } from '@renderer/workspace/hook/context' import type { WorkspaceRefs } from '@renderer/workspace/hook/refs' import type { SessionActions } from '@renderer/workspace/hook/actions/session' +import { resumableProviderSessionId } from '@renderer/workspace/providerSessionIdentity' // Provider-level actions on the focused pane. // @@ -49,7 +50,8 @@ export function useProviderActions( const targetKind = sourceKind === 'claude' ? 'codex' : 'claude' try { - if (!meta.providerSessionId) { + const sourceProviderSessionId = resumableProviderSessionId(meta) + if (!sourceProviderSessionId) { const draftImages = refs.latestRuntimesRef.current[sourceSessionId]?.draftImages ?? [] @@ -110,7 +112,7 @@ export function useProviderActions( // dead pane. const result = await window.api.switchProvider({ sourceKind, - sourceProviderSessionId: meta.providerSessionId, + sourceProviderSessionId, cwd: meta.cwd, }) @@ -146,7 +148,8 @@ export function useProviderActions( showPaneToast(sourceSessionId, 'Only Claude and Codex panes can reload') return } - if (!meta.providerSessionId) { + const resumeSessionId = resumableProviderSessionId(meta) + if (!resumeSessionId) { showPaneToast(sourceSessionId, 'Provider session id is not ready yet') return } @@ -154,7 +157,7 @@ export function useProviderActions( try { const newSessionId = await sessionActions.replaceSession(meta.cwd, { kind, - resumeSessionId: meta.providerSessionId, + resumeSessionId, builtInMcpDomains: meta.builtInMcpDomains, }) if (!newSessionId) return @@ -209,11 +212,11 @@ export function useProviderActions( showPaneToast(sourceSessionId, 'Only Claude and Codex panes support rewind') return } - if (!meta.providerSessionId) { + const previousProviderSessionId = resumableProviderSessionId(meta) + if (!previousProviderSessionId) { showPaneToast(sourceSessionId, 'Provider session id is not ready yet') return } - const previousProviderSessionId = meta.providerSessionId if (kind !== anchor.kind) { showPaneToast( sourceSessionId, diff --git a/src/renderer/src/workspace/hook/actions/session.ts b/src/renderer/src/workspace/hook/actions/session.ts index c534e2ec7..9b218ad91 100644 --- a/src/renderer/src/workspace/hook/actions/session.ts +++ b/src/renderer/src/workspace/hook/actions/session.ts @@ -28,6 +28,11 @@ import type { import type { WorkspaceRefs } from '@renderer/workspace/hook/refs' import { loadInitialHistoryForSession } from '@renderer/workspace/hook/actions/initialHistory' import { commandTargetSessionIdForState } from '@renderer/workspace/hook/selectors/commandTargetSessionId' +import { + hasDurableProviderSession, + resumableProviderSessionId, + withoutProvisionalProviderSession, +} from '@renderer/workspace/providerSessionIdentity' import { collectLiveProcessIds, collectOwnedSessionIds, @@ -233,7 +238,7 @@ export function useSessionActions( ...prev, [sessionId]: { ...emptyRuntime(), - hasOlderHistory: kind !== 'terminal' && Boolean(meta.providerSessionId), + hasOlderHistory: kind !== 'terminal' && hasDurableProviderSession(meta), transcriptStatus: kind !== 'terminal' && meta.providerSessionId ? 'loading' : 'ready', transcriptError: null, @@ -596,17 +601,19 @@ export function useSessionActions( kind !== 'terminal' ? normalizeSessionBuiltInMcpDomains(meta.builtInMcpDomains) : undefined + const resumeSessionId = resumableProviderSessionId(meta) + const restoredMeta = withoutProvisionalProviderSession(meta) const { sessionId: newId } = await window.api.spawnSession({ kind, cwd: meta.cwd, - resumeSessionId: meta.providerSessionId, + resumeSessionId, dangerousMode, useProxy: kind !== 'terminal' ? refs.useProxyStreamingRef.current : undefined, builtInMcpDomains, }) idMap.set(oldId, newId) freshSessions[newId] = { - ...meta, + ...restoredMeta, ...(builtInMcpDomains ? { builtInMcpDomains } : {}), } } catch { @@ -634,7 +641,7 @@ export function useSessionActions( const restored: SessionRuntime = { ...(existing ?? emptyRuntime()) } restored.draftInput = oldRuntimes[oldId]?.draftInput ?? existing?.draftInput ?? '' restored.hasOlderHistory = - Boolean(existing?.hasOlderHistory) || Boolean(freshSessions[newId]?.providerSessionId) + Boolean(existing?.hasOlderHistory) || hasDurableProviderSession(freshSessions[newId]) restored.transcriptStatus = existing?.transcriptStatus === 'ready' || existing?.transcriptStatus === 'error' || @@ -741,7 +748,7 @@ export function useSessionActions( } }) for (const [newId, meta] of Object.entries(freshSessions)) { - if (!meta.providerSessionId) continue + if (!hasDurableProviderSession(meta)) continue void loadInitialHistoryForSession({ sessionId: newId, meta, @@ -777,8 +784,13 @@ export function useSessionActions( const kind = meta.kind ?? 'claude' if (kind !== 'claude' && kind !== 'codex') return null - const hasProviderSession = Boolean(meta.providerSessionId) + const hasProviderSession = hasDurableProviderSession(meta) if (!hasProviderSession) { + const timer = refs.bootstrapTimersRef.current.get(sessionId) + if (timer) { + clearTimeout(timer) + refs.bootstrapTimersRef.current.delete(sessionId) + } setRuntimes(prev => { const current = prev[sessionId] ?? emptyRuntime() return { diff --git a/src/renderer/src/workspace/hook/actions/undoClose.ts b/src/renderer/src/workspace/hook/actions/undoClose.ts index 9f6c5bdce..dbc0719b0 100644 --- a/src/renderer/src/workspace/hook/actions/undoClose.ts +++ b/src/renderer/src/workspace/hook/actions/undoClose.ts @@ -16,6 +16,7 @@ import type { ClosedPane, ClosedTab } from '@renderer/lib/undoClose' import type { WorkspaceSetState } from '@renderer/workspace/hook/context' import type { WorkspaceRefs } from '@renderer/workspace/hook/refs' import type { SessionActions } from '@renderer/workspace/hook/actions/session' +import { resumableProviderSessionId } from '@renderer/workspace/providerSessionIdentity' type RestoreResult = 'restored' | 'stale' | 'retryable-failure' @@ -80,7 +81,7 @@ export function useUndoCloseAction( try { newSessionId = await sessionActions.spawn(meta.cwd, { kind: meta.kind ?? 'claude', - resumeSessionId: meta.providerSessionId, + resumeSessionId: resumableProviderSessionId(meta), recoverTmuxName: meta.kind === 'terminal' ? meta.tmuxName : undefined, }) } catch { @@ -152,7 +153,7 @@ export function useUndoCloseAction( // agents. const newId = await sessionActions.spawn(meta.cwd, { kind, - resumeSessionId: kind !== 'terminal' ? meta.providerSessionId : undefined, + resumeSessionId: kind !== 'terminal' ? resumableProviderSessionId(meta) : undefined, recoverTmuxName: kind === 'terminal' ? meta.tmuxName : undefined, }) idMap.set(oldId, newId) @@ -211,7 +212,9 @@ export function useUndoCloseAction( // detached terminals doesn't silently drop tmuxName. const newId = await sessionActions.spawn(detached.meta.cwd, { kind, - resumeSessionId: kind !== 'terminal' ? detached.meta.providerSessionId : undefined, + resumeSessionId: kind !== 'terminal' + ? resumableProviderSessionId(detached.meta) + : undefined, recoverTmuxName: kind === 'terminal' ? detached.meta.tmuxName : undefined, }) restoredDetached[newId] = { diff --git a/src/renderer/src/workspace/hook/ipc/useIpcSubscriptions.ts b/src/renderer/src/workspace/hook/ipc/useIpcSubscriptions.ts index f5437a379..04d146972 100644 --- a/src/renderer/src/workspace/hook/ipc/useIpcSubscriptions.ts +++ b/src/renderer/src/workspace/hook/ipc/useIpcSubscriptions.ts @@ -73,6 +73,10 @@ import type { } from '@renderer/workspace/hook/context' import type { WorkspaceRefs } from '@renderer/workspace/hook/refs' import * as perf from '@renderer/performance/client' +import { + applyJsonlProviderSessionId, + shouldMarkProviderSessionDisconnected, +} from '@renderer/workspace/providerSessionIdentity' // Codex rollout is delivered as many small IPC bursts, but `turn_context` // is only one line near the beginning of the task. The bundle that @@ -705,8 +709,8 @@ export function useIpcSubscriptions( }) setRuntimes(prev => { const current = prev[sessionId] ?? emptyRuntime() - const shouldMarkDisconnected = - current.lastJsonlEntryAt === null && current.totalEntries === 0 + const meta = refs.stateRef.current.sessions[sessionId] ?? null + const shouldMarkDisconnected = shouldMarkProviderSessionDisconnected(current, meta) const next = appendFeedDebugLog( { ...current, @@ -1084,15 +1088,11 @@ export function useIpcSubscriptions( if (!meta) return prev const id = capturedClaudeId ?? capturedCodexId if (!id) return prev - const source = 'jsonl-entry' as const - if (meta.providerSessionId === id && meta.providerSessionIdSource === source) { + const resolution = applyJsonlProviderSessionId(meta, id) + if (resolution.status === 'unchanged') { return prev } - if ( - meta.providerSessionId && - meta.providerSessionId !== id && - meta.providerSessionIdSource !== 'proxy-header' - ) { + if (resolution.status === 'conflict') { // WHY a conflicting durable id is not auto-adopted: // // The #290 symptom is not just "missing transcript"; it is a pane @@ -1105,9 +1105,9 @@ export function useIpcSubscriptions( // first JSONL-backed id is allowed to replace it. console.warn('[workspace] ignoring conflicting providerSessionId from JSONL', { sessionId, - current: meta.providerSessionId, - incoming: id, - currentSource: meta.providerSessionIdSource ?? null, + current: resolution.current, + incoming: resolution.incoming, + currentSource: resolution.currentSource, }) return prev } @@ -1115,11 +1115,7 @@ export function useIpcSubscriptions( ...prev, sessions: { ...prev.sessions, - [sessionId]: { - ...meta, - providerSessionId: id, - providerSessionIdSource: source, - }, + [sessionId]: resolution.meta, }, } }) diff --git a/src/renderer/src/workspace/hook/persistence/rehydrate.ts b/src/renderer/src/workspace/hook/persistence/rehydrate.ts index 9e5a43a2a..9f44e24c7 100644 --- a/src/renderer/src/workspace/hook/persistence/rehydrate.ts +++ b/src/renderer/src/workspace/hook/persistence/rehydrate.ts @@ -28,6 +28,11 @@ import type { WorkspaceRefs } from '@renderer/workspace/hook/refs' import { normalizeSessionBuiltInMcpDomains } from '@renderer/workspace/mcpDomains' import * as perf from '@renderer/performance/client' import { loadInitialHistoryForSession } from '@renderer/workspace/hook/actions/initialHistory' +import { + hasDurableProviderSession, + resumableProviderSessionId, + withoutProvisionalProviderSession, +} from '@renderer/workspace/providerSessionIdentity' // Remap a persisted tree by replacing every sessionId with a freshly // spawned one (spawn happens as we walk). Returns the remapped tree @@ -380,7 +385,7 @@ export async function rehydrateWorkspace( out[newId] = { ...base, ...(draft && !base.draftInput ? { draftInput: draft } : {}), - hasOlderHistory: Boolean(freshSessions[newId]?.providerSessionId), + hasOlderHistory: hasDurableProviderSession(freshSessions[newId]), transcriptStatus: base.transcriptStatus === 'ready' || base.transcriptStatus === 'error' || @@ -411,7 +416,7 @@ export async function rehydrateWorkspace( const existing = prev[id] out[id] = { ...(existing ?? emptyRuntime()), - hasOlderHistory: Boolean(freshSessions[id]?.providerSessionId), + hasOlderHistory: hasDurableProviderSession(freshSessions[id]), transcriptStatus: existing?.transcriptStatus === 'ready' || existing?.transcriptStatus === 'error' || @@ -478,10 +483,12 @@ export async function rehydrateWorkspace( // loopback URLs/tokens for every new provider process. If rehydrate // respawns without the saved domains, the pane visually restores but // its tool surface silently changes underneath the user. + const resumeSessionId = kind !== 'terminal' ? resumableProviderSessionId(meta) : undefined + const restoredMeta = withoutProvisionalProviderSession(meta) const { sessionId: newId, tmuxName: nextTmuxName } = await window.api.spawnSession({ kind, cwd: meta.cwd, - resumeSessionId: kind !== 'terminal' ? meta.providerSessionId : undefined, + resumeSessionId, dangerousMode: kind !== 'terminal' ? refs.dangerousAgentsRef.current : undefined, useProxy: kind !== 'terminal' ? refs.useProxyStreamingRef.current : undefined, recoverTmuxName: kind === 'terminal' ? meta.tmuxName : undefined, @@ -495,12 +502,12 @@ export async function rehydrateWorkspace( // tmuxName is replaced with whatever main reported // (recovered name when alive, fresh name when respawned). freshSessions[newId] = { - ...meta, + ...restoredMeta, ...(builtInMcpDomains ? { builtInMcpDomains } : {}), ...(nextTmuxName ? { tmuxName: nextTmuxName } : {}), } commitRehydratedState() - if (kind !== 'terminal' && freshSessions[newId].providerSessionId) { + if (kind !== 'terminal' && resumeSessionId) { void loadInitialHistoryForSession({ sessionId: newId, meta: freshSessions[newId], diff --git a/src/renderer/src/workspace/providerSessionIdentity.ts b/src/renderer/src/workspace/providerSessionIdentity.ts new file mode 100644 index 000000000..f45033f94 --- /dev/null +++ b/src/renderer/src/workspace/providerSessionIdentity.ts @@ -0,0 +1,90 @@ +import type { SessionMeta } from '@renderer/workspace/types' +import type { SessionRuntime } from '@renderer/workspace/workspaceState' + +export function hasDurableProviderSession( + meta: Pick | null | undefined, +): boolean { + if (!meta?.providerSessionId) return false + // WHY missing source is treated as durable: + // + // Workspaces saved before providerSessionIdSource existed only persisted ids + // that came from JSONL capture or explicit resume. Treating those legacy ids + // as provisional would silently break reload for existing users. + return meta.providerSessionIdSource !== 'proxy-header' +} + +export function resumableProviderSessionId( + meta: Pick | null | undefined, +): string | undefined { + return hasDurableProviderSession(meta) ? meta?.providerSessionId : undefined +} + +export function withoutProvisionalProviderSession(meta: SessionMeta): SessionMeta { + if (meta.providerSessionIdSource !== 'proxy-header') return meta + const { providerSessionId: _providerSessionId, providerSessionIdSource: _source, ...rest } = meta + return rest +} + +export function shouldMarkProviderSessionDisconnected( + runtime: Pick, + meta: Pick | null | undefined, +): boolean { + if (!meta) return false + if (hasDurableProviderSession(meta)) return false + if (runtime.transcriptStatus === 'loading' || runtime.transcriptStatus === 'ready') return false + return runtime.lastJsonlEntryAt === null && runtime.totalEntries === 0 +} + +export type JsonlProviderSessionResolution = + | { + status: 'unchanged' + meta: SessionMeta + } + | { + status: 'updated' + meta: SessionMeta + } + | { + status: 'conflict' + meta: SessionMeta + current: string + incoming: string + currentSource: SessionMeta['providerSessionIdSource'] | null + } + +export function applyJsonlProviderSessionId( + meta: SessionMeta, + providerSessionId: string, +): JsonlProviderSessionResolution { + const source = 'jsonl-entry' as const + if (meta.providerSessionId === providerSessionId && meta.providerSessionIdSource === source) { + return { status: 'unchanged', meta } + } + if ( + meta.providerSessionId && + meta.providerSessionId !== providerSessionId && + meta.providerSessionIdSource !== 'proxy-header' + ) { + return { + status: 'conflict', + meta, + current: meta.providerSessionId, + incoming: providerSessionId, + currentSource: meta.providerSessionIdSource ?? null, + } + } + // WHY JSONL is allowed to replace proxy-header: + // + // The proxy header is the earliest identity signal, but it is deliberately + // provisional. A committed JSONL entry is the first proof that history is + // durable and reloadable, so it upgrades the source even if the id matches + // and replaces the id if the early header belonged to a transient fork. + return { + status: 'updated', + meta: { + ...meta, + providerSessionId, + providerSessionIdSource: source, + }, + } +} diff --git a/testing/unit/workspace/applyPromptSuggestionToRuntime.test.ts b/testing/unit/workspace/applyPromptSuggestionToRuntime.test.ts index 1803a9b37..6ab2dab45 100644 --- a/testing/unit/workspace/applyPromptSuggestionToRuntime.test.ts +++ b/testing/unit/workspace/applyPromptSuggestionToRuntime.test.ts @@ -1,6 +1,14 @@ import { describe, it, expect } from 'vitest' import { applyPromptSuggestionToRuntime } from '@renderer/workspace/hook/ipc/applyPromptSuggestionToRuntime' +import { + applyJsonlProviderSessionId, + hasDurableProviderSession, + resumableProviderSessionId, + shouldMarkProviderSessionDisconnected, + withoutProvisionalProviderSession, +} from '@renderer/workspace/providerSessionIdentity' +import type { SessionMeta } from '@renderer/workspace/types' import type { SessionRuntime } from '@renderer/workspace/workspaceState' // We don't need a full runtime — the helper touches exactly one field. Cast a @@ -24,3 +32,79 @@ describe('applyPromptSuggestionToRuntime', () => { expect(next.promptSuggestion).toEqual({ text: 'commit this', receivedAt: 0 }) }) }) + +describe('provider session identity helpers', () => { + const durableMeta = { + cwd: '/repo', + kind: 'claude', + providerSessionId: 'durable-1', + providerSessionIdSource: 'jsonl-entry', + } satisfies SessionMeta + const provisionalMeta = { + cwd: '/repo', + kind: 'claude', + providerSessionId: 'proxy-1', + providerSessionIdSource: 'proxy-header', + } satisfies SessionMeta + + it('treats proxy-header provider ids as non-resumable', () => { + expect(hasDurableProviderSession(provisionalMeta)).toBe(false) + expect(resumableProviderSessionId(provisionalMeta)).toBeUndefined() + expect(withoutProvisionalProviderSession(provisionalMeta)).toEqual({ + cwd: '/repo', + kind: 'claude', + }) + }) + + it('keeps jsonl-entry and legacy provider ids resumable', () => { + expect(hasDurableProviderSession(durableMeta)).toBe(true) + expect(resumableProviderSessionId(durableMeta)).toBe('durable-1') + expect(hasDurableProviderSession({ + cwd: '/repo', + kind: 'claude', + providerSessionId: 'legacy-1', + })).toBe(true) + }) + + it('upgrades a proxy-header id when JSONL confirms the same provider session', () => { + const result = applyJsonlProviderSessionId(provisionalMeta, 'proxy-1') + expect(result.status).toBe('updated') + expect(result.meta).toMatchObject({ + providerSessionId: 'proxy-1', + providerSessionIdSource: 'jsonl-entry', + }) + }) + + it('lets JSONL replace a provisional proxy-header id', () => { + const result = applyJsonlProviderSessionId(provisionalMeta, 'jsonl-1') + expect(result.status).toBe('updated') + expect(result.meta).toMatchObject({ + providerSessionId: 'jsonl-1', + providerSessionIdSource: 'jsonl-entry', + }) + }) + + it('rejects a conflicting durable JSONL id', () => { + const result = applyJsonlProviderSessionId(durableMeta, 'other-1') + expect(result.status).toBe('conflict') + expect(result.meta).toBe(durableMeta) + if (result.status === 'conflict') { + expect(result.current).toBe('durable-1') + expect(result.incoming).toBe('other-1') + } + }) + + it('only marks disconnected before any durable transcript identity exists', () => { + const emptyRuntime = { + lastJsonlEntryAt: null, + totalEntries: 0, + transcriptStatus: 'idle', + } as Pick + expect(shouldMarkProviderSessionDisconnected(emptyRuntime, provisionalMeta)).toBe(true) + expect(shouldMarkProviderSessionDisconnected(emptyRuntime, durableMeta)).toBe(false) + expect(shouldMarkProviderSessionDisconnected({ + ...emptyRuntime, + transcriptStatus: 'loading', + }, provisionalMeta)).toBe(false) + }) +}) From da4a4fc7493568b760c8e539b14aec9aed953920 Mon Sep 17 00:00:00 2001 From: Julius Olsson Date: Thu, 18 Jun 2026 14:05:58 +0200 Subject: [PATCH 3/3] chore: point claude headless at merged provider session support --- packages/claude-code-headless | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/claude-code-headless b/packages/claude-code-headless index 235eee478..6178461b3 160000 --- a/packages/claude-code-headless +++ b/packages/claude-code-headless @@ -1 +1 @@ -Subproject commit 235eee4787ec80b232b7e8dd0b28e2e2d4466bec +Subproject commit 6178461b34b3d35510d6ffa756c7b5e264c58a01