diff --git a/CHANGELOG.md b/CHANGELOG.md index 98c38076..7104bfe3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Fixed - Claude Desktop and Cowork sessions are discovered for Windows Microsoft Store (MSIX) installs. (#611) +- Sessions that were silently invisible now appear: Pi and Oh My Pi transcripts with an OMP title slot, Cline sessions under Code - Insiders or VSCodium roots, and OpenCode/kilo-code usage that silently read as zero now reports. (#845) ## 0.9.19 - 2026-07-20 diff --git a/packages/cli/src/daily-cache.ts b/packages/cli/src/daily-cache.ts index c5439c34..67e7c940 100644 --- a/packages/cli/src/daily-cache.ts +++ b/packages/cli/src/daily-cache.ts @@ -5,7 +5,28 @@ import { homedir } from 'os' import { join } from 'path' import type { DateRange, ProjectSummary } from './types.js' -// Bumped to 15: per-project daily rollups. Days and provider slices now carry +// Bumped to 17: pi/omp, cline and opencode/kilo-code session discovery +// was restored (#845, this PR). v15/v16 rollups missed OMP sessions +// written after a `type: "title"` slot line, Cline sessions under the +// Insiders / VSCodium / home-data roots, and opencode/kilo-code +// interrupted or user-only sessions whose session-level fallback queried +// a `model_id` column neither schema has. Those files were skipped before +// they were ever parsed, so nothing downstream can notice on its own: the +// daily cache serves every day before today, retention is ten years, and +// an upgrading user with a warm complete cache would keep the pre-fix +// zeroes forever while today's numbers silently disagreed with them. +// Raising MIN_SUPPORTED_VERSION forces the one-time re-derivation. +// +// v16 is skipped on purpose: main already spent it on the codex +// structural-discovery fix (eece4cf). The cache filename and the accepted +// version window ([MIN_SUPPORTED_VERSION, DAILY_CACHE_VERSION]) are +// shared across trees, so claiming 16 here would load a main-built v16 +// cache — which carries only the codex fix, none of this PR's — as +// current and complete, and the invalidation would never fire. 17 is the +// first version that contains both the codex fix and this PR's discovery +// fixes; the next bump must check what main has already claimed. +// +// v15: per-project daily rollups. Days and provider slices now carry // a `projects` breakdown (cost/calls/savings/sessions per project) so project // history outlives the session files, like models and categories already do. // This bump is the first to ride the v14 carry-forward: the old cache is @@ -57,8 +78,8 @@ import type { DateRange, ProjectSummary } from './types.js' // that older binaries skipped. v8 added local-model savings to the daily // rollup; the `savingsConfigHash` field is invalidated separately when the // user changes their `localModelSavings` mapping. -export const DAILY_CACHE_VERSION = 15 -const MIN_SUPPORTED_VERSION = 15 +export const DAILY_CACHE_VERSION = 17 +const MIN_SUPPORTED_VERSION = 17 // Version-suffixed so different binaries each own a distinct file and never // clobber an incompatible schema. Bumping the version mints a fresh filename; // adoptOlderDailyCaches then unions days out of every previous file (including diff --git a/packages/cli/src/providers/cline.ts b/packages/cli/src/providers/cline.ts index 797f496f..1eb238b3 100644 --- a/packages/cli/src/providers/cline.ts +++ b/packages/cli/src/providers/cline.ts @@ -6,7 +6,7 @@ import { decodeVscodeCline } from '@codeburn/core/providers/vscode-cline' import type { VscodeClineDecodedCall } from '@codeburn/core/providers/vscode-cline' import { createBridgedProvider } from './bridge.js' -import { discoverClineTasks, getVSCodeGlobalStoragePath, readClineRecords, toClineProviderCall } from './vscode-cline-parser.js' +import { discoverClineTasks, getVSCodeGlobalStoragePaths, readClineRecords, toClineProviderCall } from './vscode-cline-parser.js' import type { Provider, SessionSource } from './types.js' const EXTENSION_ID = 'saoudrizwan.claude-dev' @@ -17,7 +17,7 @@ export function getClineDataPath(): string { function normalizeOverrideDirs(overrideDirs?: string | string[]): string[] | undefined { if (overrideDirs === undefined) return undefined - // Cline has two default roots, so tests and future callers can override one or both. + // Cline has several default roots, so tests and future callers can override one or all. return Array.isArray(overrideDirs) ? overrideDirs : [overrideDirs] } @@ -56,16 +56,15 @@ export function createClineProvider(overrideDirs?: string | string[]): Provider }, async discoverSessions(): Promise { + // Cline may be installed in any VS Code variant (stable, Insiders, + // VSCodium), so every globalStorage root is scanned - same as the Roo Code + // and KiloCode siblings - plus Cline's own home-data root. const baseDirs = configuredDirs ?? [ - getVSCodeGlobalStoragePath(EXTENSION_ID), + ...getVSCodeGlobalStoragePaths(EXTENSION_ID), getClineDataPath(), ] - const sources = await Promise.all( - baseDirs.map(dir => discoverClineTasks(EXTENSION_ID, 'cline', 'Cline', dir)), - ) - - return dedupeTaskSources(sources.flat()) + return dedupeTaskSources(await discoverClineTasks(EXTENSION_ID, 'cline', 'Cline', baseDirs)) }, readRecords: readClineRecords, diff --git a/packages/cli/src/providers/pi.ts b/packages/cli/src/providers/pi.ts index 158253c0..dd24635c 100644 --- a/packages/cli/src/providers/pi.ts +++ b/packages/cli/src/providers/pi.ts @@ -4,7 +4,7 @@ import { homedir } from 'os' import { decodePi } from '@codeburn/core/providers/pi' import type { PiDecodedCall } from '@codeburn/core/providers/pi' -import { readSessionFile } from '../fs-utils.js' +import { readSessionFile, readSessionLines } from '../fs-utils.js' import { extractBashCommands } from '../bash-utils.js' import { createBridgedProvider } from './bridge.js' import type { Provider, SessionSource, ParsedProviderCall } from './types.js' @@ -13,7 +13,10 @@ type PiEntry = { type: string id?: string timestamp?: string - cwd?: string + /// JSON can carry anything here; the reader validates before use (a real + /// transcript always writes a string, but a malformed record must not + /// crash discovery with a `basename` type error). + cwd?: unknown message?: unknown } @@ -25,16 +28,31 @@ function getOmpSessionsDir(override?: string): string { return override ?? join(homedir(), '.omp', 'agent', 'sessions') } -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 +// 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, so discovery stops after twenty parse attempts and never +// walks a message-only or pathological transcript line by line to EOF. The +// bound caps how many LINES are inspected, not how many bytes: readSessionLines +// buffers one physical line whole, so a single oversized line is bounded only +// by the streaming reader's cap, not by this constant. +const MAX_HEADER_LINES_SCANNED = 20 + +async function readSessionEntry(filePath: string): Promise { + 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 { @@ -65,10 +83,13 @@ async function discoverSessionsInDir(sessionsDir: string, providerName: string): const fileStat = await stat(filePath).catch(() => null) if (!fileStat?.isFile()) continue - const first = await readFirstEntry(filePath) - if (!first || first.type !== 'session') continue + const entry = await readSessionEntry(filePath) + if (!entry) continue - const cwd = first.cwd ?? dirName + // A malformed record can carry a non-string cwd (e.g. a number); fall + // back to the project directory the way a missing cwd does, rather than + // crashing basename with a type error. + const cwd = typeof entry.cwd === 'string' && entry.cwd.length > 0 ? entry.cwd : dirName sources.push({ path: filePath, project: basename(cwd), provider: providerName }) } } diff --git a/packages/cli/src/providers/sqlite-session-parser.ts b/packages/cli/src/providers/sqlite-session-parser.ts index 18b7c943..9f4a0237 100644 --- a/packages/cli/src/providers/sqlite-session-parser.ts +++ b/packages/cli/src/providers/sqlite-session-parser.ts @@ -33,7 +33,21 @@ type SessionTokenRow = { tokens_reasoning?: number tokens_cache_read?: number tokens_cache_write?: number - model_id?: string + model?: Uint8Array | string +} + +function parseSessionModel(value: Uint8Array | string | undefined): string | undefined { + try { + const parsed: unknown = JSON.parse(blobToText(value)) + if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return undefined + + const model = parsed as Record + const id = typeof model['id'] === 'string' ? model['id'].trim() : '' + const providerID = typeof model['providerID'] === 'string' ? model['providerID'].trim() : '' + return id && providerID ? `${providerID}/${id}` : undefined + } catch { + return undefined + } } export function tryQuerySessionTokens(db: SqliteDatabase, sessionId: string): { @@ -42,7 +56,9 @@ export function tryQuerySessionTokens(db: SqliteDatabase, sessionId: string): { } | null { try { const rows = db.query( - `SELECT cost, tokens_input, tokens_output, tokens_reasoning, tokens_cache_read, tokens_cache_write, model_id FROM session WHERE id = ?`, + `SELECT cost, tokens_input, tokens_output, tokens_reasoning, tokens_cache_read, tokens_cache_write, + CAST(model AS BLOB) AS model + FROM session WHERE id = ?`, [sessionId], ) if (rows.length === 0) return null @@ -54,7 +70,7 @@ export function tryQuerySessionTokens(db: SqliteDatabase, sessionId: string): { reasoning: r.tokens_reasoning ?? 0, cacheRead: r.tokens_cache_read ?? 0, cacheWrite: r.tokens_cache_write ?? 0, - model: r.model_id ?? undefined, + model: parseSessionModel(r.model), } } catch { return null diff --git a/packages/cli/src/providers/vscode-cline-parser.ts b/packages/cli/src/providers/vscode-cline-parser.ts index 7fca033b..e1c81aa6 100644 --- a/packages/cli/src/providers/vscode-cline-parser.ts +++ b/packages/cli/src/providers/vscode-cline-parser.ts @@ -33,10 +33,6 @@ export function getVSCodeGlobalStoragePaths(extensionId: string, homeDir = homed ] } -export function getVSCodeGlobalStoragePath(extensionId: string): string { - return getVSCodeGlobalStoragePaths(extensionId)[0]! -} - export async function discoverClineTasks(extensionId: string, providerName: string, displayName: string, overrideDir?: string | string[]): Promise { const baseDirs = overrideDir ? (Array.isArray(overrideDir) ? overrideDir : [overrideDir]) diff --git a/packages/cli/src/session-cache.ts b/packages/cli/src/session-cache.ts index 461dd461..5daff31c 100644 --- a/packages/cli/src/session-cache.ts +++ b/packages/cli/src/session-cache.ts @@ -222,9 +222,10 @@ export const PROVIDER_PARSE_VERSIONS: Record = { 'lingtai-tui': 'token-ledger-registry-activity-v3', 'ibm-bob': 'worktree-project-grouping-v1', kiro: 'ide-parsing-v1-est-cost', + opencode: 'session-model-v1', quickdesk: 'emf-sqlite-v2-est-cost', kimicode: 'wire-usage-v1-est-cost', - 'kilo-code': 'worktree-project-grouping-v1', + 'kilo-code': 'worktree-project-grouping-v1-session-model-v1', 'roo-code': 'worktree-project-grouping-v1', warp: 'worktree-project-grouping-v1-est-cost', antigravity: 'worktree-project-grouping-v5', diff --git a/packages/cli/tests/daily-cache-carry-forward.test.ts b/packages/cli/tests/daily-cache-carry-forward.test.ts index 62197fae..0f1ba2ec 100644 --- a/packages/cli/tests/daily-cache-carry-forward.test.ts +++ b/packages/cli/tests/daily-cache-carry-forward.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { mkdir, readFile, rename, rm, writeFile } from 'fs/promises' import { existsSync } from 'fs' import { tmpdir } from 'os' @@ -304,6 +304,45 @@ describe('never-lose invariant: invalidations with vanished sources', () => { expect(out.days[0]).toMatchObject({ date: d.date, cost: d.cost, calls: d.calls, carried: true }) }) + it('a version bump forces a re-derive that recovers usage the old cache never had', async () => { + // The pre-fix binary shipped daily-cache v16 (main's codex discovery + // fix spent 16; this PR's fixes land at 17). THIS LITERAL IS THE POINT: + // it must stay pinned to the version the pre-fix binary wrote, so a warm + // cache from that binary sits at daily-cache.v16.json, complete: true, + // with no opencode usage (the session-level fallback and pi/omp discovery + // fixes landed after). Only the MIN_SUPPORTED_VERSION bump decides + // whether that file loads as the trusted CURRENT cache — freezing the + // pre-fix zeroes forever — or as an old-version file that forces the + // one-time re-derive. When the next bump lands, move this literal to the + // version the current binary shipped. + const PRE_FIX_CACHE_VERSION = 16 + const preFixCache: DailyCache = { + version: PRE_FIX_CACHE_VERSION, + savingsConfigHash: 'cfg-A', + tzKey: currentTzKey(), + lastComputedDate: daysAgoStr(1), + days: [seededDay()], + complete: true, + } + await writeFile(join(TMP_CACHE_ROOT, `daily-cache.v${PRE_FIX_CACHE_VERSION}.json`), JSON.stringify(preFixCache), 'utf-8') + + const aggregate = vi.fn(() => [day(daysAgoStr(30), { opencode: slice(12.5, 7) })]) + const out = await ensureCacheHydrated(noSessions, aggregate, 'cfg-A') + + // The bump forced a full re-derivation: the fresh parse was consulted. + expect(aggregate).toHaveBeenCalled() + expect(out.version).toBe(DAILY_CACHE_VERSION) + expect(out.complete).toBe(true) + // The recovered usage is picked up by the re-derive… + expect(out.days[0]!.providers['opencode']!.cost).toBe(12.5) + expect(out.days[0]!.providers['opencode']!.calls).toBe(7) + // …and providers the parse could not re-derive keep their old + // accounting, carried forward (never-lose invariant intact). + expect(out.days[0]!.providers['claude']!.cost).toBe(230.06) + expect(out.days[0]!.providers['codex']!.cost).toBe(79.29) + expect(out.days[0]!.carried).toBe(true) + }) + it('a same-version file found under an old name is trusted as-is (no spurious rebuild)', async () => { const d = await seed() await rename(dailyCachePath(), join(TMP_CACHE_ROOT, 'daily-cache.json')) diff --git a/packages/cli/tests/providers/cline.test.ts b/packages/cli/tests/providers/cline.test.ts index d5c7c0cf..bfd0f0b3 100644 --- a/packages/cli/tests/providers/cline.test.ts +++ b/packages/cli/tests/providers/cline.test.ts @@ -3,10 +3,13 @@ import { mkdtemp, mkdir, writeFile, rm, utimes } from 'fs/promises' import { join } from 'path' import { tmpdir } from 'os' -import { cline, createClineProvider } from '../../src/providers/cline.js' +import { cline, createClineProvider, getClineDataPath } from '../../src/providers/cline.js' +import { getVSCodeGlobalStoragePaths } from '../../src/providers/vscode-cline-parser.js' import { priceProviderCall } from '../../src/pricing-pass.js' import type { ParsedProviderCall } from '../../src/providers/types.js' +const EXTENSION_ID = 'saoudrizwan.claude-dev' + let tmpDir: string async function writeTask(baseDir: string, taskId: string, opts?: { @@ -94,6 +97,78 @@ describe('cline provider - discovery', () => { }) }) +describe('cline provider - default roots', () => { + let previousHome: string | undefined + let previousUserProfile: string | undefined + + beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'cline-test-')) + previousHome = process.env['HOME'] + previousUserProfile = process.env['USERPROFILE'] + // os.homedir() reads HOME on POSIX and USERPROFILE on Windows - set both so + // the default roots resolve inside the sandbox on every platform. + process.env['HOME'] = tmpDir + process.env['USERPROFILE'] = tmpDir + }) + + afterEach(async () => { + if (previousHome === undefined) delete process.env['HOME'] + else process.env['HOME'] = previousHome + if (previousUserProfile === undefined) delete process.env['USERPROFILE'] + else process.env['USERPROFILE'] = previousUserProfile + await rm(tmpDir, { recursive: true, force: true }) + }) + + it('discovers tasks from every VS Code variant (stable, Insiders, VSCodium)', async () => { + const roots = getVSCodeGlobalStoragePaths(EXTENSION_ID) + expect(roots).toHaveLength(3) + const [stableRoot, insidersRoot, codiumRoot] = roots + await writeTask(stableRoot!, 'task-stable') + await writeTask(insidersRoot!, 'task-insiders') + await writeTask(codiumRoot!, 'task-codium') + + const sessions = await createClineProvider().discoverSessions() + + expect(sessions).toHaveLength(3) + expect(sessions.every(s => s.provider === 'cline')).toBe(true) + expect(sessions.map(s => s.path).sort()).toEqual([ + join(stableRoot!, 'tasks', 'task-stable'), + join(insidersRoot!, 'tasks', 'task-insiders'), + join(codiumRoot!, 'tasks', 'task-codium'), + ].sort()) + }) + + it('still scans the ~/.cline/data root alongside the VS Code variants', async () => { + const stableRoot = getVSCodeGlobalStoragePaths(EXTENSION_ID)[0]! + const clineDataDir = getClineDataPath() + expect(clineDataDir).toBe(join(tmpDir, '.cline', 'data')) + await writeTask(stableRoot, 'task-vscode') + await writeTask(clineDataDir, 'task-home') + + const sessions = await createClineProvider().discoverSessions() + + expect(sessions.map(s => s.path).sort()).toEqual([ + join(stableRoot, 'tasks', 'task-vscode'), + join(clineDataDir, 'tasks', 'task-home'), + ].sort()) + }) + + it('does not double-count a task id shared by several VS Code variants', async () => { + const [stableRoot, insidersRoot, codiumRoot] = getVSCodeGlobalStoragePaths(EXTENSION_ID) + const stableTask = await writeTask(stableRoot!, 'task-same') + const insidersTask = await writeTask(insidersRoot!, 'task-same') + const codiumTask = await writeTask(codiumRoot!, 'task-same') + await utimes(join(stableTask, 'ui_messages.json'), new Date('2026-01-01T00:00:00Z'), new Date('2026-01-01T00:00:00Z')) + await utimes(join(codiumTask, 'ui_messages.json'), new Date('2026-02-01T00:00:00Z'), new Date('2026-02-01T00:00:00Z')) + await utimes(join(insidersTask, 'ui_messages.json'), new Date('2026-03-01T00:00:00Z'), new Date('2026-03-01T00:00:00Z')) + + const sessions = await createClineProvider().discoverSessions() + + expect(sessions).toHaveLength(1) + expect(sessions[0]!.path).toBe(insidersTask) + }) +}) + describe('cline provider - parsing', () => { beforeEach(async () => { tmpDir = await mkdtemp(join(tmpdir(), 'cline-test-')) diff --git a/packages/cli/tests/providers/omp.test.ts b/packages/cli/tests/providers/omp.test.ts index 5ba76ab4..70cba6e2 100644 --- a/packages/cli/tests/providers/omp.test.ts +++ b/packages/cli/tests/providers/omp.test.ts @@ -111,13 +111,30 @@ describe('omp provider - session discovery', () => { expect(sessions[0]!.project).toBe('myproject') }) + it('discovers title-first sessions', async () => { + const projectDir = join(tmpDir, '--Users-test-myproject--') + await writeSession(projectDir, 'title-first.jsonl', [ + JSON.stringify({ type: 'title', title: 'OMP title-first session' }), + '', + 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('returns empty for non-existent directory', async () => { const provider = createOmpProvider('/nonexistent/omp/path') const sessions = await provider.discoverSessions() expect(sessions).toEqual([]) }) - it('skips files whose first line is not a session entry', async () => { + it('skips files without a session entry', async () => { const projectDir = join(tmpDir, '--Users-test-myproject--') await writeSession(projectDir, 'bad.jsonl', [ JSON.stringify({ type: 'message', id: 'x' }), diff --git a/packages/cli/tests/providers/opencode-session-shared-bridge.test.ts b/packages/cli/tests/providers/opencode-session-shared-bridge.test.ts index 8650a74a..dfb6e1d9 100644 --- a/packages/cli/tests/providers/opencode-session-shared-bridge.test.ts +++ b/packages/cli/tests/providers/opencode-session-shared-bridge.test.ts @@ -64,7 +64,7 @@ function createTestDb(dir: string): string { time_archived INTEGER, cost REAL, tokens_input INTEGER, tokens_output INTEGER, tokens_reasoning INTEGER, tokens_cache_read INTEGER, tokens_cache_write INTEGER, - model_id TEXT + model TEXT ) `) db.exec(` @@ -164,7 +164,7 @@ function createKiloTestDb(dir: string): string { time_archived INTEGER, cost REAL, tokens_input INTEGER, tokens_output INTEGER, tokens_reasoning INTEGER, tokens_cache_read INTEGER, tokens_cache_write INTEGER, - model_id TEXT + model TEXT ) `) db.exec(` @@ -1277,9 +1277,9 @@ skipUnlessSqlite('SQLite arm S9-S12 — session-level fallback', () => { insertSession(db, 'sess-1') insertMessage(db, 'msg-user', 'sess-1', 1700000000000, { role: 'user' }) insertPart(db, 'p-user', 'msg-user', 'sess-1', { type: 'text', text: 'hello' }) - // session row rollup - db.prepare(`UPDATE session SET cost=1, tokens_input=100, tokens_output=50, tokens_reasoning=5, tokens_cache_read=10, tokens_cache_write=20, model_id=? WHERE id=?`) - .run('session-model', 'sess-1') + // session row rollup — model is the real-schema JSON object + db.prepare(`UPDATE session SET cost=1, tokens_input=100, tokens_output=50, tokens_reasoning=5, tokens_cache_read=10, tokens_cache_write=20, model=? WHERE id=?`) + .run(JSON.stringify({ providerID: 'test-provider', id: 'session-model' }), 'sess-1') }) const calls = await collectCalls(createOpenCodeProvider(tmpDir), dbPath, 'sess-1') @@ -1294,7 +1294,7 @@ skipUnlessSqlite('SQLite arm S9-S12 — session-level fallback', () => { "deduplicationKey": "opencode:sess-1:session-level", "fallbackCostUSD": 1, "inputTokens": 100, - "model": "session-model", + "model": "test-provider/session-model", "outputTokens": 50, "provider": "opencode", "reasoningTokens": 5, @@ -1819,6 +1819,60 @@ skipUnlessSqlite('kilo-code SQLite arm golden', () => { }) }) +// Kilo-code mirror of the opencode S9 case (SQLite arm, §7). The opencode and +// kilo-code SQLite arms share readSqliteSessionRecords + decodeOpenCodeSession, +// so the session-level fallback must resolve `model` the same way for kilo. +// The kilo golden and the zero-yield stderr case never populate a session +// rollup, so this is the only kilo test that pins the fallback call end to end. + +skipUnlessSqlite('kilo-code SQLite arm S9 mirror — session-level fallback', () => { + it('emits the session-level fallback with the model resolved from the real `model` column', async () => { + const dbPath = createKiloTestDb(tmpDir) + withTestDb(dbPath, (db) => { + insertSession(db, 'kilo-sess-s9') + insertMessage(db, 'msg-user', 'kilo-sess-s9', 1700000000000, { role: 'user' }) + insertPart(db, 'p-user', 'msg-user', 'kilo-sess-s9', { type: 'text', text: 'hello' }) + // session row rollup — model is the real-schema JSON object (same as S9) + db.prepare(`UPDATE session SET cost=1, tokens_input=100, tokens_output=50, tokens_reasoning=5, tokens_cache_read=10, tokens_cache_write=20, model=? WHERE id=?`) + .run(JSON.stringify({ providerID: 'test-provider', id: 'session-model' }), 'kilo-sess-s9') + }) + + const calls = await collectKiloCalls(dbPath, 'kilo-sess-s9') + expect(calls).toEqual([ + { + "bashCommands": [], + "cacheCreationInputTokens": 20, + "cacheReadInputTokens": 10, + "cachedInputTokens": 10, + "costBasis": "estimated", + "costUSD": 1, + "deduplicationKey": "kilo-code:kilo-sess-s9:session-level", + "fallbackCostUSD": 1, + "inputTokens": 100, + "model": "test-provider/session-model", + "outputTokens": 50, + "provider": "kilo-code", + "reasoningTokens": 5, + "sessionId": "kilo-sess-s9", + "speed": "standard", + "timestamp": "2023-11-14T22:13:20.000Z", + "tools": [], + "userMessage": "", + "webSearchRequests": 0, + }, +]) + // Key-presence gate, mirroring S9: the session-level arm emits NO + // skills/subagentTypes keys for kilo-code either. + const keys = Object.keys(calls[0]!) + expect(keys).not.toContain('skills') + expect(keys).not.toContain('subagentTypes') + expect(keys).toContain('tools') + expect(keys).toContain('bashCommands') + expect(keys).toContain('fallbackCostUSD') + expect(keys).toContain('costBasis') + }) +}) + // ═════════════════════════════════════════════════════════════════════════════ // CODEBURN_VERBOSE stderr parity // ═════════════════════════════════════════════════════════════════════════════ diff --git a/packages/cli/tests/providers/opencode.test.ts b/packages/cli/tests/providers/opencode.test.ts index fc5f1f07..84a3eeea 100644 --- a/packages/cli/tests/providers/opencode.test.ts +++ b/packages/cli/tests/providers/opencode.test.ts @@ -824,10 +824,15 @@ skipUnlessSqlite('opencode provider - session parsing', () => { db.exec(`ALTER TABLE session ADD COLUMN tokens_reasoning INTEGER`) db.exec(`ALTER TABLE session ADD COLUMN tokens_cache_read INTEGER`) db.exec(`ALTER TABLE session ADD COLUMN tokens_cache_write INTEGER`) - db.exec(`ALTER TABLE session ADD COLUMN model_id TEXT`) + db.exec(`ALTER TABLE session ADD COLUMN model TEXT`) insertSession(db, 'sess-1') - db.prepare(`UPDATE session SET cost = 0.15, tokens_input = 5000, tokens_output = 2000, tokens_reasoning = 0, tokens_cache_read = 3000, tokens_cache_write = 1000, model_id = 'claude-sonnet-4-20250514' WHERE id = 'sess-1'`).run() + db.prepare(`UPDATE session SET cost = ?, tokens_input = ?, tokens_output = ?, tokens_reasoning = ?, tokens_cache_read = ?, tokens_cache_write = ?, model = ? WHERE id = ?`) + .run(0.15, 5000, 2000, 0, 3000, 1000, JSON.stringify({ + providerID: 'anthropic', + id: 'claude-sonnet-4-20250514', + variant: 'high', + }), 'sess-1') insertMessage(db, 'msg-1', 'sess-1', 1700000001000, { role: 'assistant', modelID: 'claude-sonnet-4-20250514', @@ -841,7 +846,7 @@ skipUnlessSqlite('opencode provider - session parsing', () => { expect(calls[0]!.cacheReadInputTokens).toBe(3000) expect(calls[0]!.cacheCreationInputTokens).toBe(1000) expect(calls[0]!.costUSD).toBeGreaterThan(0) - expect(calls[0]!.model).toBe('claude-sonnet-4-20250514') + expect(calls[0]!.model).toBe('anthropic/claude-sonnet-4-20250514') expect(calls[0]!.deduplicationKey).toBe('opencode:sess-1:session-level') }) diff --git a/packages/cli/tests/providers/pi.test.ts b/packages/cli/tests/providers/pi.test.ts index f2efc73b..6b68cb2f 100644 --- a/packages/cli/tests/providers/pi.test.ts +++ b/packages/cli/tests/providers/pi.test.ts @@ -1,9 +1,10 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest' -import { mkdtemp, mkdir, writeFile, rm } from 'fs/promises' +import { mkdtemp, mkdir, writeFile, rm, truncate, stat } 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 { MAX_SESSION_FILE_BYTES } from '../../src/fs-utils.js' import { priceProviderCall } from '../../src/pricing-pass.js' import type { ParsedProviderCall } from '../../src/providers/types.js' import { classifyTurn } from '../../src/classifier.js' @@ -60,6 +61,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', @@ -166,7 +179,7 @@ describe('pi provider - session discovery', () => { expect(sessions).toEqual([]) }) - it('skips files whose first line is not a session entry', async () => { + it('skips files without a session entry', async () => { const projectDir = join(tmpDir, '--Users-test-myproject--') await writeSession(projectDir, 'bad.jsonl', [ JSON.stringify({ type: 'message', id: 'x' }), @@ -186,6 +199,145 @@ 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('does not crash on a malformed session record with a non-string cwd (e.g. cwd: 42)', async () => { + const projectDir = join(tmpDir, '--Users-test-myproject--') + await writeSession(projectDir, 'bad-cwd.jsonl', [ + titleSlot({ title: 'Malformed record' }), + JSON.stringify({ type: 'session', id: 'sess-bad', timestamp: '2026-04-14T10:00:00.000Z', cwd: 42 }), + assistantMessage({}), + ]) + + // The title line puts the session record on line 2, past the old + // first-line-only scan; a non-string cwd must fall back to the project + // directory instead of reaching an unchecked basename(cwd). + const provider = createOmpProvider(tmpDir) + const sessions = await provider.discoverSessions() + expect(sessions).toHaveLength(1) + // Falls back to the project dir name (basename of the single-segment + // dirName), exactly as a missing cwd does. + expect(sessions[0]!.project).toBe('--Users-test-myproject--') + }) + + 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 guards the exact + // bound: it fails if the scan is ever widened past 25 lines or replaced + // by a scan-to-EOF for the session record. (The old first-line reader + // happens to pass it too, so it is NOT the old-vs-new discriminator — + // the oversize-transcript test below is: it fails against the old + // whole-file reader.) + 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('finds a session at the head of a transcript too large for the whole-file reader', async () => { + const projectDir = join(tmpDir, '--Users-test-myproject--') + // The old discovery read the entire transcript with the whole-file reader, + // which refuses anything over MAX_SESSION_FILE_BYTES (128 MiB) — so a + // session whose transcript is larger than that was silently invisible. + // Discovery now streams a bounded twenty leading lines, so the record at + // the head is found no matter how large the tail is. This is the + // observable that distinguishes the bounded scan from a whole-file read: + // against the old reader this test is red (oversize => no session found), + // against the new one it is green. The file is extended past the cap with + // a sparse hole via truncate, so no 128 MiB is actually written. + const filePath = join(projectDir, 'oversize.jsonl') + await writeSession(projectDir, 'oversize.jsonl', [ + titleSlot(), + sessionMeta({ cwd: '/Users/test/myproject' }), + assistantMessage({}), + ]) + await truncate(filePath, MAX_SESSION_FILE_BYTES + 1024 * 1024) + expect((await stat(filePath)).size).toBeGreaterThan(MAX_SESSION_FILE_BYTES) + + const provider = createOmpProvider(tmpDir) + const sessions = await provider.discoverSessions() + + expect(sessions).toHaveLength(1) + expect(sessions[0]!.project).toBe('myproject') + }) }) describe('pi provider - JSONL parsing', () => {