fix(native-chat): retry not-yet-flushed transcripts instead of settling into a permanent error (#8418)

* 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 <kaynan.camargo@terceiro-sky.com.br>
This commit is contained in:
Kaynan Sampaio de Camargo 2026-07-13 12:45:46 -07:00 committed by GitHub
parent 527c692b71
commit dc4fb2aa03
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 649 additions and 51 deletions

View File

@ -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

View File

@ -91,7 +91,9 @@ export async function readNativeChatTranscriptCached(
): Promise<ReadTranscriptResult> {
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)

View File

@ -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)
}
})
})

View File

@ -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<ReadTranscriptResult> {
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) }
}
}

View File

@ -24,6 +24,14 @@ async function tempFile(initial: string): Promise<string> {
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<string> {
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)
})
})

View File

@ -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<NativeChatTranscriptSubscription> {
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<NativeChatTranscriptSubscription | null> {
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<typeof setTimeout> | 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<void> {
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<NativeChatTranscriptSubscription> {
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)
}

View File

@ -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[]

View File

@ -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<Root> {
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')
})
})

View File

@ -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<typeof setTimeout> | 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
])
}

View File

@ -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<void> {
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<void> {
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<void> {
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 })
}
})
})