diff --git a/config/scripts/vitest-sqlite-database-runtime-files.mjs b/config/scripts/vitest-sqlite-database-runtime-files.mjs index 43701691d05..fcb249648e2 100644 --- a/config/scripts/vitest-sqlite-database-runtime-files.mjs +++ b/config/scripts/vitest-sqlite-database-runtime-files.mjs @@ -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', diff --git a/src/main/ai-vault-search/session-search-dependent-transcripts.test.ts b/src/main/ai-vault-search/session-search-dependent-transcripts.test.ts new file mode 100644 index 00000000000..daf8b38877a --- /dev/null +++ b/src/main/ai-vault-search/session-search-dependent-transcripts.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 { + 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: `${text}` })}\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 }) +}) diff --git a/src/main/ai-vault-search/session-search-file-write.test.ts b/src/main/ai-vault-search/session-search-file-write.test.ts index 2c0be88b026..56351c0f538 100644 --- a/src/main/ai-vault-search/session-search-file-write.test.ts +++ b/src/main/ai-vault-search/session-search-file-write.test.ts @@ -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. diff --git a/src/main/ai-vault-search/session-search-index-consumer.ts b/src/main/ai-vault-search/session-search-index-consumer.ts index c4ff7877453..02e39f93238 100644 --- a/src/main/ai-vault-search/session-search-index-consumer.ts +++ b/src/main/ai-vault-search/session-search-index-consumer.ts @@ -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 + /** * 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) { diff --git a/src/main/ai-vault-search/session-search-list-reparse.test.ts b/src/main/ai-vault-search/session-search-list-reparse.test.ts new file mode 100644 index 00000000000..ade3b9a6209 --- /dev/null +++ b/src/main/ai-vault-search/session-search-list-reparse.test.ts @@ -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 }) +}) diff --git a/src/main/ai-vault-search/session-search-store.ts b/src/main/ai-vault-search/session-search-store.ts index 73da2c2db6f..93cfed0ddff 100644 --- a/src/main/ai-vault-search/session-search-store.ts +++ b/src/main/ai-vault-search/session-search-store.ts @@ -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, diff --git a/src/main/ai-vault-search/session-search-transcript-dependencies.test.ts b/src/main/ai-vault-search/session-search-transcript-dependencies.test.ts new file mode 100644 index 00000000000..5550d4fa670 --- /dev/null +++ b/src/main/ai-vault-search/session-search-transcript-dependencies.test.ts @@ -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([]) + } + } + } +)