Skip reindexing unchanged transcripts on cold session list reads (#28798)

* Skip reindexing unchanged transcripts on cold session list reads

A cold session-list read re-parsed every self-contained transcript and rewrote its search rows, even when nothing had changed. That wasted work and churned the index on every list.

- When a transcript's stat (mtime, size, inode, device) matches a complete index entry, the list read now skips the rewrite. Agents with sidecar metadata, unknown sizes, or partial or stale entries still reread.
- Test fixtures that replay a "changed" transcript now pass a new mtime so they still exercise the replace path.
- New tests cover unchanged skips, changed size/mtime/file identity, sidecar refresh, due/failed repair, and append.
- Dependent-transcript and Codex title tests check that cold rereads keep search and title data current.

* Make the self-contained transcript check exhaustive over every agent

- Replace the hand-maintained `Set` of self-contained agents with a
  `Record<AiVaultAgent, boolean>`, so adding an agent type fails typecheck
  until someone decides whether its transcript is self-contained.
- Classification is unchanged: the agents that were in the set are `true`,
  and the rest are `false` with a short note on the sidecar or metadata
  source they depend on.
- Make the replace-generation tests derive the second mtime from the first
  candidate instead of a hard-coded timestamp, so they stay valid if the
  default fixture mtime changes.

* Guard file-stat reuse with a self-contained transcript test

Indexing skips reparsing unchanged transcripts for agents marked self-contained. That is only safe if their parsers read nothing but the transcript itself, and nothing enforced it. A parser that started reading a sidecar or dependency file could serve stale index entries.

- Export SELF_CONTAINED_TRANSCRIPT_AGENTS so the test checks the real list
- Add a test that parses each self-contained agent's fixture, both direct and cached, and fails if any file other than the transcript is read
- Register the test in the SQLite runtime-file include list

* Require stored file identity before skipping a transcript rewrite

- Rows with a NULL dev or ino were treated as covered, so a file replaced with the same size and mtime could be skipped and never reindexed.
- Such rows now count as uncovered, so the rewrite runs and records the identity.
- The identity is read through the shared `fileIdentity` helper instead of the raw file fields.

* Add regression test for session rows indexed without file identity

A session row indexed before file identity (dev/ino) was recorded was never reread once the stat began supplying one, so it could keep stale search content. This test covers that case: the first read with identity replaces the row, and a second read with the same identity leaves it alone.
This commit is contained in:
Jinjing
2026-10-14 14:37:57 -07:00
committed by GitHub
parent f8cbfef4fd
commit ebd4f00453
7 changed files with 527 additions and 7 deletions
@@ -1,7 +1,10 @@
// Database opening and lifecycle fixtures exercise Node SQLite behavior.
export const SQLITE_DATABASE_RUNTIME_INCLUDE = [
'src/main/ai-vault-search/session-search-dependent-transcripts.test.ts',
'src/main/ai-vault-search/session-search-file-write.test.ts',
'src/main/ai-vault-search/session-search-live-transcript.test.ts',
'src/main/ai-vault-search/session-search-list-reparse.test.ts',
'src/main/ai-vault-search/session-search-transcript-dependencies.test.ts',
'src/main/ai-vault/session-scanner-devin-contention.test.ts',
'src/main/ai-vault/session-scanner-opencode-native-worker.test.ts',
'src/main/ai-vault/session-scanner-opencode-sqlite-bounds.test.ts',
@@ -0,0 +1,195 @@
import { mkdir, stat, unlink, writeFile } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import { afterEach, beforeEach, expect, it } from 'vitest'
import {
parseAgentSessionFileCached,
resetSessionParseCacheForTests
} from '../ai-vault/session-scanner-parse-cache'
import { resetCodexSessionIndexTitleCacheForTests } from '../ai-vault/session-scanner-codex-title-index'
import type { SessionFileCandidate } from '../ai-vault/session-scanner-types'
import { resetTranscriptConsumersForTests } from '../ai-vault/session-transcript-consumers'
import { registerSessionSearchIndexConsumer } from './session-search-index-consumer'
import {
openSessionSearchIndexFile,
type SessionSearchIndexFile
} from './session-search-index-test-fixture'
import { SessionSearchStore } from './session-search-store'
import {
CODEX_ROLLOUT_FILE,
CODEX_SESSION_ID,
codexRolloutLines,
sessionCandidate
} from './session-search-transcript-fixtures'
let index: SessionSearchIndexFile
let store: SessionSearchStore
let root: string
let errors: unknown[]
const SESSION_ID = '0b5e1c2a-3d4f-4a5b-8c6d-7e8f9a0b1c2d'
beforeEach(async () => {
resetTranscriptConsumersForTests()
resetCodexSessionIndexTitleCacheForTests()
index = await openSessionSearchIndexFile('ss-dependent-transcripts')
root = dirname(index.path)
errors = []
store = new SessionSearchStore(index.path, (error) => errors.push(error))
registerSessionSearchIndexConsumer(store)
})
afterEach(async () => {
resetTranscriptConsumersForTests()
resetSessionParseCacheForTests()
resetCodexSessionIndexTitleCacheForTests()
store.close()
await index.close()
})
async function writeSource(path: string, content: string): Promise<void> {
await mkdir(dirname(path), { recursive: true })
await writeFile(path, content)
}
async function coldRead(candidate: SessionFileCandidate) {
resetSessionParseCacheForTests()
const session = await parseAgentSessionFileCached(candidate, process.platform)
expect(errors).toEqual([])
expect(session).not.toBeNull()
return session
}
function matches(term: string): unknown {
return index.db
.prepare(
`SELECT count(*) AS n FROM messages_fts
JOIN messages m ON m.id = messages_fts.rowid
JOIN sessions s ON s.id = m.session_row_id
WHERE messages_fts MATCH ?`
)
.get(term)
}
it.each(['cline', 'kiro'] as const)(
'removes old searchable messages when the %s sidecar is deleted',
async (agent) => {
const path = join(root, agent, SESSION_ID, `${SESSION_ID}.json`)
const sidecarPath = agent === 'cline' ? path.replace(/\.json$/, '.messages.json') : `${path}l`
await writeSource(path, JSON.stringify({ session_id: SESSION_ID, title: 'Conversation' }))
await writeSource(
sidecarPath,
agent === 'cline'
? JSON.stringify({ messages: [{ role: 'user', content: 'oldsidecarmessage' }] })
: `${JSON.stringify({
kind: 'Prompt',
data: { content: [{ kind: 'text', data: 'oldsidecarmessage' }] }
})}\n`
)
const candidate = await sessionCandidate(agent, path)
const sidecar = await stat(sidecarPath)
candidate.file.sidecar = {
path: sidecarPath,
mtimeMs: sidecar.mtimeMs,
sizeBytes: sidecar.size
}
await coldRead(candidate)
expect(matches('oldsidecarmessage')).toEqual({ n: 1 })
await unlink(sidecarPath)
const after = await sessionCandidate(agent, path)
expect(after.file).toEqual({ ...candidate.file, sidecar: undefined })
after.file.sidecar = 'none'
expect(await coldRead(after)).toMatchObject({ messageCount: 0 })
expect(matches('oldsidecarmessage')).toEqual({ n: 0 })
expect(index.db.prepare('SELECT state FROM files').get()).toEqual({ state: 'current' })
}
)
it.each(['opencode', 'kimi', 'grok'] as const)(
'repairs %s search content after a cold reread of unchanged candidate metadata',
async (agent) => {
const source = await multiFileSource(agent)
await writeSource(source.messagesPath, source.message('olddependentmessage'))
const candidate = await sessionCandidate(agent, source.path)
candidate.file.sidecar = 'none'
await coldRead(candidate)
expect(matches('olddependentmessage')).toEqual({ n: 1 })
await writeSource(source.messagesPath, source.message('newdependentmessage'))
expect((await sessionCandidate(agent, source.path)).file).toEqual({
...candidate.file,
sidecar: undefined
})
const session = await coldRead(candidate)
expect(session?.previewMessages).toEqual(
expect.arrayContaining([expect.objectContaining({ text: 'newdependentmessage' })])
)
expect(matches('newdependentmessage')).toEqual({ n: 1 })
expect(matches('olddependentmessage')).toEqual({ n: 0 })
}
)
async function multiFileSource(agent: 'opencode' | 'kimi' | 'grok') {
if (agent === 'opencode') {
const path = join(root, 'storage', 'session', 'project', 'ses_fixture.json')
await writeSource(path, JSON.stringify({ id: SESSION_ID, title: 'Conversation' }))
return {
path,
messagesPath: join(root, 'storage', 'message', SESSION_ID, 'msg_fixture.json'),
message: (text: string) => JSON.stringify({ role: 'user', content: text })
}
}
if (agent === 'kimi') {
const directory = join(root, 'kimi', 'sessions', 'wd_app_abc', `session_${SESSION_ID}`)
const path = join(directory, 'state.json')
await writeSource(path, JSON.stringify({ title: 'Conversation' }))
return {
path,
messagesPath: join(directory, 'agents', 'main', 'wire.jsonl'),
message: (text: string) =>
`${JSON.stringify({
type: 'context.append_message',
message: { role: 'user', origin: { kind: 'user' }, content: text }
})}\n`
}
}
const path = join(root, 'grok', SESSION_ID, 'summary.json')
await writeSource(
path,
JSON.stringify({ info: { id: SESSION_ID }, generated_title: 'Conversation' })
)
return {
path,
messagesPath: join(dirname(path), 'chat_history.jsonl'),
message: (text: string) =>
`${JSON.stringify({ type: 'user', content: `<user_query>${text}</user_query>` })}\n`
}
}
it('updates the indexed Codex title when only session_index.jsonl changes', async () => {
const home = join(root, 'codex')
const path = join(home, 'sessions', CODEX_ROLLOUT_FILE)
await writeSource(path, `${codexRolloutLines(['pwd'], '/repo/app', 'codexmessage').join('\n')}\n`)
const titleIndex = join(home, 'session_index.jsonl')
await writeSource(
titleIndex,
`${JSON.stringify({ id: CODEX_SESSION_ID, thread_name: 'Original' })}\n`
)
const candidate = await sessionCandidate('codex', path, home)
candidate.file.sidecar = 'none'
await coldRead(candidate)
expect(index.db.prepare('SELECT title FROM sessions').get()).toEqual({ title: 'Original' })
await writeSource(
titleIndex,
`${JSON.stringify({ id: CODEX_SESSION_ID, thread_name: 'Renamed conversation' })}\n`
)
expect((await sessionCandidate('codex', path, home)).file).toEqual({
...candidate.file,
sidecar: undefined
})
expect(await coldRead(candidate)).toMatchObject({ title: 'Renamed conversation' })
expect(index.db.prepare('SELECT title FROM sessions').get()).toEqual({
title: 'Renamed conversation'
})
expect(matches('codexmessage')).toEqual({ n: 1 })
})
@@ -491,8 +491,12 @@ it('fences a first-ever read whose file was removed before it committed', () =>
})
it('replaces the previous generation without ever showing both', async () => {
replayTranscriptRead({ messages: userMessages('firstgeneration', 10) })
replayTranscriptRead({ messages: userMessages('secondgeneration', 10) })
const candidate = syntheticCandidate()
replayTranscriptRead({ candidate, messages: userMessages('firstgeneration', 10) })
replayTranscriptRead({
candidate: syntheticCandidate({ mtimeMs: candidate.file.mtimeMs + 1 }),
messages: userMessages('secondgeneration', 10)
})
expect(matches(index.db, 'messages_fts', 'firstgeneration')).toBe(0)
expect(matches(index.db, 'messages_fts', 'secondgeneration')).toBe(10)
@@ -541,8 +545,12 @@ it('replaces a generation by cutting the old one loose, not by deleting it inlin
})
it('drains what a replace cut loose without being asked', async () => {
replayTranscriptRead({ messages: userMessages('firstgeneration', 200) })
replayTranscriptRead({ messages: userMessages('secondgeneration', 3) })
const candidate = syntheticCandidate()
replayTranscriptRead({ candidate, messages: userMessages('firstgeneration', 200) })
replayTranscriptRead({
candidate: syntheticCandidate({ mtimeMs: candidate.file.mtimeMs + 1 }),
messages: userMessages('secondgeneration', 3)
})
// The store schedules the reclaim the way it schedules retention's. Hiding a
// generation and never reclaiming it would grow the file by every re-read.
@@ -1,3 +1,4 @@
import type { AiVaultAgent } from '../../shared/ai-vault-types'
import {
registerTranscriptConsumer,
type TranscriptConsumer,
@@ -10,6 +11,35 @@ import { fileIdentity } from './session-search-file-cursor'
import type { SessionSearchFileWrite } from './session-search-index-writer'
import type { SessionSearchStore } from './session-search-store'
// File-read assumptions are checked by session-search-transcript-dependencies.test.ts.
export const SELF_CONTAINED_TRANSCRIPT_AGENTS = {
claude: true,
qoder: true,
codebuddy: true,
codex: false, // session_index.jsonl supplies titles.
gemini: true,
copilot: true,
cursor: false, // Sibling chat metadata enriches the transcript.
antigravity: false, // Workspace history enriches the transcript separately.
opencode: false, // SQLite or a separate message directory.
opencode2: false,
zcode: false,
grok: false, // chat_history.jsonl supplies messages.
hermes: true,
rovo: false, // session_context.json supplies messages.
openclaw: true,
pi: true,
omp: true,
'prime-agent': true,
droid: true,
cline: false, // A .messages.json sidecar supplies messages.
devin: false, // sessions.db supplies metadata.
kimi: false, // agents/main/wire.jsonl supplies messages.
muse: true,
jcode: true,
kiro: false // A .jsonl sidecar supplies messages.
} satisfies Record<AiVaultAgent, boolean>
/**
* The search index as a consumer of the transcript reader.
*
@@ -29,6 +59,14 @@ export class SessionSearchIndexConsumer implements TranscriptConsumer {
beginRead(start: TranscriptReadStart): TranscriptReadConsumer | null {
const { candidate } = start
if (
start.mode === 'replace' &&
SELF_CONTAINED_TRANSCRIPT_AGENTS[candidate.agent] &&
(candidate.file.sidecar === undefined || candidate.file.sidecar === 'none') &&
this.store.coversTranscript(candidate)
) {
return null
}
if (start.mode === 'append') {
const cursor = this.store.indexedFile(candidate.file.path, fileIdentity(candidate.file))
if (!cursor || cursor.byteOffset !== start.previousByteOffset) {
@@ -0,0 +1,123 @@
import { afterEach, beforeEach, expect, it } from 'vitest'
import { resetTranscriptConsumersForTests } from '../ai-vault/session-transcript-consumers'
import { registerSessionSearchIndexConsumer } from './session-search-index-consumer'
import {
openSessionSearchIndexFile,
replayTranscriptRead,
syntheticCandidate,
userMessages,
type SessionSearchIndexFile
} from './session-search-index-test-fixture'
import { SessionSearchStore } from './session-search-store'
let index: SessionSearchIndexFile
let store: SessionSearchStore
const messages = userMessages('Searchable conversation', 2)
beforeEach(async () => {
index = await openSessionSearchIndexFile('ss-list-reparse')
store = new SessionSearchStore(index.path)
registerSessionSearchIndexConsumer(store)
})
afterEach(async () => {
resetTranscriptConsumersForTests()
store.close()
await index.close()
})
function sessionRow(): unknown {
return index.db.prepare('SELECT id FROM sessions').get()
}
it('keeps current search rows when a cold history list rereads the same transcript', () => {
replayTranscriptRead({ messages })
const before = sessionRow()
replayTranscriptRead({ messages })
expect(sessionRow()).toEqual(before)
expect(index.db.prepare('SELECT count(*) AS n FROM messages').get()).toEqual({ n: 2 })
})
it('still replaces a changed transcript', () => {
const candidate = syntheticCandidate()
replayTranscriptRead({ candidate, messages })
const before = sessionRow()
replayTranscriptRead({
candidate: syntheticCandidate({ mtimeMs: candidate.file.mtimeMs + 1 }),
messages
})
expect(sessionRow()).not.toEqual(before)
})
it('still replaces a transcript whose size changed without a timestamp change', () => {
replayTranscriptRead({ messages })
const before = sessionRow()
replayTranscriptRead({ candidate: syntheticCandidate({ sizeBytes: 5000 }), messages })
expect(sessionRow()).not.toEqual(before)
})
it('refreshes metadata supplied by a sidecar despite an unchanged transcript', () => {
replayTranscriptRead({ messages })
const before = sessionRow()
replayTranscriptRead({
candidate: syntheticCandidate({
sidecar: { path: 'metadata.json', mtimeMs: 1, sizeBytes: 10 }
}),
messages
})
expect(sessionRow()).not.toEqual(before)
})
it('rereads when the transcript size is unknown', () => {
replayTranscriptRead({ messages })
const before = sessionRow()
replayTranscriptRead({ candidate: syntheticCandidate({ sizeBytes: undefined }), messages })
expect(sessionRow()).not.toEqual(before)
})
it('still replaces a different file with identical size and timestamp', () => {
replayTranscriptRead({ candidate: syntheticCandidate({ dev: 1, ino: 10 }), messages })
const before = sessionRow()
replayTranscriptRead({ candidate: syntheticCandidate({ dev: 1, ino: 11 }), messages })
expect(sessionRow()).not.toEqual(before)
})
it('rereads a row indexed without a file identity once the stat supplies one', () => {
replayTranscriptRead({ messages })
const before = sessionRow()
const identified = syntheticCandidate({ dev: 1, ino: 10 })
replayTranscriptRead({ candidate: identified, messages })
expect(sessionRow()).not.toEqual(before)
const repaired = sessionRow()
replayTranscriptRead({ candidate: identified, messages })
expect(sessionRow()).toEqual(repaired)
})
it.each(['due', 'failed'] as const)('repairs an unchanged transcript marked %s', (state) => {
replayTranscriptRead({ messages })
const before = sessionRow()
store.setFileState(syntheticCandidate().file.path, state)
replayTranscriptRead({ messages })
expect(sessionRow()).not.toEqual(before)
expect(index.db.prepare('SELECT state FROM files').get()).toEqual({ state: 'current' })
})
it('repairs a partial index even when the stat matches', () => {
replayTranscriptRead({ messages })
const before = sessionRow()
index.db.exec('UPDATE files SET byte_offset = -1')
replayTranscriptRead({ messages })
expect(sessionRow()).not.toEqual(before)
})
it('still indexes new appended messages', () => {
replayTranscriptRead({ messages })
replayTranscriptRead({
candidate: syntheticCandidate({ sizeBytes: 5000 }),
mode: 'append',
previousByteOffset: 4096,
messages,
outcome: { byteOffset: 5000 }
})
expect(index.db.prepare('SELECT count(*) AS n FROM messages').get()).toEqual({ n: 4 })
})
@@ -2,9 +2,10 @@ import type SyncDatabase from '../sqlite/sync-database'
import { asRecord } from '../ai-vault/session-scanner-record-value'
import type { SessionFileCandidate } from '../ai-vault/session-scanner-types'
import type { TranscriptSessionIdentity } from '../ai-vault/session-transcript-consumers'
import type {
SessionSearchFileIdentity,
SessionSearchIndexedFile
import {
fileIdentity,
type SessionSearchFileIdentity,
type SessionSearchIndexedFile
} from './session-search-file-cursor'
import {
SESSION_SEARCH_COMMIT_CHARS,
@@ -171,6 +172,37 @@ export class SessionSearchStore {
}
}
/** A cold list read must not rewrite a complete, unchanged search entry. */
coversTranscript(candidate: SessionFileCandidate): boolean {
const file = candidate.file
if (this.closed || typeof file.sizeBytes !== 'number') {
return false
}
const identity = fileIdentity(file)
try {
return (
this.db
.prepare(
// A row without a stored identity is uncovered so the rewrite records one.
`SELECT 1 FROM files WHERE path = ? AND state = 'current'
AND mtime_ms = ? AND size_bytes = ? AND byte_offset = size_bytes
AND (? IS NULL OR (CAST(dev AS REAL) = ? AND CAST(ino AS REAL) = ?))`
)
.get(
file.path,
file.mtimeMs,
file.sizeBytes,
identity?.dev ?? null,
identity?.dev ?? null,
identity?.ino ?? null
) !== undefined
)
} catch (error) {
this.onError(error)
return false
}
}
/** Null when this read cannot extend the index, or when the store refuses writes. */
beginWrite(
candidate: SessionFileCandidate,
@@ -0,0 +1,121 @@
import * as fs from 'node:fs'
import * as fsPromises from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterAll, afterEach, beforeAll, expect, it, vi } from 'vitest'
import { AI_VAULT_AGENTS } from '../../shared/ai-vault-types'
import * as transcriptFs from '../native-chat/wsl-transcript-fs-access'
import { parseAgentSessionFile } from '../ai-vault/session-scanner-agent-parser'
import { AI_VAULT_AGENT_SOURCES } from '../ai-vault/session-scanner-agent-sources'
import { discoverFiles } from '../ai-vault/session-scanner-discovery'
import {
writeEveryAgentVault,
type EveryAgentVault
} from '../ai-vault/session-scanner-every-agent-fixture'
import { geminiJsonlFixture } from '../ai-vault/session-scanner-incremental-fixtures'
import {
parseAgentSessionFileCached,
resetSessionParseCacheForTests
} from '../ai-vault/session-scanner-parse-cache'
import {
registerTranscriptConsumer,
resetTranscriptConsumersForTests,
type TranscriptMessage
} from '../ai-vault/session-transcript-consumers'
import { SELF_CONTAINED_TRANSCRIPT_AGENTS } from './session-search-index-consumer'
vi.mock('node:fs', { spy: true })
vi.mock('node:fs/promises', { spy: true })
vi.mock('../native-chat/wsl-transcript-fs-access', { spy: true })
let root: string
let vault: EveryAgentVault
beforeAll(async () => {
root = await fsPromises.mkdtemp(join(tmpdir(), 'ss-transcript-dependencies-'))
vault = await writeEveryAgentVault(root)
const gemini = geminiJsonlFixture()
await fsPromises.writeFile(
join(vault.roots.geminiSessionsDir, gemini.fileName),
`${gemini.seedLines.join('\n')}\n`
)
})
afterEach(() => {
vi.clearAllMocks()
resetTranscriptConsumersForTests()
resetSessionParseCacheForTests()
})
afterAll(async () => {
await fsPromises.rm(root, { recursive: true, force: true })
})
function contentReadPaths(): unknown[] {
return [
...vi.mocked(fsPromises.readFile).mock.calls.map(([path]) => path),
...vi.mocked(fsPromises.open).mock.calls.map(([path]) => path),
...vi.mocked(fs.readFile).mock.calls.map(([path]) => path),
...vi.mocked(fs.readFileSync).mock.calls.map(([path]) => path),
...vi.mocked(fs.open).mock.calls.map(([path]) => path),
...vi.mocked(fs.openSync).mock.calls.map(([path]) => path),
...vi.mocked(fs.createReadStream).mock.calls.map(([path]) => path),
...vi.mocked(transcriptFs.wslGatedReadFile).mock.calls.map(([path]) => path),
...vi.mocked(transcriptFs.openTranscriptFile).mock.calls.map(([path]) => path),
...vi.mocked(transcriptFs.openTranscriptReadStream).mock.calls.map(([path]) => path),
...vi.mocked(transcriptFs.readTranscriptSlice).mock.calls.map(([path]) => path)
]
}
it.each(AI_VAULT_AGENTS.filter((agent) => SELF_CONTAINED_TRANSCRIPT_AGENTS[agent]))(
'%s reads indexed content only from its candidate transcript',
async (agent) => {
const source = AI_VAULT_AGENT_SOURCES[agent]
if (!source) {
throw new Error(`${agent} needs a real transcript fixture before enabling file-stat reuse`)
}
expect(source.contentDependencyPath).toBeUndefined()
const discoveries = await Promise.all(
source.rootDirs(vault.roots, []).map((rootDir) =>
discoverFiles({
...source,
rootDir,
agent,
limit: 10,
issues: [],
extensions: [...source.extensions]
})
)
)
const files = discoveries.flatMap((discovery) => discovery.files)
expect(files.length).toBeGreaterThan(0)
for (const file of files) {
const candidate = { agent, file, codexHome: null }
for (const read of ['direct', 'cached'] as const) {
const messages: TranscriptMessage[] = []
const sink = { active: true, push: (message: TranscriptMessage) => messages.push(message) }
resetTranscriptConsumersForTests()
resetSessionParseCacheForTests()
registerTranscriptConsumer({
beginRead: () => ({ message: sink.push, finish: () => undefined })
})
vi.clearAllMocks()
const session = await (read === 'direct'
? parseAgentSessionFile(candidate, process.platform, sink)
: parseAgentSessionFileCached(candidate, process.platform))
expect(session).not.toBeNull()
expect(messages.length).toBeGreaterThan(0)
const paths = contentReadPaths()
expect(paths).toContain(file.path)
// Include failed optional reads: an absent sidecar can appear on a later scan.
expect(
paths.filter((path) => path !== file.path),
`${read}: ${file.path}`
).toEqual([])
}
}
}
)