Files
orca/src/main/native-chat/transcript-reader.ts
T
Kaynan Sampaio de Camargoandkaynan dc4fb2aa03 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>
2026-07-13 12:45:46 -07:00

68 lines
2.7 KiB
TypeScript

import { createReadStream } from 'node:fs'
import type { AgentType, NativeChatMessage } from '../../shared/native-chat-types'
import { errorMessage } from '../ai-vault/session-scanner-values'
import { resolveSessionFilePath, type ResolveSessionFileOptions } from './session-file-resolver'
import {
decodeClaudeTranscriptLine,
decodeCodexTranscriptLine,
decodeGrokTranscriptLine
} from './transcript-line-decoders'
import { decodeTranscriptStream } from './transcript-stream-lines'
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). */
filePath?: string
}
/**
* Read the ENTIRE Claude/Codex JSONL transcript for an agent + session id into
* the NativeChatMessage model. Unlike the AI-Vault preview scan, this applies
* NO message cap. Unknown record types are skipped rather than throwing, so a
* single malformed/unrecognized line cannot fail the whole read. The per-line
* record-to-message mapping is shared with the live tailer.
*/
export async function readNativeChatTranscript(
agent: AgentType,
sessionId: string,
options: ReadTranscriptOptions = {}
): Promise<ReadTranscriptResult> {
const filePath = options.filePath ?? (await resolveSessionFilePath(agent, sessionId, options))
if (!filePath) {
return { error: `No transcript found for ${agent} session ${sessionId}`, notFound: true }
}
try {
if (agent === 'claude') {
return { messages: await readTranscript(filePath, decodeClaudeTranscriptLine) }
}
if (agent === 'codex') {
return { messages: await readTranscript(filePath, decodeCodexTranscriptLine) }
}
if (agent === 'grok') {
return { messages: await readTranscript(filePath, decodeGrokTranscriptLine) }
}
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) }
}
}
async function readTranscript(
filePath: string,
decode: (line: string, fallbackId: string) => NativeChatMessage | null
): Promise<NativeChatMessage[]> {
const stream = createReadStream(filePath, { encoding: 'utf-8' })
const { messages } = await decodeTranscriptStream(stream, filePath, 0, decode, true)
return messages
}