mirror of
https://github.com/stablyai/orca.git
synced 2026-09-30 16:02:56 +00:00
Root cause: the test cleared the injected tail-reader failure *before*
writing the recovered transcript line. The capped rotation retry loop is
still firing at that point, so a retry drain could succeed against the
still-empty file, consume the pending initial drain, and emit an empty
initial snapshot (`[], false, 0, undefined, undefined`). The later manual
watch callback then took the append path, and `u-recovered` never reached
onInitialSnapshot -- producing the CI failure
`expected [ false, +0, ...(5) ] to deeply equal ArrayContaining{...}`.
That empty-snapshot-then-append sequence is correct product behavior, so
this is a test bug: write the content first, then clear the failure, so no
drain can ever observe a readable-but-empty transcript. The assertion now
checks the exact recovered snapshot instead of a flattened
arrayContaining, so an empty recovery snapshot fails loudly.
190 lines
6.9 KiB
TypeScript
190 lines
6.9 KiB
TypeScript
import { EventEmitter } from 'node:events'
|
|
import type { FSWatcher } from 'node:fs'
|
|
import type * as NodeFs from 'node:fs'
|
|
import { appendFile, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
|
import { tmpdir } from 'node:os'
|
|
import { join } from 'node:path'
|
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
import type * as TranscriptTailReader from './transcript-tail-reader'
|
|
|
|
const { tailReaderState, watchers, watchCallbacks, watchMock } = vi.hoisted(() => ({
|
|
tailReaderState: { failure: null as Error | null },
|
|
watchers: [] as (EventEmitter & { close: ReturnType<typeof vi.fn> })[],
|
|
watchCallbacks: [] as ((event: string, filename: string | Buffer | null) => void)[],
|
|
watchMock: vi.fn()
|
|
}))
|
|
|
|
vi.mock('node:fs', async () => {
|
|
const actual = await vi.importActual<typeof NodeFs>('node:fs')
|
|
watchMock.mockImplementation((_path, callback) => {
|
|
const watcher = Object.assign(new EventEmitter(), { close: vi.fn() })
|
|
watchers.push(watcher)
|
|
watchCallbacks.push(callback)
|
|
return watcher as unknown as FSWatcher
|
|
})
|
|
return { ...actual, watch: watchMock }
|
|
})
|
|
|
|
vi.mock('./transcript-tail-reader', async () => {
|
|
const actual = await vi.importActual<typeof TranscriptTailReader>('./transcript-tail-reader')
|
|
return {
|
|
...actual,
|
|
readNativeChatTranscriptTailFile: (
|
|
...args: Parameters<typeof actual.readNativeChatTranscriptTailFile>
|
|
) => {
|
|
if (tailReaderState.failure) {
|
|
return Promise.reject(tailReaderState.failure)
|
|
}
|
|
return actual.readNativeChatTranscriptTailFile(...args)
|
|
}
|
|
}
|
|
})
|
|
|
|
import { subscribeNativeChatTranscript } from './transcript-watch'
|
|
|
|
const roots: string[] = []
|
|
|
|
afterEach(async () => {
|
|
watchers.length = 0
|
|
watchCallbacks.length = 0
|
|
watchMock.mockClear()
|
|
tailReaderState.failure = null
|
|
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
|
|
})
|
|
|
|
describe('native chat transcript watcher errors', () => {
|
|
it('handles a watcher error and rebinds after the directory is readable', async () => {
|
|
const root = await mkdtemp(join(tmpdir(), 'orca-native-chat-watch-error-'))
|
|
roots.push(root)
|
|
const filePath = join(root, 'transcript.jsonl')
|
|
await writeFile(filePath, '')
|
|
const subscription = await subscribeNativeChatTranscript({
|
|
agent: 'claude',
|
|
sessionId: 'session',
|
|
filePath,
|
|
onAppend: () => {},
|
|
debounceMs: 0
|
|
})
|
|
|
|
expect(() => watchers[0]!.emit('error', new Error('EPERM'))).not.toThrow()
|
|
await vi.waitFor(() => expect(watchMock).toHaveBeenCalledTimes(2))
|
|
|
|
subscription.unsubscribe()
|
|
expect(watchers[0]!.close).toHaveBeenCalledOnce()
|
|
expect(watchers[1]!.close).toHaveBeenCalledOnce()
|
|
})
|
|
|
|
it('keeps retrying after the old recovery window and tails a recreated directory', async () => {
|
|
const root = await mkdtemp(join(tmpdir(), 'orca-native-chat-watch-gap-'))
|
|
roots.push(root)
|
|
const filePath = join(root, 'transcript.jsonl')
|
|
await writeFile(filePath, '')
|
|
const onInitialSnapshot = vi.fn()
|
|
const onAppend = vi.fn()
|
|
const subscription = await subscribeNativeChatTranscript({
|
|
agent: 'claude',
|
|
sessionId: 'session',
|
|
filePath,
|
|
onInitialSnapshot,
|
|
onAppend,
|
|
initialLimit: 40,
|
|
debounceMs: 0
|
|
})
|
|
await vi.waitFor(() => expect(onInitialSnapshot).toHaveBeenCalledOnce())
|
|
|
|
await rm(root, { recursive: true, force: true })
|
|
watchers[0]!.emit('error', new Error('EPERM'))
|
|
await new Promise((resolve) => setTimeout(resolve, 1_200))
|
|
expect(watchMock).toHaveBeenCalledTimes(1)
|
|
|
|
await mkdir(root, { recursive: true })
|
|
await writeFile(filePath, claudeLine('u-recreated', 'user', 'back'))
|
|
await vi.waitFor(() => expect(watchMock).toHaveBeenCalledTimes(2), { timeout: 2_000 })
|
|
|
|
await appendFile(filePath, claudeLine('a-recreated', 'assistant', 'reply'))
|
|
watchCallbacks[1]!('change', 'transcript.jsonl')
|
|
await vi.waitFor(() =>
|
|
expect(onAppend.mock.calls.flat(2)).toEqual(
|
|
expect.arrayContaining([expect.objectContaining({ id: 'a-recreated' })])
|
|
)
|
|
)
|
|
|
|
subscription.unsubscribe()
|
|
expect(watchers[1]!.close).toHaveBeenCalledOnce()
|
|
})
|
|
|
|
it('surfaces an error snapshot when the initial drain throws', async () => {
|
|
const root = await mkdtemp(join(tmpdir(), 'orca-native-chat-initial-error-'))
|
|
roots.push(root)
|
|
const filePath = join(root, 'transcript.jsonl')
|
|
await writeFile(filePath, '')
|
|
tailReaderState.failure = new Error('deterministic read failure')
|
|
const onInitialSnapshot = vi.fn()
|
|
const onAppend = vi.fn()
|
|
const subscription = await subscribeNativeChatTranscript({
|
|
agent: 'claude',
|
|
sessionId: 'session',
|
|
filePath,
|
|
onInitialSnapshot,
|
|
onAppend,
|
|
initialLimit: 40,
|
|
debounceMs: 0
|
|
})
|
|
expect(subscription.watching).toBe(true)
|
|
|
|
await vi.waitFor(() =>
|
|
expect(onInitialSnapshot).toHaveBeenCalledWith([], false, 0, 'Transcript unavailable')
|
|
)
|
|
// Surfaced once, not spammed by the capped rotation retry loop.
|
|
await new Promise((resolve) => setTimeout(resolve, 120))
|
|
expect(onInitialSnapshot).toHaveBeenCalledOnce()
|
|
|
|
subscription.unsubscribe()
|
|
})
|
|
|
|
it('still wins with a real initial snapshot once the transcript becomes readable', async () => {
|
|
const root = await mkdtemp(join(tmpdir(), 'orca-native-chat-initial-recover-'))
|
|
roots.push(root)
|
|
const filePath = join(root, 'transcript.jsonl')
|
|
await writeFile(filePath, '')
|
|
tailReaderState.failure = new Error('deterministic read failure')
|
|
const onInitialSnapshot = vi.fn()
|
|
const subscription = await subscribeNativeChatTranscript({
|
|
agent: 'claude',
|
|
sessionId: 'session',
|
|
filePath,
|
|
onInitialSnapshot,
|
|
onAppend: () => {},
|
|
initialLimit: 40,
|
|
debounceMs: 0
|
|
})
|
|
await vi.waitFor(() =>
|
|
expect(onInitialSnapshot).toHaveBeenCalledWith([], false, 0, 'Transcript unavailable')
|
|
)
|
|
|
|
// initialDrain stays true after the error, so a recovered read delivers the
|
|
// real snapshot instead of stranding the client on the error frame.
|
|
// The content must land before reads recover: the capped rotation retry is
|
|
// still firing, and any drain that succeeds against a still-empty file
|
|
// legitimately consumes the pending initial drain with an empty snapshot.
|
|
await writeFile(filePath, claudeLine('u-recovered', 'user', 'back'))
|
|
tailReaderState.failure = null
|
|
watchCallbacks[0]!('change', 'transcript.jsonl')
|
|
await vi.waitFor(() => expect(onInitialSnapshot).toHaveBeenCalledTimes(2))
|
|
expect(onInitialSnapshot.mock.calls[1]![0]).toEqual([
|
|
expect.objectContaining({ id: 'u-recovered' })
|
|
])
|
|
|
|
subscription.unsubscribe()
|
|
})
|
|
})
|
|
|
|
function claudeLine(uuid: string, role: 'user' | 'assistant', text: string): string {
|
|
return `${JSON.stringify({
|
|
type: role,
|
|
uuid,
|
|
timestamp: '2026-06-01T10:00:00.000Z',
|
|
message: { role, content: role === 'user' ? text : [{ type: 'text', text }] }
|
|
})}\n`
|
|
}
|