Files
orca/src/main/native-chat/transcript-read-cache.test.ts
T

226 lines
9.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { mkdir, mkdtemp, rm, utimes, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type * as TranscriptReader from './transcript-reader'
// Spy on the underlying reader so we can assert cache hits issue zero reads.
const readSpy = vi.hoisted(() => vi.fn())
vi.mock('./transcript-reader', async (importOriginal) => {
const actual = await importOriginal<typeof TranscriptReader>()
return {
...actual,
readNativeChatTranscript: (...args: Parameters<typeof actual.readNativeChatTranscript>) => {
readSpy(...args)
return actual.readNativeChatTranscript(...args)
}
}
})
import { isTextBlock } from '../../shared/native-chat-types'
import {
clearNativeChatTranscriptCache,
readNativeChatTranscriptCached,
setNativeChatTranscriptCacheMaxBytesForTests
} from './transcript-read-cache'
let tempRoots: string[] = []
function jsonLines(records: unknown[]): string {
return records.map((record) => JSON.stringify(record)).join('\n')
}
async function seedSession(sessionId: string, turns: number): Promise<string> {
const root = await mkdtemp(join(tmpdir(), 'orca-native-chat-cache-'))
tempRoots.push(root)
const projectDir = join(root, '.claude', 'projects', '-repo')
await mkdir(projectDir, { recursive: true })
const records = Array.from({ length: turns }, (_unused, n) => ({
type: 'user',
uuid: `u-${n}`,
timestamp: `2026-06-01T10:00:0${n}.000Z`,
message: { role: 'user', content: `m${n}` }
}))
const filePath = join(projectDir, `${sessionId}.jsonl`)
await writeFile(filePath, jsonLines(records))
process.env.HOME = root
return filePath
}
// Writes a transcript file at an explicit path whose on-disk size is ~`bytes`
// (padded via one big user message), returning the path. Read it back by passing
// the path as `transcriptPath` so resolution doesn't depend on process.env.HOME.
async function seedBigFile(name: string, bytes: number): Promise<string> {
const root = await mkdtemp(join(tmpdir(), 'orca-native-chat-cache-bytes-'))
tempRoots.push(root)
const filePath = join(root, `${name}.jsonl`)
const record = {
type: 'user',
uuid: `u-${name}`,
timestamp: '2026-06-01T10:00:00.000Z',
message: { role: 'user', content: 'x'.repeat(Math.max(1, bytes)) }
}
await writeFile(filePath, jsonLines([record]))
return filePath
}
beforeEach(() => {
clearNativeChatTranscriptCache()
setNativeChatTranscriptCacheMaxBytesForTests()
readSpy.mockClear()
})
afterEach(async () => {
await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true })))
tempRoots = []
})
describe('readNativeChatTranscriptCached', () => {
it('returns the same cached object on an mtime hit without re-reading', async () => {
await seedSession('sess-hit', 3)
const first = await readNativeChatTranscriptCached('claude', 'sess-hit')
const second = await readNativeChatTranscriptCached('claude', 'sess-hit')
expect(readSpy).toHaveBeenCalledTimes(1)
// Same reference: the second call served the cached parse.
expect(second).toBe(first)
})
it('re-reads when the file mtime changes', async () => {
const filePath = await seedSession('sess-mtime', 2)
await readNativeChatTranscriptCached('claude', 'sess-mtime')
expect(readSpy).toHaveBeenCalledTimes(1)
// Bump mtime into the future to invalidate without changing content shape.
const future = new Date(Date.now() + 5_000)
await utimes(filePath, future, future)
await readNativeChatTranscriptCached('claude', 'sess-mtime')
expect(readSpy).toHaveBeenCalledTimes(2)
})
it('clear() empties the cache so the next read re-reads', async () => {
await seedSession('sess-clear', 1)
await readNativeChatTranscriptCached('claude', 'sess-clear')
clearNativeChatTranscriptCache()
await readNativeChatTranscriptCached('claude', 'sess-clear')
expect(readSpy).toHaveBeenCalledTimes(2)
})
it('returns an error result for an unknown session without throwing', async () => {
await seedSession('present', 1)
const result = await readNativeChatTranscriptCached('claude', 'absent')
expect('error' in result && result.error).toBeTruthy()
})
// 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
// worktree's cached parse be served to the other whenever their file mtimes
// coincided, leaking A's chat transcript into C's panel (#7326).
it('never serves one file’s parse for a different file that shares a sessionId', async () => {
const root = await mkdtemp(join(tmpdir(), 'orca-native-chat-cache-xwt-'))
tempRoots.push(root)
const fileA = join(root, 'worktree-a.jsonl')
const fileC = join(root, 'worktree-c.jsonl')
await writeFile(
fileA,
jsonLines([
{
type: 'user',
uuid: 'a0',
timestamp: '2026-06-01T10:00:00.000Z',
message: { role: 'user', content: 'from-worktree-A' }
}
])
)
await writeFile(
fileC,
jsonLines([
{
type: 'user',
uuid: 'c0',
timestamp: '2026-06-01T10:00:00.000Z',
message: { role: 'user', content: 'from-worktree-C' }
}
])
)
// Force IDENTICAL mtimes so a sessionId-only key's mtime guard cannot rescue
// the collision — this is the intermittent, activity-driven case.
const when = new Date('2026-06-01T10:00:00.000Z')
await utimes(fileA, when, when)
await utimes(fileC, when, when)
const readText = (result: Awaited<ReturnType<typeof readNativeChatTranscriptCached>>): string =>
'messages' in result
? result.messages
.flatMap((message) => message.blocks)
.filter(isTextBlock)
.map((block) => block.text)
.join(' ')
: ''
// Same sessionId, different transcript files (worktree A resumed into C).
const a = await readNativeChatTranscriptCached('claude', 'shared-session', fileA)
const c = await readNativeChatTranscriptCached('claude', 'shared-session', fileC)
expect(readText(a)).toContain('from-worktree-A')
expect(readText(c)).toContain('from-worktree-C')
expect(readText(c)).not.toContain('from-worktree-A')
// Distinct files must not share a cached parse object.
expect(c).not.toBe(a)
})
// Why: each cached entry is a FULL unwindowed transcript parse; heavy agent
// sessions are tens of MB, so the count-only cap let 50 entries retain multiple
// GB in the one process serving desktop + every paired client. The byte budget
// evicts the oldest large entries while always keeping the most-recent.
it('evicts the oldest entry once total cached bytes exceed the budget', async () => {
// ~4 KB per file, budget 9 KB → holds 2, a third read evicts the oldest.
setNativeChatTranscriptCacheMaxBytesForTests(9 * 1024)
const fileA = await seedBigFile('big-a', 4 * 1024)
const fileB = await seedBigFile('big-b', 4 * 1024)
const fileC = await seedBigFile('big-c', 4 * 1024)
await readNativeChatTranscriptCached('claude', 'sa', fileA) // spy 1
await readNativeChatTranscriptCached('claude', 'sb', fileB) // spy 2
await readNativeChatTranscriptCached('claude', 'sc', fileC) // spy 3, evicts A
expect(readSpy).toHaveBeenCalledTimes(3)
// C is the most-recent → still cached (no re-read).
await readNativeChatTranscriptCached('claude', 'sc', fileC)
expect(readSpy).toHaveBeenCalledTimes(3)
// A was evicted by the byte cap → this re-reads (and evicts B in turn).
await readNativeChatTranscriptCached('claude', 'sa', fileA)
expect(readSpy).toHaveBeenCalledTimes(4)
// B is now evicted → also re-reads.
await readNativeChatTranscriptCached('claude', 'sb', fileB)
expect(readSpy).toHaveBeenCalledTimes(5)
})
it('keeps a single active transcript larger than the whole budget cached', async () => {
// A lone entry over budget must NOT be dropped, else every read re-parses.
setNativeChatTranscriptCacheMaxBytesForTests(1024)
const big = await seedBigFile('huge', 8 * 1024)
await readNativeChatTranscriptCached('claude', 'huge', big)
await readNativeChatTranscriptCached('claude', 'huge', big)
expect(readSpy).toHaveBeenCalledTimes(1)
})
it('does not evict small entries under the default budget (no regression for typical use)', async () => {
const files = await Promise.all([
seedBigFile('s1', 512),
seedBigFile('s2', 512),
seedBigFile('s3', 512)
])
for (const [i, file] of files.entries()) {
await readNativeChatTranscriptCached('claude', `sess-${i}`, file)
}
expect(readSpy).toHaveBeenCalledTimes(3)
// Re-read all three: every one is still cached (byte budget never triggered).
for (const [i, file] of files.entries()) {
await readNativeChatTranscriptCached('claude', `sess-${i}`, file)
}
expect(readSpy).toHaveBeenCalledTimes(3)
})
})