From a4627a1a5a9e9247ba02430669c23d6b6d1c7cf2 Mon Sep 17 00:00:00 2001 From: Aditya Vikram Singh <247195684+avs-io@users.noreply.github.com> Date: Wed, 29 Jul 2026 22:43:01 +0530 Subject: [PATCH] fix(providers): scan bounded leading lines for Pi/OMP session discovery The shared Pi/OMP discovery gate only checked the first physical line of a transcript for a `type: "session"` record. Oh My Pi writes a fixed-width `type: "title"` metadata line before that header (upstream can1357/oh-my-pi@0ce330a, 2026-06-27), so valid OMP transcripts were rejected and omitted from `codeburn sessions --provider omp`. readFirstEntry now scans up to MAX_HEADER_LINES_SCANNED (20) leading lines via the existing streaming readSessionLines helper, skipping blank lines and malformed JSON, until it finds a session record. This keeps discovery bounded for message-only files (and pathological blank/junk-line runs) instead of reading the whole file, and Pi and OMP continue to share the exact same discovery path. Fixes #845 --- src/providers/pi.ts | 31 ++++++--- tests/providers/pi.test.ts | 127 ++++++++++++++++++++++++++++++++++++- 2 files changed, 148 insertions(+), 10 deletions(-) diff --git a/src/providers/pi.ts b/src/providers/pi.ts index 85d5f25c..84b38f20 100644 --- a/src/providers/pi.ts +++ b/src/providers/pi.ts @@ -2,7 +2,7 @@ import { readdir, stat } from 'fs/promises' import { basename, join } from 'path' import { homedir } from 'os' -import { readSessionFile } from '../fs-utils.js' +import { readSessionFile, readSessionLines } from '../fs-utils.js' import { calculateCost } from '../models.js' import { extractBashCommands } from '../bash-utils.js' import { normalizeContentBlocks } from '../content-utils.js' @@ -90,16 +90,29 @@ function getOmpSessionsDir(override?: string): string { return override ?? join(homedir(), '.omp', 'agent', 'sessions') } +// OMP can write a fixed-width title metadata line (`type: "title"`) before the +// `type: "session"` header (issue #845), and either provider may pad the +// header with blank lines. Scan a bounded number of leading lines rather than +// just the first one, and rather than the whole file, so discovery stays cheap +// even when a file turns out to have no session record at all (a message-only +// file, or a pathological run of blank/junk lines). +const MAX_HEADER_LINES_SCANNED = 20 + async function readFirstEntry(filePath: string): Promise { - const content = await readSessionFile(filePath) - if (content === null) return null - const line = content.split('\n')[0] - if (!line?.trim()) return null - try { - return JSON.parse(line) as PiEntry - } catch { - return null + let linesScanned = 0 + for await (const line of readSessionLines(filePath)) { + if (linesScanned >= MAX_HEADER_LINES_SCANNED) break + linesScanned++ + const trimmed = line.trim() + if (!trimmed) continue + try { + const entry = JSON.parse(trimmed) as PiEntry + if (entry.type === 'session') return entry + } catch { + continue + } } + return null } async function discoverSessionsInDir(sessionsDir: string, providerName: string): Promise { diff --git a/tests/providers/pi.test.ts b/tests/providers/pi.test.ts index 398d5098..1c5f2302 100644 --- a/tests/providers/pi.test.ts +++ b/tests/providers/pi.test.ts @@ -3,7 +3,7 @@ import { mkdtemp, mkdir, writeFile, rm } from 'fs/promises' import { join } from 'path' import { tmpdir } from 'os' -import { createPiProvider } from '../../src/providers/pi.js' +import { createPiProvider, createOmpProvider } from '../../src/providers/pi.js' import type { ParsedProviderCall } from '../../src/providers/types.js' import { classifyTurn } from '../../src/classifier.js' import type { ParsedApiCall, ParsedTurn } from '../../src/types.js' @@ -59,6 +59,18 @@ function sessionMeta(opts: { id?: string; cwd?: string } = {}) { }) } +// Oh My Pi (issue #845): a fixed-width title metadata line written before the +// `type: "session"` header, per upstream can1357/oh-my-pi@0ce330a. +function titleSlot(opts: { title?: string; pad?: string } = {}) { + return JSON.stringify({ + type: 'title', + v: 1, + title: opts.title ?? 'My Session', + updatedAt: '2026-04-14T10:00:00.000Z', + pad: opts.pad ?? '', + }) +} + function userMessage(text: string, timestamp?: string) { return JSON.stringify({ type: 'message', @@ -185,6 +197,119 @@ describe('pi provider - session discovery', () => { const sessions = await provider.discoverSessions() expect(sessions).toEqual([]) }) + + // Issue #845: OMP writes a `type: "title"` metadata line before the + // `type: "session"` header. Discovery must scan past it instead of only + // checking the first physical line. + it('discovers an OMP transcript with a title slot before the session record', async () => { + const projectDir = join(tmpDir, '--Users-test-myproject--') + await writeSession(projectDir, 'omp-session.jsonl', [ + titleSlot({ title: 'Fix the bug' }), + sessionMeta({ cwd: '/Users/test/myproject' }), + assistantMessage({}), + ]) + + const provider = createOmpProvider(tmpDir) + const sessions = await provider.discoverSessions() + + expect(sessions).toHaveLength(1) + expect(sessions[0]!.provider).toBe('omp') + expect(sessions[0]!.project).toBe('myproject') + }) + + it('Pi and OMP discover an identical title-slot transcript the same way', async () => { + const projectDir = join(tmpDir, '--Users-test-myproject--') + const lines = [titleSlot(), sessionMeta({ cwd: '/Users/test/myproject' }), assistantMessage({})] + await writeSession(projectDir, 'shared.jsonl', lines) + + const piSessions = await createPiProvider(tmpDir).discoverSessions() + const ompSessions = await createOmpProvider(tmpDir).discoverSessions() + + expect(piSessions).toHaveLength(1) + expect(ompSessions).toHaveLength(1) + expect(piSessions[0]!.project).toBe(ompSessions[0]!.project) + }) + + it('tolerates blank lines before the session record', async () => { + const projectDir = join(tmpDir, '--Users-test-myproject--') + await writeSession(projectDir, 'blank-padded.jsonl', [ + titleSlot(), + '', + '', + sessionMeta({ cwd: '/Users/test/myproject' }), + assistantMessage({}), + ]) + + const provider = createOmpProvider(tmpDir) + const sessions = await provider.discoverSessions() + expect(sessions).toHaveLength(1) + }) + + it('skips a malformed leading JSON line without throwing', async () => { + const projectDir = join(tmpDir, '--Users-test-myproject--') + await writeSession(projectDir, 'malformed-head.jsonl', [ + '{not valid json', + sessionMeta({ cwd: '/Users/test/myproject' }), + assistantMessage({}), + ]) + + const provider = createOmpProvider(tmpDir) + await expect(provider.discoverSessions()).resolves.toHaveLength(1) + }) + + it('excludes a message-only file with no session record', async () => { + const projectDir = join(tmpDir, '--Users-test-myproject--') + await writeSession(projectDir, 'messages-only.jsonl', [ + titleSlot(), + userMessage('hello'), + assistantMessage({}), + ]) + + const provider = createOmpProvider(tmpDir) + const sessions = await provider.discoverSessions() + expect(sessions).toEqual([]) + }) + + it('does not discover a session record beyond the bounded leading-line scan', async () => { + const projectDir = join(tmpDir, '--Users-test-myproject--') + // 25 non-session leading lines exceeds MAX_HEADER_LINES_SCANNED (20), so + // the session record on line 26 must NOT be found. This proves the scan + // is bounded rather than silently degrading into a full-file read. + const junkLines = Array.from({ length: 25 }, (_, i) => JSON.stringify({ type: 'message', id: `junk-${i}` })) + await writeSession(projectDir, 'too-deep.jsonl', [ + ...junkLines, + sessionMeta({ cwd: '/Users/test/myproject' }), + assistantMessage({}), + ]) + + const provider = createOmpProvider(tmpDir) + const sessions = await provider.discoverSessions() + expect(sessions).toEqual([]) + }) + + it('does not read an unreasonable amount of a large message-only file', async () => { + const projectDir = join(tmpDir, '--Users-test-myproject--') + // ~20MB of message lines with no session record anywhere. A bounded + // scan rejects this after its leading-line cap, not after reading the + // whole file; this keeps discovery cheap for provider directories full + // of large, irrelevant transcripts. + const bigPad = 'x'.repeat(50_000) + const junkLines = Array.from({ length: 400 }, (_, i) => + JSON.stringify({ type: 'message', id: `junk-${i}`, pad: bigPad })) + await writeSession(projectDir, 'huge.jsonl', junkLines) + + const provider = createOmpProvider(tmpDir) + const start = performance.now() + const sessions = await provider.discoverSessions() + const elapsedMs = performance.now() - start + + expect(sessions).toEqual([]) + // Generous ceiling: a genuinely bounded scan (20 lines) finishes in low + // single-digit milliseconds; reading the full ~20MB file would still be + // fast on local disk, so this mainly guards against a regression to an + // unbounded per-line-parse scan across the entire file. + expect(elapsedMs).toBeLessThan(500) + }) }) describe('pi provider - JSONL parsing', () => {