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:
parent
527c692b71
commit
dc4fb2aa03
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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[]
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
}
|
||||
})
|
||||
})
|
||||
Loading…
Reference in New Issue