From dc4fb2aa0350f654f42e629d4b94d35ea484d002 Mon Sep 17 00:00:00 2001 From: Kaynan Sampaio de Camargo <33468632+kaynansc@users.noreply.github.com> Date: Mon, 13 Jul 2026 12:45:46 -0700 Subject: [PATCH] fix(native-chat): retry not-yet-flushed transcripts instead of settling into a permanent error (#8418) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(native-chat): retry not-yet-flushed transcripts instead of settling into a permanent error A freshly-created session's transcript .jsonl lands on disk seconds to minutes after the process starts. Native chat's one-shot read raced that first flush: a miss became a permanent "No transcript found" error and the live-tail subscription silently degraded to a no-op, so the pane never recovered even after the file appeared. - transcript-reader/read-cache: mark the miss with notFound so callers can tell "not flushed yet" from a real parse/IO error (never cached). - transcript-watch: poll resolve+install (500ms backoff, 5s cap) for the subscription's lifetime instead of returning a dead no-op watcher. - use-native-chat-live-session: retry a notFound read with backoff for up to 60s while staying in the loading state, and let live appends render over a stale initial-read error. Fixes #8401 Claude-Session: https://claude.ai/code/session_01HA5g3X7wCakBttBpDru9Fp * fix(native-chat): address CodeRabbit review — ENOENT retryable, content over spinner, blank-id guard, unref poll timer - transcript-reader: an ENOENT after a successful resolve is the same first-flush/rotation race as an unresolved path — mark it notFound. - use-native-chat-live-session: live appends landing mid-retry render instead of the loading state (mirrors the stale-error gate). - transcript-watch: bail out for a blank session id with no explicit file (nothing to resolve-poll), and unref the poll timer so headless serve shutdown is never held open by an unresolvable session. Claude-Session: https://claude.ai/code/session_01HA5g3X7wCakBttBpDru9Fp --------- Co-authored-by: kaynan --- .../native-chat/transcript-read-cache.test.ts | 13 ++ src/main/native-chat/transcript-read-cache.ts | 4 +- .../native-chat/transcript-reader.test.ts | 26 ++- src/main/native-chat/transcript-reader.ts | 13 +- src/main/native-chat/transcript-watch.test.ts | 82 +++++++++ src/main/native-chat/transcript-watch.ts | 173 +++++++++++++++--- src/preload/api-types.ts | 6 +- .../use-native-chat-live-session.test.ts | 126 +++++++++++++ .../use-native-chat-live-session.ts | 92 +++++++--- .../e2e/native-chat-first-flush-race.spec.ts | 165 +++++++++++++++++ 10 files changed, 649 insertions(+), 51 deletions(-) create mode 100644 tests/e2e/native-chat-first-flush-race.spec.ts diff --git a/src/main/native-chat/transcript-read-cache.test.ts b/src/main/native-chat/transcript-read-cache.test.ts index 6f0fc39ac..0d61f25f4 100644 --- a/src/main/native-chat/transcript-read-cache.test.ts +++ b/src/main/native-chat/transcript-read-cache.test.ts @@ -110,6 +110,19 @@ describe('readNativeChatTranscriptCached', () => { expect('error' in result && result.error).toBeTruthy() }) + // Why: a just-created session's transcript can take up to minutes to exist on + // disk (#8401) — the miss must be marked notFound so watch/renderer callers + // retry instead of settling into a permanent error, and it must never be + // cached (a real error already isn't cached; this locks in the same for a miss). + it('marks a resolve miss as notFound and does not cache it', async () => { + await seedSession('present-2', 1) + const first = await readNativeChatTranscriptCached('claude', 'absent-2') + expect('error' in first && first.notFound).toBe(true) + expect(readSpy).not.toHaveBeenCalled() + const second = await readNativeChatTranscriptCached('claude', 'absent-2') + expect(second).not.toBe(first) + }) + // Why: two worktrees can present the SAME (agent, sessionId) via different // transcript files — e.g. the same session resumed into a second worktree, // which writes a new transcript file. Keying the cache by sessionId let one diff --git a/src/main/native-chat/transcript-read-cache.ts b/src/main/native-chat/transcript-read-cache.ts index 9f4df5bde..8943c223d 100644 --- a/src/main/native-chat/transcript-read-cache.ts +++ b/src/main/native-chat/transcript-read-cache.ts @@ -91,7 +91,9 @@ export async function readNativeChatTranscriptCached( ): Promise { const filePath = await resolveSessionFilePath(agent, sessionId, { transcriptPath }) if (!filePath) { - return { error: `No transcript found for ${agent} session ${sessionId}` } + // Not cached (see below): a not-yet-flushed transcript should be re-checked + // on the next call, not pinned as a settled miss (#8401). + return { error: `No transcript found for ${agent} session ${sessionId}`, notFound: true } } const key = cacheKey(agent, filePath) diff --git a/src/main/native-chat/transcript-reader.test.ts b/src/main/native-chat/transcript-reader.test.ts index c6c2223c8..cd87f2bdd 100644 --- a/src/main/native-chat/transcript-reader.test.ts +++ b/src/main/native-chat/transcript-reader.test.ts @@ -242,19 +242,41 @@ describe('readNativeChatTranscript (codex)', () => { }) describe('readNativeChatTranscript (errors)', () => { - it('returns an error for an unreadable/missing file without throwing', async () => { + // Why: ENOENT after a successful resolve is the same first-flush/rotation + // race as an unresolved path (#8401) — it must stay retry-worthy. + it('marks an ENOENT on a directly-passed path as notFound (vanished after resolve)', async () => { const result = await readNativeChatTranscript('claude', 'sess', { filePath: join(tmpdir(), 'orca-native-chat-does-not-exist.jsonl') }) expect('error' in result).toBe(true) + if ('error' in result) { + expect(result.notFound).toBe(true) + } }) - it('returns an error when no transcript can be resolved', async () => { + it('returns a real read error (no notFound) when the path exists but is unreadable', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-native-chat-unreadable-')) + tempRoots.push(root) + // A directory instead of a file fails the read with a non-ENOENT error. + const result = await readNativeChatTranscript('claude', 'sess', { filePath: root }) + expect('error' in result).toBe(true) + if ('error' in result) { + expect(result.notFound).toBeUndefined() + } + }) + + // Why: a just-created Claude Code session's transcript can take up to minutes + // to exist on disk (#8401) — the miss must be marked retry-worthy so callers + // above (cache, watch, renderer) don't settle into a permanent error. + it('marks an unresolved session as notFound so callers know to retry', async () => { const root = await mkdtemp(join(tmpdir(), 'orca-native-chat-noresolve-')) tempRoots.push(root) const result = await readNativeChatTranscript('claude', 'missing', { claudeProjectsDir: join(root, 'empty') }) expect('error' in result).toBe(true) + if ('error' in result) { + expect(result.notFound).toBe(true) + } }) }) diff --git a/src/main/native-chat/transcript-reader.ts b/src/main/native-chat/transcript-reader.ts index 1dbbd7dd8..e2ea727c8 100644 --- a/src/main/native-chat/transcript-reader.ts +++ b/src/main/native-chat/transcript-reader.ts @@ -9,7 +9,11 @@ import { } from './transcript-line-decoders' import { decodeTranscriptStream } from './transcript-stream-lines' -export type ReadTranscriptResult = { messages: NativeChatMessage[] } | { error: string } +export type ReadTranscriptResult = + | { messages: NativeChatMessage[] } + // notFound marks a retry-worthy miss (transcript not flushed to disk yet, + // #8401) as opposed to a real parse/IO error callers surface immediately. + | { error: string; notFound?: true } export type ReadTranscriptOptions = ResolveSessionFileOptions & { /** Resolve directly to this file, skipping path discovery (used by tests). */ @@ -30,7 +34,7 @@ export async function readNativeChatTranscript( ): Promise { const filePath = options.filePath ?? (await resolveSessionFilePath(agent, sessionId, options)) if (!filePath) { - return { error: `No transcript found for ${agent} session ${sessionId}` } + return { error: `No transcript found for ${agent} session ${sessionId}`, notFound: true } } try { if (agent === 'claude') { @@ -44,6 +48,11 @@ export async function readNativeChatTranscript( } return { error: `Unsupported agent for native chat transcript: ${agent}` } } catch (err) { + // Why: ENOENT after a successful resolve is the same first-flush/rotation + // race as an unresolved path — keep it retry-worthy (#8401). + if ((err as NodeJS.ErrnoException | null)?.code === 'ENOENT') { + return { error: errorMessage(err), notFound: true } + } return { error: errorMessage(err) } } } diff --git a/src/main/native-chat/transcript-watch.test.ts b/src/main/native-chat/transcript-watch.test.ts index 55009521a..4ff621d05 100644 --- a/src/main/native-chat/transcript-watch.test.ts +++ b/src/main/native-chat/transcript-watch.test.ts @@ -24,6 +24,14 @@ async function tempFile(initial: string): Promise { return filePath } +// A path inside a fresh temp dir with nothing written yet — simulates a +// just-created session whose agent hasn't flushed its first JSONL line (#8401). +async function pendingFilePath(): Promise { + const root = await mkdtemp(join(tmpdir(), 'orca-native-chat-watch-pending-')) + tempRoots.push(root) + return join(root, 'rollout.jsonl') +} + function claudeLine(uuid: string, role: 'user' | 'assistant', text: string): string { return `${JSON.stringify({ type: role, @@ -237,3 +245,77 @@ describe('subscribeNativeChatTranscript', () => { sub.unsubscribe() }) }) + +// Regression for #8401: Claude Code (and other agents) can take from ~3s to +// minutes to flush a brand-new session's first JSONL line, so the file +// genuinely doesn't exist when native chat subscribes. Before this fix, +// subscribeNativeChatTranscript returned a permanent no-op the instant the +// file was missing and never recovered once it appeared. +describe('subscribeNativeChatTranscript (resolve-poll for a not-yet-created file, #8401)', () => { + it('keeps retrying resolve+install and tails the file once it is created', async () => { + const filePath = await pendingFilePath() + const seen: NativeChatMessage[] = [] + + const sub = await subscribeNativeChatTranscript({ + agent: 'claude', + sessionId: 'ignored', + filePath, + onAppend: (messages) => seen.push(...messages), + debounceMs: 5, + resolvePollIntervalMs: 20 + }) + + // Nothing installed yet — the file doesn't exist on disk. + expect(getActiveNativeChatWatcherCount()).toBe(0) + + // The agent flushes its first turn well after subscribe. + await new Promise((resolve) => setTimeout(resolve, 50)) + await writeFile(filePath, claudeLine('u-1', 'user', 'hello')) + + await waitFor(() => seen.some((m) => m.id === 'u-1')) + expect(getActiveNativeChatWatcherCount()).toBe(1) + + sub.unsubscribe() + expect(getActiveNativeChatWatcherCount()).toBe(0) + }) + + it('returns a no-op (no resolve poll) for a blank session id with no explicit file', async () => { + const before = getActiveNativeChatWatcherCount() + const sub = await subscribeNativeChatTranscript({ + agent: 'claude', + sessionId: ' ', + onAppend: () => {}, + debounceMs: 5, + resolvePollIntervalMs: 10 + }) + + // An unresolvable target must not spin the resolve poll forever. + await new Promise((resolve) => setTimeout(resolve, 60)) + expect(getActiveNativeChatWatcherCount()).toBe(before) + sub.unsubscribe() + sub.unsubscribe() + expect(getActiveNativeChatWatcherCount()).toBe(before) + }) + + it('unsubscribing during the poll phase leaves no watcher or timer alive', async () => { + const filePath = await pendingFilePath() + const before = getActiveNativeChatWatcherCount() + + const sub = await subscribeNativeChatTranscript({ + agent: 'claude', + sessionId: 'ignored', + filePath, + onAppend: () => {}, + debounceMs: 5, + resolvePollIntervalMs: 20 + }) + + // Unsubscribe while still polling — the file is never created in this test. + sub.unsubscribe() + expect(getActiveNativeChatWatcherCount()).toBe(before) + + // Give any stray timer a chance to fire; it must not install a watcher. + await new Promise((resolve) => setTimeout(resolve, 100)) + expect(getActiveNativeChatWatcherCount()).toBe(before) + }) +}) diff --git a/src/main/native-chat/transcript-watch.ts b/src/main/native-chat/transcript-watch.ts index 4bbd934f0..1f55c3312 100644 --- a/src/main/native-chat/transcript-watch.ts +++ b/src/main/native-chat/transcript-watch.ts @@ -29,6 +29,10 @@ export type SubscribeNativeChatTranscriptArgs = ResolveSessionFileOptions & { filePath?: string /** Coalesce window for rapid fs.watch events (ms). Defaults to 40ms. */ debounceMs?: number + /** Overrides the resolve-poll interval (see subscribeViaResolvePoll) so tests + * don't wait out the production backoff. Production ignores this and backs + * off from 500ms to a 5s cap. */ + resolvePollIntervalMs?: number } export type NativeChatTranscriptSubscription = { @@ -115,26 +119,17 @@ async function readAppendedMessages( } /** - * Subscribe to live appends on an agent's transcript file. Returns an - * unsubscribe fn that tears the watcher down completely. - * - * Handles file rotation/replacement: when the file shrinks (a new session id - * resolved to a smaller/newer file, or the file was truncated), the offset is - * reset to 0 so the replacement's content is read from the top. + * Install a live-tail watcher on an already-resolved file path. Returns null + * (rather than throwing) when `watch()` fails — e.g. the file vanished between + * resolve and install — so the caller can fall back to the resolve-poll below + * instead of surfacing a hard error. */ -export async function subscribeNativeChatTranscript( - args: SubscribeNativeChatTranscriptArgs -): Promise { - const { agent, sessionId, onAppend, debounceMs } = args - const decode = lineDecoderForAgent(agent) - const filePath = args.filePath ?? (await resolveSessionFilePath(agent, sessionId, args)) - - if (!filePath || !decode) { - // Nothing watchable — return a no-op teardown so callers can unconditionally - // unsubscribe without null-checks. - return { unsubscribe: () => {} } - } - +function installTranscriptWatcher( + filePath: string, + decode: (line: string, fallbackId: string) => NativeChatMessage | null, + onAppend: (messages: NativeChatMessage[]) => void, + debounceMs?: number +): NativeChatTranscriptSubscription | null { // Why: seed the offset at 0 so the FIRST drain re-reads the whole file. This // closes the read/subscribe race — a turn appended between the caller's // readSession EOF and the watcher install is still emitted. Re-emitted lines @@ -161,12 +156,12 @@ export async function subscribeNativeChatTranscript( do { pendingReadRequested = false try { - const currentSize = await fileSize(filePath!) + const currentSize = await fileSize(filePath) if (currentSize < offset) { // Rotation/replacement/truncation: re-read from the top. offset = 0 } - const { messages, consumedTo } = await readAppendedMessages(filePath!, offset, decode!) + const { messages, consumedTo } = await readAppendedMessages(filePath, offset, decode) offset = consumedTo if (!closed && messages.length > 0) { onAppend(messages) @@ -200,8 +195,8 @@ export async function subscribeNativeChatTranscript( try { watcher = watch(filePath, scheduleDrain) } catch { - // File vanished between resolve and watch — return a no-op teardown. - return { unsubscribe: () => {} } + // File vanished between resolve and watch. + return null } activeWatcherCount++ @@ -225,3 +220,135 @@ export async function subscribeNativeChatTranscript( } } } + +/** One resolve+install attempt. Returns null when the file isn't resolvable + * yet, or vanished between resolve and `watch()` — either case is retried by + * the resolve-poll rather than treated as a hard failure. */ +async function attemptInstall( + args: SubscribeNativeChatTranscriptArgs, + decode: (line: string, fallbackId: string) => NativeChatMessage | null +): Promise { + const filePath = args.filePath ?? (await resolveSessionFilePath(args.agent, args.sessionId, args)) + if (!filePath) { + return null + } + return installTranscriptWatcher(filePath, decode, args.onAppend, args.debounceMs) +} + +// Why: Claude Code (and other agents) can take from ~3s to minutes to flush a +// brand-new session's first JSONL line (#8401) — resolveSessionFilePath +// genuinely has nothing to find yet. Poll for it instead of going deaf; each +// attempt is a cheap glob, so the cost of polling is negligible next to the +// gap it covers. +const INITIAL_RESOLVE_POLL_MS = 500 +const MAX_RESOLVE_POLL_MS = 5_000 + +/** + * Background retry loop for a transcript that hasn't been resolvable yet. + * Returns a subscription immediately (per subscribeNativeChatTranscript's + * contract); the loop keeps retrying resolve+install until it succeeds or + * unsubscribe() cancels it. + */ +function subscribeViaResolvePoll( + args: SubscribeNativeChatTranscriptArgs, + decode: (line: string, fallbackId: string) => NativeChatMessage | null +): NativeChatTranscriptSubscription { + let closed = false + let installed: NativeChatTranscriptSubscription | null = null + let pollTimer: ReturnType | null = null + let delay = args.resolvePollIntervalMs ?? INITIAL_RESOLVE_POLL_MS + + function scheduleAttempt(): void { + if (closed) { + return + } + pollTimer = setTimeout(() => { + pollTimer = null + void runAttempt() + }, delay) + // Why: never hold the event loop open (headless `orca serve` shutdown) for + // a session that may genuinely never resolve. + pollTimer.unref?.() + // Only back off in production; a test-supplied interval stays fixed so + // tests resolve in bounded, predictable time. + if (args.resolvePollIntervalMs === undefined) { + delay = Math.min(delay * 2, MAX_RESOLVE_POLL_MS) + } + } + + async function runAttempt(): Promise { + if (closed) { + return + } + let result: NativeChatTranscriptSubscription | null + try { + result = await attemptInstall(args, decode) + } catch { + // Why: a transient resolve failure (EACCES/EIO during the glob) must not + // kill the poll loop with an unhandled rejection — retry like a miss. + result = null + } + if (closed) { + // unsubscribe() ran while this attempt was in flight. + result?.unsubscribe() + return + } + if (result) { + installed = result + return + } + scheduleAttempt() + } + + scheduleAttempt() + + return { + unsubscribe: () => { + if (closed) { + return + } + closed = true + if (pollTimer) { + clearTimeout(pollTimer) + pollTimer = null + } + installed?.unsubscribe() + installed = null + } + } +} + +/** + * Subscribe to live appends on an agent's transcript file. Returns an + * unsubscribe fn that tears the watcher down completely. + * + * Handles file rotation/replacement: when the file shrinks (a new session id + * resolved to a smaller/newer file, or the file was truncated), the offset is + * reset to 0 so the replacement's content is read from the top. + * + * When the transcript isn't resolvable yet (a just-created session whose + * agent hasn't flushed its first JSONL line, #8401), returns the subscription + * immediately and keeps retrying resolve+install in the background rather + * than returning a no-op that never recovers. + */ +export async function subscribeNativeChatTranscript( + args: SubscribeNativeChatTranscriptArgs +): Promise { + const decode = lineDecoderForAgent(args.agent) + if (!decode) { + // Nothing watchable — return a no-op teardown so callers can unconditionally + // unsubscribe without null-checks. + return { unsubscribe: () => {} } + } + // Why: a blank session id (and no explicit file) can never resolve — bail out + // instead of resolve-polling an unresolvable target forever. + if (!args.filePath && !args.sessionId.trim()) { + return { unsubscribe: () => {} } + } + + const installed = await attemptInstall(args, decode) + if (installed) { + return installed + } + return subscribeViaResolvePoll(args, decode) +} diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 5784d5f77..599c1a8f8 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -813,7 +813,11 @@ export type AiVaultApi = { onWindowFocused: (callback: () => void) => () => void } -export type NativeChatReadSessionResult = { messages: NativeChatMessage[] } | { error: string } +// notFound marks a miss caused by the transcript not existing on disk yet +// (retry-worthy), as opposed to a real read/parse error (#8401). +export type NativeChatReadSessionResult = + | { messages: NativeChatMessage[] } + | { error: string; notFound?: true } /** Messages appended to a live-tailed transcript since the previous emit. */ export type NativeChatAppendedMessages = NativeChatMessage[] diff --git a/src/renderer/src/components/native-chat/use-native-chat-live-session.test.ts b/src/renderer/src/components/native-chat/use-native-chat-live-session.test.ts index c2b6e5d7e..f60ce34d3 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-live-session.test.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-live-session.test.ts @@ -293,3 +293,129 @@ describe('useNativeChatLiveSession — transport routing', () => { expect(latest?.messages.map((m) => m.id)).not.toContain('stale') }) }) + +// Regression for #8401: a just-created Claude Code session's transcript can +// take up to minutes to exist on disk, so the first readSession commonly +// misses. Before this fix, the hook settled into a permanent 'error' phase +// on that first miss and never recovered. +describe('useNativeChatLiveSession — notFound retry (#8401)', () => { + const AGENT = 'claude' as const + const SESSION = 'sess-notfound' + const PANE = 'pane-notfound' + const roots: Root[] = [] + let latest: NativeChatLiveSession | null = null + + function Probe(props: UseNativeChatLiveSessionArgs): null { + latest = useNativeChatLiveSession(props) + return null + } + + async function render(props: UseNativeChatLiveSessionArgs): Promise { + const container = document.createElement('div') + const root = createRoot(container) + roots.push(root) + await act(async () => { + root.render(createElement(Probe, props)) + await Promise.resolve() + await Promise.resolve() + }) + return root + } + + beforeEach(() => { + useAppStore.setState({ agentStatusByPaneKey: {} }) + }) + + afterEach(() => { + for (const root of roots.splice(0)) { + act(() => root.unmount()) + } + latest = null + vi.clearAllMocks() + resetMockTransports() + vi.useRealTimers() + }) + + it('retries a notFound miss with backoff and settles into ready without ever exposing an error', async () => { + vi.useFakeTimers() + const transport = getMockTransport('env-1') + transport.readSession + .mockResolvedValueOnce({ error: 'No transcript found', notFound: true }) + .mockResolvedValueOnce({ messages: [assistant('a-1', 'hello')] }) + + await render({ paneKey: PANE, agent: AGENT, sessionId: SESSION, runtimeEnvironmentId: 'env-1' }) + expect(latest?.status).toBe('loading') + + // First backoff step (1s) fires the second readSession, which resolves. + await act(async () => { + await vi.advanceTimersByTimeAsync(1_000) + }) + + expect(latest?.status).not.toBe('error') + expect(transport.readSession).toHaveBeenCalledTimes(2) + expect(latest?.messages.map((m) => m.id)).toContain('a-1') + }) + + it('surfaces an error once the ~60s retry window is exhausted', async () => { + vi.useFakeTimers() + const transport = getMockTransport('env-1') + transport.readSession.mockResolvedValue({ error: 'No transcript found', notFound: true }) + + await render({ paneKey: PANE, agent: AGENT, sessionId: SESSION, runtimeEnvironmentId: 'env-1' }) + expect(latest?.status).toBe('loading') + + await act(async () => { + await vi.advanceTimersByTimeAsync(70_000) + }) + + expect(latest?.status).toBe('error') + expect(latest?.error).toBe('No transcript found') + }) + + it('renders live-appended content instead of loading while the read is still retrying', async () => { + vi.useFakeTimers() + const transport = getMockTransport('env-1') + transport.readSession.mockResolvedValue({ error: 'No transcript found', notFound: true }) + let onAppended: ((messages: NativeChatMessage[]) => void) | null = null + transport.subscribe.mockImplementationOnce( + (_args: unknown, cb: (m: NativeChatMessage[]) => void) => { + onAppended = cb + return transport.unsubscribe + } + ) + + await render({ paneKey: PANE, agent: AGENT, sessionId: SESSION, runtimeEnvironmentId: 'env-1' }) + expect(latest?.status).toBe('loading') + + // The watcher's first drain lands mid-retry — content must win over the spinner. + await act(async () => { + onAppended?.([assistant('a-early', 'landed during retry')]) + }) + + expect(latest?.status).not.toBe('loading') + expect(latest?.messages.map((m) => m.id)).toContain('a-early') + }) + + it('renders live-appended content even when the initial read settled into a permanent error', async () => { + const transport = getMockTransport('env-1') + transport.readSession.mockResolvedValueOnce({ error: 'unreadable transcript' }) + let onAppended: ((messages: NativeChatMessage[]) => void) | null = null + transport.subscribe.mockImplementationOnce( + (_args: unknown, cb: (m: NativeChatMessage[]) => void) => { + onAppended = cb + return transport.unsubscribe + } + ) + + await render({ paneKey: PANE, agent: AGENT, sessionId: SESSION, runtimeEnvironmentId: 'env-1' }) + expect(latest?.status).toBe('error') + + await act(async () => { + onAppended?.([assistant('a-late', 'landed late')]) + }) + + expect(latest?.status).not.toBe('error') + expect(latest?.error).toBeUndefined() + expect(latest?.messages.map((m) => m.id)).toContain('a-late') + }) +}) diff --git a/src/renderer/src/components/native-chat/use-native-chat-live-session.ts b/src/renderer/src/components/native-chat/use-native-chat-live-session.ts index 8832b6e19..384ae7247 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-live-session.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-live-session.ts @@ -76,6 +76,17 @@ function nextSubscriptionId(): string { return `native-chat-${subscriptionCounter}-${Date.now()}` } +// Why: a brand-new session's transcript can take seconds to minutes to appear +// on disk (#8401), so a `notFound` miss retries — 1s/2s/4s/8s then every 10s — +// until the window below elapses. +const NOTFOUND_RETRY_DELAYS_MS = [1_000, 2_000, 4_000, 8_000] +const NOTFOUND_RETRY_FIXED_DELAY_MS = 10_000 +const NOTFOUND_RETRY_WINDOW_MS = 60_000 + +function notFoundRetryDelayMs(attempt: number): number { + return NOTFOUND_RETRY_DELAYS_MS[attempt] ?? NOTFOUND_RETRY_FIXED_DELAY_MS +} + type ReadState = | { phase: 'loading' } | { phase: 'ready'; messages: NativeChatMessage[] } @@ -161,31 +172,50 @@ export function useNativeChatLiveSession( } let cancelled = false + let retryTimer: ReturnType | null = null + const retryStartedAt = Date.now() + // Re-bound as a plain const: TS doesn't retain the `!sessionId` narrowing + // above inside a nested function declaration (it's hoisted, so the + // narrowing can't be proven to hold at every call site). + const activeSessionId = sessionId limitRef.current = NATIVE_CHAT_INITIAL_LIMIT setRead({ phase: 'loading' }) replaceList(appendMergerRef.current, []) setAppended([]) setHasMore(false) - void transport - .readSession(agent, sessionId, limitRef.current, transcriptPath ?? undefined) - .then((result) => { - if (cancelled) { - return - } - if (result && 'error' in result) { - setRead({ phase: 'error', error: result.error }) - return - } - const messages = result?.messages ?? [] - setRead({ phase: 'ready', messages }) - setHasMore(hasMoreNativeChatHistory(messages.length, limitRef.current)) - }) - .catch((err: unknown) => { - if (!cancelled) { - setRead({ phase: 'error', error: err instanceof Error ? err.message : String(err) }) - } - }) + function loadSession(attempt: number): void { + void transport + .readSession(agent, activeSessionId, limitRef.current, transcriptPath ?? undefined) + .then((result) => { + if (cancelled) { + return + } + if (result && 'error' in result) { + // A not-yet-flushed transcript: stay in 'loading' and retry with + // backoff instead of settling into a permanent error (#8401). + if (result.notFound && Date.now() - retryStartedAt < NOTFOUND_RETRY_WINDOW_MS) { + retryTimer = setTimeout(() => { + retryTimer = null + loadSession(attempt + 1) + }, notFoundRetryDelayMs(attempt)) + return + } + setRead({ phase: 'error', error: result.error }) + return + } + const messages = result?.messages ?? [] + setRead({ phase: 'ready', messages }) + setHasMore(hasMoreNativeChatHistory(messages.length, limitRef.current)) + }) + .catch((err: unknown) => { + if (!cancelled) { + setRead({ phase: 'error', error: err instanceof Error ? err.message : String(err) }) + } + }) + } + + loadSession(0) const subscriptionId = nextSubscriptionId() const unsubscribe = transport.subscribe( @@ -208,6 +238,10 @@ export function useNativeChatLiveSession( return () => { cancelled = true + if (retryTimer) { + clearTimeout(retryTimer) + retryTimer = null + } // Desktop returns a sync unsubscribe fn; the web RPC bridge returns a // Promise instead (and can't deliver streaming callbacks). Calling a // Promise as a function crashed the whole chat view, so resolve it first @@ -304,9 +338,23 @@ export function useNativeChatLiveSession( sessionId, agent, hookState, - loading: read.phase === 'loading', - ...(read.phase === 'error' ? { error: read.error } : {}) + // Why: a watcher append (fix for #8401) can land content while the read is + // still retrying ('loading') or after it settled into 'error' — in both + // cases showing the live content beats a spinner or a stale error, so each + // override only applies while there is nothing appended to render. + loading: read.phase === 'loading' && appended.length === 0, + ...(read.phase === 'error' && appended.length === 0 ? { error: read.error } : {}) }) return { ...session, hasMore, loadingEarlier, loadEarlier } - }, [assembledMessages, read, sessionId, agent, hookState, hasMore, loadingEarlier, loadEarlier]) + }, [ + assembledMessages, + read, + sessionId, + agent, + hookState, + hasMore, + loadingEarlier, + loadEarlier, + appended + ]) } diff --git a/tests/e2e/native-chat-first-flush-race.spec.ts b/tests/e2e/native-chat-first-flush-race.spec.ts new file mode 100644 index 000000000..15ec43dc8 --- /dev/null +++ b/tests/e2e/native-chat-first-flush-race.spec.ts @@ -0,0 +1,165 @@ +import { randomUUID } from 'node:crypto' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import type { Page } from '@stablyai/playwright-test' +import { test, expect } from './helpers/orca-app' +import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { waitForActivePaneHookDescriptor, waitForActiveTerminalManager } from './helpers/terminal' +import type { GlobalSettings } from '../../src/shared/types' + +const LOADING_TITLE = 'Loading conversation…' +const ERROR_TITLE = 'Could not load conversation' + +async function enableNativeChatSetting(page: Page): Promise { + await page.evaluate(async () => { + const nextSettings = await window.api.settings.set({ experimentalNativeChat: true }) + window.__store?.setState({ settings: nextSettings as GlobalSettings }) + }) +} + +// Why: seeding agentStatusByPaneKey directly (rather than posting a real +// `/hook/claude` event) mirrors the technique agent-session-quit-resume.spec.ts +// uses to stay hermetic — it exercises the identical store → NativeChatView +// path a real Claude Code hook would drive, without an installed CLI. +async function seedClaudeProviderSession( + page: Page, + args: { paneKey: string; worktreeId: string; sessionId: string; transcriptPath: string } +): Promise { + await page.evaluate(({ paneKey, worktreeId, sessionId, transcriptPath }) => { + window.__store + ?.getState() + .setAgentStatus( + paneKey, + { state: 'working', prompt: 'e2e first-flush race probe', agentType: 'claude' }, + 'Claude', + undefined, + { worktreeId }, + { providerSession: { key: 'session_id', id: sessionId, transcriptPath } } + ) + }, args) +} + +// Why: toggleTabViewMode keys off the *unified* tab id, which can differ from +// the terminal tab id embedded in paneKey — resolve it the same way +// TerminalPane.tsx does before calling the store action a real toggle/shortcut +// would use. +async function toggleTerminalTabToChatView( + page: Page, + args: { tabId: string; worktreeId: string } +): Promise { + await page.evaluate(({ tabId, worktreeId }) => { + const store = window.__store + if (!store) { + throw new Error('Store unavailable') + } + const state = store.getState() + const unifiedTab = (state.unifiedTabsByWorktree[worktreeId] ?? []).find( + (tab) => tab.contentType === 'terminal' && tab.entityId === tabId + ) + if (!unifiedTab) { + throw new Error('Unified terminal tab not found for chat toggle') + } + state.toggleTabViewMode(unifiedTab.id) + }, args) +} + +function claudeTranscriptLines(args: { + sessionId: string + userText: string + assistantText: string +}): string { + // Why: distinct timestamps keep the rendered order deterministic (a tie is + // broken by uuid, which would put the assistant turn first). + const userTime = new Date() + const assistantTime = new Date(userTime.getTime() + 2_000) + const lines = [ + { + sessionId: args.sessionId, + uuid: `${args.sessionId}-user`, + timestamp: userTime.toISOString(), + type: 'user', + message: { role: 'user', content: [{ type: 'text', text: args.userText }] } + }, + { + sessionId: args.sessionId, + uuid: `${args.sessionId}-assistant`, + timestamp: assistantTime.toISOString(), + type: 'assistant', + message: { model: 'claude-opus-4', content: [{ type: 'text', text: args.assistantText }] } + } + ] + return `${lines.map((line) => JSON.stringify(line)).join('\n')}\n` +} + +test.describe('Native chat first-flush transcript race (#8401)', () => { + test('stays in loading (never errors) until a not-yet-flushed transcript appears, then hydrates live', async ({ + orcaPage + }, testInfo) => { + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + + const descriptor = await waitForActivePaneHookDescriptor(orcaPage) + const [tabId] = descriptor.paneKey.split(':') + const sessionId = `e2e-first-flush-${randomUUID()}` + + // Why: a real Claude Code session flushes its first JSONL line up to + // minutes after launch (#8401) — this directory intentionally has no file + // yet when the pane resolves its providerSession. + const scratchDir = mkdtempSync(path.join(os.tmpdir(), 'orca-e2e-native-chat-')) + const transcriptPath = path.join(scratchDir, `${sessionId}.jsonl`) + + const screenshotDir = path.join( + process.cwd(), + 'validation-screenshots', + `native-chat-first-flush-race-${Date.now()}` + ) + mkdirSync(screenshotDir, { recursive: true }) + await testInfo.attach('validation-screenshot-dir', { + body: screenshotDir, + contentType: 'text/plain' + }) + + try { + await enableNativeChatSetting(orcaPage) + await seedClaudeProviderSession(orcaPage, { + paneKey: descriptor.paneKey, + worktreeId: descriptor.worktreeId, + sessionId, + transcriptPath + }) + await toggleTerminalTabToChatView(orcaPage, { tabId, worktreeId: descriptor.worktreeId }) + + await expect(orcaPage.locator('[data-native-chat-root="true"]')).toBeVisible({ + timeout: 15_000 + }) + await expect(orcaPage.getByText(LOADING_TITLE)).toBeVisible({ timeout: 10_000 }) + await expect(orcaPage.getByText(ERROR_TITLE)).toHaveCount(0) + await orcaPage.screenshot({ + path: path.join(screenshotDir, '01-loading-no-error.png') + }) + + // Why: a short real delay proves the first readSession attempt already + // hit the not-yet-flushed file (returning notFound) and the renderer's + // backoff retry — not a lucky first read — is what picks it up below. + await orcaPage.waitForTimeout(1_500) + await expect(orcaPage.getByText(ERROR_TITLE)).toHaveCount(0) + + const userText = 'Explain the native chat first-flush race fix for #8401' + const assistantText = + 'The main process now retries a not-yet-flushed transcript instead of caching a permanent miss.' + writeFileSync(transcriptPath, claudeTranscriptLines({ sessionId, userText, assistantText })) + + await expect(orcaPage.getByText(userText)).toBeVisible({ timeout: 30_000 }) + await expect(orcaPage.getByText(assistantText)).toBeVisible({ timeout: 30_000 }) + await expect(orcaPage.getByText(ERROR_TITLE)).toHaveCount(0) + await orcaPage.screenshot({ + path: path.join(screenshotDir, '02-hydrated.png') + }) + } finally { + rmSync(scratchDir, { recursive: true, force: true }) + } + }) +})