diff --git a/packages/cli/src/providers/kilo-code.ts b/packages/cli/src/providers/kilo-code.ts index 249fb5d2..a8dfe23e 100644 --- a/packages/cli/src/providers/kilo-code.ts +++ b/packages/cli/src/providers/kilo-code.ts @@ -1,9 +1,16 @@ import { join } from 'path' import { homedir } from 'os' -import { discoverClineTasks, createClineParser } from './vscode-cline-parser.js' -import { discoverSqliteSessions, createSqliteSessionParser, type SqliteProviderConfig } from './sqlite-session-parser.js' -import type { Provider, SessionSource, SessionParser } from './types.js' +import { decodeVscodeCline } from '@codeburn/core/providers/vscode-cline' +import { decodeOpenCodeSession } from '@codeburn/core/providers/opencode-session' +import type { VscodeClineDecodedCall } from '@codeburn/core/providers/vscode-cline' +import type { OpenCodeSessionDecodedCall } from '@codeburn/core/providers/opencode-session' + +import { discoverSqliteSessions, readSqliteSessionRecords, type SqliteProviderConfig } from './sqlite-session-parser.js' +import { discoverClineTasks, readClineRecords, toClineProviderCall } from './vscode-cline-parser.js' +import { toOpenCodeProviderCall } from './opencode.js' +import { createBridgedProvider } from './bridge.js' +import type { Provider, SessionSource, ParsedProviderCall } from './types.js' const EXTENSION_ID = 'kilocode.kilo-code' const PROVIDER_NAME = 'kilo-code' @@ -18,10 +25,20 @@ function getSqliteConfig(): SqliteProviderConfig { } } +type KiloRich = + | { arm: 'cline'; call: VscodeClineDecodedCall } + | { arm: 'sqlite'; call: OpenCodeSessionDecodedCall } + export function createKiloCodeProvider(overrideDir?: string | string[]): Provider { const sqliteConfig = getSqliteConfig() - return { + // Provider-instance-scoped handoff for the verbose-stderr counts and session + // id on the SQLite arm. readRecords and decode are called synchronously in + // sequence inside one parse() generator, so this is safe. + let lastSqliteCounts: { messageCount: number; partCount: number } | undefined + let lastSessionId: string | undefined + + return createBridgedProvider({ name: PROVIDER_NAME, displayName: 'KiloCode', @@ -41,13 +58,59 @@ export function createKiloCodeProvider(overrideDir?: string | string[]): Provide return [...oldSessions, ...dbSessions] }, - createSessionParser(source: SessionSource, seenKeys: Set): SessionParser { + async readRecords(source) { if (source.path.includes('.db:')) { - return createSqliteSessionParser(source, seenKeys, sqliteConfig) + const result = await readSqliteSessionRecords(source, sqliteConfig) + if (result === null) return null + const envelope = result.records[0] as { sessionId: string } + lastSessionId = envelope.sessionId + lastSqliteCounts = { messageCount: result.messageCount, partCount: result.partCount } + return result.records } - return createClineParser(source, seenKeys, PROVIDER_NAME) + return readClineRecords(source) }, - } + + decode(input) { + const envelope = input.records[0] as { kind: string; sessionId?: string } | undefined + if (!envelope) return { calls: [] } + + if (envelope.kind === 'cline-task') { + const { calls } = decodeVscodeCline({ + records: input.records, + context: input.context, + seenKeys: input.seenKeys, + }) + return { calls: calls.map(call => ({ arm: 'cline' as const, call })) } + } + + if (envelope.kind === 'sqlite') { + const { calls, diagnostics } = decodeOpenCodeSession({ + records: input.records, + context: input.context, + seenKeys: input.seenKeys, + }) + // Preserve the pre-migration CODEBURN_VERBOSE notice for the SQLite arm. + if (calls.length === 0 && lastSqliteCounts && lastSqliteCounts.messageCount > 0 + && process.env['CODEBURN_VERBOSE'] === '1' && lastSessionId) { + const parseFailCount = diagnostics.filter(d => d.code === 'malformed-json').length + const roleSkipCount = diagnostics.filter(d => d.code === 'unknown-shape').length + process.stderr.write( + `codeburn: KiloCode session ${lastSessionId} has ${lastSqliteCounts.messageCount} messages ` + + `(${parseFailCount} unparseable, ${roleSkipCount} non-user/assistant roles) ` + + `but yielded 0 calls. Parts: ${lastSqliteCounts.partCount}.\n` + ) + } + return { calls: calls.map(call => ({ arm: 'sqlite' as const, call })) } + } + + return { calls: [] } + }, + + toProviderCall(rich) { + if (rich.arm === 'cline') return toClineProviderCall(rich.call) + return toOpenCodeProviderCall(rich.call) + }, + }) } export const kiloCode = createKiloCodeProvider() diff --git a/packages/cli/src/providers/opencode-file-parser.ts b/packages/cli/src/providers/opencode-file-parser.ts index 60bfb6a9..ac032bc4 100644 --- a/packages/cli/src/providers/opencode-file-parser.ts +++ b/packages/cli/src/providers/opencode-file-parser.ts @@ -1,15 +1,15 @@ import { readdir, readFile } from 'fs/promises' import { join } from 'path' -import { buildAssistantCall, sanitize, type MessageData, type PartData } from './session-message.js' -import type { SessionSource, SessionParser, ParsedProviderCall } from './types.js' +import { sanitize } from './session-message.js' +import type { SessionSource } from './types.js' // OpenCode 1.1+ stores sessions as file-based JSON instead of a SQLite DB: // storage/session//.json session metadata // storage/message//.json one file per message // storage/part//.json one file per part // The message/part shape matches the SQLite layout, so the per-message build -// logic is shared via buildAssistantCall. +// logic is shared via @codeburn/core/providers/opencode-session. type SessionMeta = { id?: string @@ -18,8 +18,24 @@ type SessionMeta = { time?: { created?: number } } -type FileMessageData = MessageData & { +type FileMessageData = { id?: string + role: string + modelID?: string + model?: string + cost?: number + tokens?: { + input?: number + output?: number + reasoning?: number + cache?: { read?: number; write?: number } + } + usage?: { + input_tokens?: number + output_tokens?: number + cache_creation_input_tokens?: number + cache_read_input_tokens?: number + } time?: { created?: number } } @@ -31,23 +47,6 @@ async function readJson(path: string): Promise { } } -async function readParts(dataDir: string, messageId: string): Promise { - const dir = join(dataDir, 'storage', 'part', messageId) - let files: string[] - try { - files = (await readdir(dir)).sort() - } catch { - return [] - } - const parts: PartData[] = [] - for (const f of files) { - if (!f.endsWith('.json')) continue - const part = await readJson(join(dir, f)) - if (part) parts.push(part) - } - return parts -} - export async function discoverOpenCodeFileSessions( dataDir: string, providerName: string, @@ -83,72 +82,62 @@ export async function discoverOpenCodeFileSessions( return sources } -export function createOpenCodeFileSessionParser( +export async function readOpenCodeFileRecords( source: SessionSource, - seenKeys: Set, dataDir: string, - providerName: string, -): SessionParser { - return { - async *parse(): AsyncGenerator { - const meta = await readJson(source.path) - if (!meta?.id) return - const sessionId = meta.id - - const messageDir = join(dataDir, 'storage', 'message', sessionId) - let messageFiles: string[] - try { - messageFiles = await readdir(messageDir) - } catch { - return - } - - const messages: Array<{ id: string; data: FileMessageData }> = [] - for (const f of messageFiles) { - if (!f.endsWith('.json')) continue - const data = await readJson(join(messageDir, f)) - if (!data) continue - messages.push({ id: data.id ?? f.replace(/\.json$/, ''), data }) - } - messages.sort((a, b) => { - const byTime = (a.data.time?.created ?? 0) - (b.data.time?.created ?? 0) - if (byTime !== 0) return byTime - return a.id < b.id ? -1 : a.id > b.id ? 1 : 0 - }) - - let currentUserMessage = '' - for (const { id, data } of messages) { - if (data.role === 'user') { - const parts = await readParts(dataDir, id) - const text = parts - .filter((p) => p.type === 'text') - .map((p) => p.text ?? '') - .filter(Boolean) - .join(' ') - if (text) currentUserMessage = text - continue - } +): Promise { + const meta = await readJson(source.path) + if (!meta?.id) return null + const sessionId = meta.id - if (data.role !== 'assistant' && data.role !== 'model') continue - - const dedupKey = `${providerName}:${sessionId}:${id}` - if (seenKeys.has(dedupKey)) continue + const messageDir = join(dataDir, 'storage', 'message', sessionId) + let messageFiles: string[] + try { + messageFiles = await readdir(messageDir) + } catch { + return null + } - const parts = await readParts(dataDir, id) - const call = buildAssistantCall({ - providerName, - dedupKey, - sessionId, - data, - parts, - timeCreatedMs: data.time?.created ?? meta.time?.created ?? 0, - userMessage: currentUserMessage, - }) - if (!call) continue + // Message-file JSON.parse stays host-side here (unlike the SQLite arm, where + // core parses), because the message ids determine which part directories to + // read: the parse is a precondition of the I/O, not a step after it. + const messages: Array<{ id: string; data: FileMessageData }> = [] + for (const f of messageFiles) { + if (!f.endsWith('.json')) continue + const data = await readJson(join(messageDir, f)) + if (!data) continue + messages.push({ id: data.id ?? f.replace(/\.json$/, ''), data }) + } - seenKeys.add(dedupKey) - yield call + // Part files are read eagerly for every message. The original read them lazily + // only for messages that survived role and dedup checks; this is a strict + // superset with identical output, changing only I/O volume. + const partsRawByMessageId = new Map() + for (const { id } of messages) { + const partDir = join(dataDir, 'storage', 'part', id) + let files: string[] + try { + files = (await readdir(partDir)).sort() + } catch { + continue + } + const rawParts: string[] = [] + for (const f of files) { + if (!f.endsWith('.json')) continue + try { + rawParts.push(await readFile(join(partDir, f), 'utf8')) + } catch { + // skip unreadable part file } - }, + } + partsRawByMessageId.set(id, rawParts) } + + return [{ + kind: 'file', + sessionId, + messages, + partsRawByMessageId, + metaTimeCreatedMs: meta.time?.created, + }] } diff --git a/packages/cli/src/providers/opencode.ts b/packages/cli/src/providers/opencode.ts index c8089513..d2bad809 100644 --- a/packages/cli/src/providers/opencode.ts +++ b/packages/cli/src/providers/opencode.ts @@ -1,10 +1,14 @@ import { join } from 'path' import { homedir } from 'os' +import { decodeOpenCodeSession } from '@codeburn/core/providers/opencode-session' +import type { OpenCodeSessionDecodedCall } from '@codeburn/core/providers/opencode-session' import { getShortModelName } from '../models.js' -import { discoverSqliteSessions, createSqliteSessionParser, type SqliteProviderConfig } from './sqlite-session-parser.js' -import { discoverOpenCodeFileSessions, createOpenCodeFileSessionParser } from './opencode-file-parser.js' -import type { Provider, ProbeRoot, SessionSource, SessionParser } from './types.js' +import { extractBashCommands } from '../bash-utils.js' +import { discoverSqliteSessions, readSqliteSessionRecords, type SqliteProviderConfig } from './sqlite-session-parser.js' +import { discoverOpenCodeFileSessions, readOpenCodeFileRecords } from './opencode-file-parser.js' +import { createBridgedProvider } from './bridge.js' +import type { Provider, ProbeRoot, SessionSource, ParsedProviderCall } from './types.js' const toolNameMap: Record = { bash: 'Bash', @@ -55,11 +59,41 @@ function getSqliteConfig(dataDir?: string): SqliteProviderConfig { } } +export function toOpenCodeProviderCall(rich: OpenCodeSessionDecodedCall): ParsedProviderCall { + return { + provider: rich.provider, + model: rich.model, + inputTokens: rich.inputTokens, + outputTokens: rich.outputTokens, + cacheCreationInputTokens: rich.cacheCreationInputTokens, + cacheReadInputTokens: rich.cacheReadInputTokens, + cachedInputTokens: rich.cachedInputTokens, + reasoningTokens: rich.reasoningTokens, + webSearchRequests: rich.webSearchRequests, + costBasis: 'estimated', + ...(rich.fallbackCostUSD !== undefined ? { fallbackCostUSD: rich.fallbackCostUSD } : {}), + tools: rich.tools, + bashCommands: rich.rawBashCommands.flatMap(c => extractBashCommands(c)), + ...(rich.arm === 'session-level' ? {} : { skills: rich.skills, subagentTypes: rich.subagentTypes }), + timestamp: rich.timestamp, + speed: rich.speed, + deduplicationKey: rich.deduplicationKey, + userMessage: rich.userMessage, + sessionId: rich.sessionId, + } +} + export function createOpenCodeProvider(dataDir?: string): Provider { const sqliteConfig = getSqliteConfig(dataDir) const resolvedDataDir = getDataDir(dataDir) - return { + // Provider-instance-scoped handoff for the verbose-stderr counts and session + // id. readRecords and decode are called synchronously in sequence inside one + // parse() generator, so this is safe without ordering assumptions. + let lastSqliteCounts: { messageCount: number; partCount: number } | undefined + let lastSessionId: string | undefined + + return createBridgedProvider({ name: 'opencode', displayName: 'OpenCode', @@ -91,13 +125,43 @@ export function createOpenCodeProvider(dataDir?: string): Provider { return [...fileSessions, ...sqliteSessions] }, - createSessionParser(source: SessionSource, seenKeys: Set): SessionParser { + async readRecords(source) { if (source.path.endsWith('.json')) { - return createOpenCodeFileSessionParser(source, seenKeys, resolvedDataDir, 'opencode') + // Clear the SQLite handoff: the file arm has no verbose notice, and a + // stale count from an earlier SQLite source would make decode() emit one + // for a zero-yield file session. + lastSqliteCounts = undefined + lastSessionId = undefined + return readOpenCodeFileRecords(source, resolvedDataDir) } - return createSqliteSessionParser(source, seenKeys, sqliteConfig) + const result = await readSqliteSessionRecords(source, sqliteConfig) + if (result === null) return null + const envelope = result.records[0] as { sessionId: string } + lastSessionId = envelope.sessionId + lastSqliteCounts = { messageCount: result.messageCount, partCount: result.partCount } + return result.records }, - } + + decode(input) { + const { calls, diagnostics } = decodeOpenCodeSession(input) + // The pre-migration decode printed one aggregate diagnostic per zero-yield + // SQLite session under CODEBURN_VERBOSE. The bridge discards diagnostics, so + // the notice is re-emitted here rather than silently dropped. + if (calls.length === 0 && lastSqliteCounts && lastSqliteCounts.messageCount > 0 + && process.env['CODEBURN_VERBOSE'] === '1' && lastSessionId) { + const parseFailCount = diagnostics.filter(d => d.code === 'malformed-json').length + const roleSkipCount = diagnostics.filter(d => d.code === 'unknown-shape').length + process.stderr.write( + `codeburn: OpenCode session ${lastSessionId} has ${lastSqliteCounts.messageCount} messages ` + + `(${parseFailCount} unparseable, ${roleSkipCount} non-user/assistant roles) ` + + `but yielded 0 calls. Parts: ${lastSqliteCounts.partCount}.\n` + ) + } + return { calls } + }, + + toProviderCall: toOpenCodeProviderCall, + }) } export const opencode = createOpenCodeProvider() diff --git a/packages/cli/src/providers/session-message.ts b/packages/cli/src/providers/session-message.ts index c4bf80fa..5a53c7b0 100644 --- a/packages/cli/src/providers/session-message.ts +++ b/packages/cli/src/providers/session-message.ts @@ -1,155 +1,8 @@ -import { extractBashCommands } from '../bash-utils.js' -import type { ParsedProviderCall } from './types.js' - -// The message/part shape shared by OpenCode-style stores (OpenCode SQLite, the -// OpenCode file-based JSON layout, and Kilo Code). Token-bearing assistant -// messages carry either the normalized `tokens` object or a raw `usage` block. -export type MessageData = { - role: string - modelID?: string - model?: string - cost?: number - tokens?: { - input?: number - output?: number - reasoning?: number - cache?: { read?: number; write?: number } - } - usage?: { - input_tokens?: number - output_tokens?: number - cache_creation_input_tokens?: number - cache_read_input_tokens?: number - } -} - -export type PartData = { - type: string - text?: string - tool?: string - state?: { input?: { command?: string; name?: string; subagent_type?: string } } -} - -const toolNameMap: Record = { - bash: 'Bash', - read: 'Read', - edit: 'Edit', - write: 'Write', - glob: 'Glob', - grep: 'Grep', - task: 'Agent', - fetch: 'WebFetch', - search: 'WebSearch', - todo: 'TodoWrite', - skill: 'Skill', - patch: 'Patch', -} - -export function normalizeToolName(rawTool?: string): string { - if (!rawTool) return '' - if (rawTool.startsWith('mcp__')) return rawTool - const builtIn = toolNameMap[rawTool] - if (builtIn) return builtIn - const serverSeparator = rawTool.indexOf('_') - if (serverSeparator > 0 && serverSeparator < rawTool.length - 1) { - const server = rawTool.slice(0, serverSeparator) - const tool = rawTool.slice(serverSeparator + 1) - return `mcp__${server}__${tool}` - } - return rawTool -} +// Discovery-side helper retained from the old OpenCode session-message module. +// The shared decode logic (the assistant-turn builder, tool-name normalization, +// timestamp parsing, and the message/part types) moved to +// @codeburn/core/providers/opencode-session. export function sanitize(dir: string): string { return dir.replace(/^\//, '').replace(/\//g, '-') } - -export function parseTimestamp(raw: number): string { - const ms = raw < 1e12 ? raw * 1000 : raw - return new Date(ms).toISOString() -} - -// Build a ParsedProviderCall from one assistant message and its parts. Returns -// null when the message has no tokens, no cost, and no substantive parts (an -// empty or errored turn worth skipping). Shared by the SQLite and file-based -// OpenCode parsers so both attribute tokens, tools, and cost identically. -export function buildAssistantCall(opts: { - providerName: string - dedupKey: string - sessionId: string - data: MessageData - parts: PartData[] - timeCreatedMs: number - userMessage: string -}): ParsedProviderCall | null { - const { data, parts } = opts - - const tokens = { - input: data.tokens?.input ?? data.usage?.input_tokens ?? 0, - output: data.tokens?.output ?? data.usage?.output_tokens ?? 0, - reasoning: data.tokens?.reasoning ?? 0, - cacheRead: data.tokens?.cache?.read ?? data.usage?.cache_read_input_tokens ?? 0, - cacheWrite: data.tokens?.cache?.write ?? data.usage?.cache_creation_input_tokens ?? 0, - } - - const toolParts = parts.filter((p) => (p.type === 'tool' || p.type === 'tool-call' || p.type === 'tool_call') && normalizeToolName(p.tool)) - const hasTextOutput = parts.some((p) => p.type === 'text' && typeof p.text === 'string' && p.text.trim().length > 0) - const hasToolOrTextParts = hasTextOutput || toolParts.length > 0 - const hasAnySubstantiveParts = parts.some((p) => - p.type === 'text' || p.type === 'tool' || p.type === 'tool-call' || p.type === 'tool_call' || - p.type === 'tool-result' || p.type === 'tool_result' || p.type === 'reasoning' || p.type === 'file' - ) - const hasActivity = hasToolOrTextParts || hasAnySubstantiveParts - - const allZero = - tokens.input === 0 && - tokens.output === 0 && - tokens.reasoning === 0 && - tokens.cacheRead === 0 && - tokens.cacheWrite === 0 - if (allZero && (data.cost ?? 0) === 0 && !hasActivity) return null - - const tools = toolParts - .map((p) => normalizeToolName(p.tool)) - .filter(Boolean) - - const bashCommands = toolParts - .filter((p) => p.tool === 'bash' && typeof p.state?.input?.command === 'string') - .flatMap((p) => extractBashCommands(p.state!.input!.command!)) - - // The skill/subagent name lives in the tool-call input, not the tool name, so - // the Skills & Agents breakdown needs these extracted alongside the tool list. - const skills = toolParts - .filter((p) => p.tool === 'skill' && typeof p.state?.input?.name === 'string') - .map((p) => p.state!.input!.name!) - .filter(Boolean) - - const subagentTypes = toolParts - .filter((p) => p.tool === 'task' && typeof p.state?.input?.subagent_type === 'string') - .map((p) => p.state!.input!.subagent_type!) - .filter(Boolean) - - const model = data.modelID ?? data.model ?? 'unknown' - - return { - provider: opts.providerName, - model, - inputTokens: tokens.input, - outputTokens: tokens.output, - cacheCreationInputTokens: tokens.cacheWrite, - cacheReadInputTokens: tokens.cacheRead, - cachedInputTokens: tokens.cacheRead, - reasoningTokens: tokens.reasoning, - webSearchRequests: 0, - costBasis: 'estimated', - ...(typeof data.cost === 'number' ? { fallbackCostUSD: data.cost } : {}), - tools, - bashCommands, - skills, - subagentTypes, - timestamp: parseTimestamp(opts.timeCreatedMs), - speed: 'standard', - deduplicationKey: opts.dedupKey, - userMessage: opts.userMessage, - sessionId: opts.sessionId, - } -} diff --git a/packages/cli/src/providers/sqlite-session-parser.ts b/packages/cli/src/providers/sqlite-session-parser.ts index 93935c6f..18b7c943 100644 --- a/packages/cli/src/providers/sqlite-session-parser.ts +++ b/packages/cli/src/providers/sqlite-session-parser.ts @@ -2,11 +2,9 @@ import { readdir } from 'fs/promises' import { join } from 'path' import { isSqliteAvailable, getSqliteLoadError, openDatabase, blobToText, isSqliteBusyError, type SqliteDatabase } from '../sqlite.js' -import { buildAssistantCall, parseTimestamp, sanitize, type MessageData, type PartData } from './session-message.js' +import { sanitize } from './session-message.js' import type { SessionSource, - SessionParser, - ParsedProviderCall, } from './types.js' type MessageRow = { @@ -38,7 +36,7 @@ type SessionTokenRow = { model_id?: string } -function tryQuerySessionTokens(db: SqliteDatabase, sessionId: string): { +export function tryQuerySessionTokens(db: SqliteDatabase, sessionId: string): { cost: number; input: number; output: number; reasoning: number cacheRead: number; cacheWrite: number; model: string | undefined } | null { @@ -100,173 +98,89 @@ export type SqliteProviderConfig = { dbFilePrefix: string } -export function createSqliteSessionParser( +export async function readSqliteSessionRecords( source: SessionSource, - seenKeys: Set, config: SqliteProviderConfig, -): SessionParser { - return { - async *parse(): AsyncGenerator { - if (!isSqliteAvailable()) { - process.stderr.write(getSqliteLoadError() + '\n') - return - } - - const segments = source.path.split(':') - const sessionId = segments[segments.length - 1]! - const dbPath = segments.slice(0, -1).join(':') - - let db: SqliteDatabase - try { - db = openDatabase(dbPath) - } catch (err) { - process.stderr.write(`codeburn: cannot open ${config.displayName} database: ${err instanceof Error ? err.message : err}\n`) - return - } - - try { - const schema = validateSchemaDetailed(db) - if (!schema.ok) { - warnUnrecognizedSchemaOnce(config.displayName, schema.missing) - return - } - - const messages = db.query( - `WITH RECURSIVE session_tree(id) AS ( - SELECT id FROM session WHERE id = ? - UNION - SELECT child.id - FROM session child - JOIN session_tree parent ON child.parent_id = parent.id - WHERE child.time_archived IS NULL - ) - SELECT session_id, id, time_created, CAST(data AS BLOB) AS data - FROM message - WHERE session_id IN (SELECT id FROM session_tree) - ORDER BY time_created ASC, id ASC`, - [sessionId], - ) - - const parts = db.query( - `WITH RECURSIVE session_tree(id) AS ( - SELECT id FROM session WHERE id = ? - UNION - SELECT child.id - FROM session child - JOIN session_tree parent ON child.parent_id = parent.id - WHERE child.time_archived IS NULL - ) - SELECT message_id, CAST(data AS BLOB) AS data - FROM part - WHERE session_id IN (SELECT id FROM session_tree) - ORDER BY message_id, id`, - [sessionId], - ) - - const partsByMsg = new Map() - for (const part of parts) { - try { - const parsed = JSON.parse(blobToText(part.data)) as PartData - const list = partsByMsg.get(part.message_id) ?? [] - list.push(parsed) - partsByMsg.set(part.message_id, list) - } catch { - // skip corrupt part data - } - } - - const currentUserMessageBySession = new Map() - let yieldCount = 0 - let parseFailCount = 0 - let roleSkipCount = 0 - - for (const msg of messages) { - let data: MessageData - try { - data = JSON.parse(blobToText(msg.data)) as MessageData - } catch { - parseFailCount++ - continue - } +): Promise<{ records: unknown[]; messageCount: number; partCount: number } | null> { + if (!isSqliteAvailable()) { + process.stderr.write(getSqliteLoadError() + '\n') + return null + } - if (data.role === 'user') { - const textParts = (partsByMsg.get(msg.id) ?? []) - .filter((p) => p.type === 'text') - .map((p) => p.text ?? '') - .filter(Boolean) - if (textParts.length > 0) { - currentUserMessageBySession.set(msg.session_id, textParts.join(' ')) - } - continue - } + const segments = source.path.split(':') + const sessionId = segments[segments.length - 1]! + const dbPath = segments.slice(0, -1).join(':') - if (data.role !== 'assistant' && data.role !== 'model') { - if (data.role !== 'user') roleSkipCount++ - continue - } + let db: SqliteDatabase + try { + db = openDatabase(dbPath) + } catch (err) { + process.stderr.write(`codeburn: cannot open ${config.displayName} database: ${err instanceof Error ? err.message : err}\n`) + return null + } - const dedupKey = `${config.providerName}:${msg.session_id}:${msg.id}` - if (seenKeys.has(dedupKey)) continue + try { + const schema = validateSchemaDetailed(db) + if (!schema.ok) { + warnUnrecognizedSchemaOnce(config.displayName, schema.missing) + return null + } - const call = buildAssistantCall({ - providerName: config.providerName, - dedupKey, - sessionId, - data, - parts: partsByMsg.get(msg.id) ?? [], - timeCreatedMs: msg.time_created, - userMessage: currentUserMessageBySession.get(msg.session_id) ?? '', - }) - if (!call) continue + const messages = db.query( + `WITH RECURSIVE session_tree(id) AS ( + SELECT id FROM session WHERE id = ? + UNION + SELECT child.id + FROM session child + JOIN session_tree parent ON child.parent_id = parent.id + WHERE child.time_archived IS NULL + ) + SELECT session_id, id, time_created, CAST(data AS BLOB) AS data + FROM message + WHERE session_id IN (SELECT id FROM session_tree) + ORDER BY time_created ASC, id ASC`, + [sessionId], + ) - seenKeys.add(dedupKey) - yieldCount++ - yield call - } + const parts = db.query( + `WITH RECURSIVE session_tree(id) AS ( + SELECT id FROM session WHERE id = ? + UNION + SELECT child.id + FROM session child + JOIN session_tree parent ON child.parent_id = parent.id + WHERE child.time_archived IS NULL + ) + SELECT message_id, CAST(data AS BLOB) AS data + FROM part + WHERE session_id IN (SELECT id FROM session_tree) + ORDER BY message_id, id`, + [sessionId], + ) - if (yieldCount === 0 && messages.length > 0) { - const sessionTokens = tryQuerySessionTokens(db, sessionId) - if (sessionTokens && (sessionTokens.cost > 0 || sessionTokens.input > 0 || sessionTokens.output > 0)) { - const dedupKey = `${config.providerName}:${sessionId}:session-level` - if (!seenKeys.has(dedupKey)) { - seenKeys.add(dedupKey) - const model = sessionTokens.model ?? 'unknown' - yield { - provider: config.providerName, - model, - inputTokens: sessionTokens.input, - outputTokens: sessionTokens.output, - cacheCreationInputTokens: sessionTokens.cacheWrite, - cacheReadInputTokens: sessionTokens.cacheRead, - cachedInputTokens: sessionTokens.cacheRead, - reasoningTokens: sessionTokens.reasoning, - webSearchRequests: 0, - costBasis: 'estimated', - ...(sessionTokens.cost > 0 ? { fallbackCostUSD: sessionTokens.cost } : {}), - tools: [], - bashCommands: [], - timestamp: parseTimestamp(messages[0]!.time_created), - speed: 'standard', - deduplicationKey: dedupKey, - userMessage: '', - sessionId, - } - yieldCount++ - } - } + const sessionTokens = tryQuerySessionTokens(db, sessionId) - if (yieldCount === 0 && process.env['CODEBURN_VERBOSE'] === '1') { - process.stderr.write( - `codeburn: ${config.displayName} session ${sessionId} has ${messages.length} messages ` + - `(${parseFailCount} unparseable, ${roleSkipCount} non-user/assistant roles) ` + - `but yielded 0 calls. Parts: ${parts.length}.\n` - ) - } - } - } finally { - db.close() - } - }, + return { + records: [{ + kind: 'sqlite', + sessionId, + messages: messages.map(m => ({ + session_id: m.session_id, + id: m.id, + time_created: m.time_created, + data: blobToText(m.data), + })), + parts: parts.map(p => ({ + message_id: p.message_id, + data: blobToText(p.data), + })), + sessionTokens, + }], + messageCount: messages.length, + partCount: parts.length, + } + } finally { + db.close() } } @@ -322,4 +236,3 @@ export async function discoverSqliteSessions( return sessions } - diff --git a/packages/cli/src/providers/vscode-cline-parser.ts b/packages/cli/src/providers/vscode-cline-parser.ts index 7f692bbe..7fca033b 100644 --- a/packages/cli/src/providers/vscode-cline-parser.ts +++ b/packages/cli/src/providers/vscode-cline-parser.ts @@ -5,7 +5,7 @@ import { homedir } from 'os' import { decodeVscodeCline } from '@codeburn/core/providers/vscode-cline' import type { ClineRecordEnvelope, VscodeClineDecodedCall } from '@codeburn/core/providers/vscode-cline' -import type { SessionSource, SessionParser, ParsedProviderCall } from './types.js' +import type { ParsedProviderCall, SessionSource } from './types.js' export function getVSCodeGlobalStoragePaths(extensionId: string, homeDir = homedir(), platform = process.platform): string[] { const pathJoin = platform === 'win32' ? win32.join : posix.join @@ -124,25 +124,3 @@ export function toClineProviderCall(rich: VscodeClineDecodedCall): ParsedProvide projectPath: rich.projectPath, } } - -/** - * Legacy-shaped adapter retained for kilo-code, whose second (SQLite) arm does - * not move to core until batch S2. It is I/O + map over the core decode — no - * decode logic lives here. Deleted once kilo-code becomes a bridged provider. - */ -export function createClineParser( - source: SessionSource, - seenKeys: Set, - providerName: string, - fallbackModel = 'cline-auto', -): SessionParser { - return { - async *parse(): AsyncGenerator { - const records = await readClineRecords(source) - if (records === null) return - const context = { privacyKey: '', providerId: providerName, sourceRef: source.path } - const { calls } = decodeVscodeCline({ records, context, seenKeys, fallbackModel }) - for (const rich of calls) yield toClineProviderCall(rich) - }, - } -} diff --git a/packages/cli/tests/providers/opencode-session-shared-bridge.test.ts b/packages/cli/tests/providers/opencode-session-shared-bridge.test.ts new file mode 100644 index 00000000..8650a74a --- /dev/null +++ b/packages/cli/tests/providers/opencode-session-shared-bridge.test.ts @@ -0,0 +1,1926 @@ +// Shared-bridge goldens for the opencode-session decode family (phase 8, batch S2). +// +// Every literal array below was captured against the PRE-MIGRATION tree +// (38172892), before `session-message.ts` / `sqlite-session-parser.ts` / +// `opencode-file-parser.ts` were unified into +// `@codeburn/core/providers/opencode-session`. The goldens are the oracle: if +// the bridge disagrees, the bridge is wrong. +// +// `toEqual` ignores keys whose value is `undefined`, so the golden alone cannot +// prove a key is absent rather than present-and-undefined. The key-presence +// gates below close that gap for the arms where key SHAPE is load-bearing — +// notably the session-level fallback, which emitted no `skills`/`subagentTypes` +// keys at all pre-migration. + +import { mkdtemp, rm, mkdir, writeFile } from 'fs/promises' +import { mkdirSync } from 'fs' +import { join } from 'path' +import { tmpdir } from 'os' +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' + +import { isSqliteAvailable } from '../../src/sqlite.js' +import { createOpenCodeProvider } from '../../src/providers/opencode.js' +import { createKiloCodeProvider } from '../../src/providers/kilo-code.js' +import { priceProviderCall } from '../../src/pricing-pass.js' +import type { ParsedProviderCall } from '../../src/providers/types.js' + +type TestDb = { + exec(sql: string): void + prepare(sql: string): { run(...params: unknown[]): void } + close(): void +} + +let tmpDir: string +let originalXdg: string | undefined + +beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'opencode-bridge-test-')) + originalXdg = process.env['XDG_DATA_HOME'] +}) + +afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }) + if (originalXdg === undefined) { + delete process.env['XDG_DATA_HOME'] + } else { + process.env['XDG_DATA_HOME'] = originalXdg + } +}) + +// ── SQLite fixture builders (copied from opencode.test.ts) ─────────────────── + +function createTestDb(dir: string): string { + const ocDir = join(dir, 'opencode') + mkdirSync(ocDir, { recursive: true }) + const dbPath = join(ocDir, 'opencode.db') + + const { DatabaseSync: Database } = require('node:sqlite') + const db = new Database(dbPath) + db.exec(` + CREATE TABLE session ( + id TEXT PRIMARY KEY, project_id TEXT NOT NULL, parent_id TEXT, + slug TEXT NOT NULL, directory TEXT NOT NULL, title TEXT NOT NULL, + version TEXT NOT NULL, time_created INTEGER, time_updated INTEGER, + 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 + ) + `) + db.exec(` + CREATE TABLE message ( + id TEXT PRIMARY KEY, session_id TEXT NOT NULL, + time_created INTEGER, time_updated INTEGER, data TEXT NOT NULL + ) + `) + db.exec(` + CREATE TABLE part ( + id TEXT PRIMARY KEY, message_id TEXT NOT NULL, + session_id TEXT NOT NULL, time_created INTEGER, + time_updated INTEGER, data TEXT NOT NULL + ) + `) + db.close() + return dbPath +} + +function withTestDb(dbPath: string, fn: (db: TestDb) => void): void { + const { DatabaseSync: Database } = require('node:sqlite') + const db = new Database(dbPath) + fn(db) + db.close() +} + +function insertSession( + db: TestDb, + id: string, + opts: { directory?: string; title?: string; parentId?: string | null; archived?: number | null; timeCreated?: number } = {}, +): void { + db.prepare(` + INSERT INTO session (id, project_id, slug, directory, title, version, time_created, time_archived, parent_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run(id, 'proj-1', 'slug-1', opts.directory ?? '/home/user/myproject', opts.title ?? 'My Project', '1.0', opts.timeCreated ?? 1700000000000, opts.archived ?? null, opts.parentId ?? null) +} + +type MessageFixture = { + role: string + modelID?: string + model?: string + cost?: number + tokens?: { + input: number + output: number + reasoning: number + cache: { read: number; write: number } + } + usage?: { + input_tokens?: number + output_tokens?: number + cache_creation_input_tokens?: number + cache_read_input_tokens?: number + } +} + +type PartFixture = { + type: string + text?: string + tool?: string + state?: { status: string; input: { command?: string; name?: string; subagent_type?: string } } +} + +function insertMessage(db: TestDb, id: string, sessionId: string, timeCreated: number, data: MessageFixture): void { + db.prepare(`INSERT INTO message (id, session_id, time_created, data) VALUES (?, ?, ?, ?)`) + .run(id, sessionId, timeCreated, JSON.stringify(data)) +} + +function insertPart(db: TestDb, id: string, messageId: string, sessionId: string, data: PartFixture): void { + db.prepare(`INSERT INTO part (id, message_id, session_id, data) VALUES (?, ?, ?, ?)`) + .run(id, messageId, sessionId, JSON.stringify(data)) +} + +async function collectCalls(provider: ReturnType, dbPath: string, sessionId: string, seenKeys?: Set): Promise { + const source = { path: `${dbPath}:${sessionId}`, project: 'myproject', provider: provider.name } + const calls: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser(source, seenKeys ?? new Set()).parse()) { + calls.push(priceProviderCall(call)) + } + return calls +} + +// ── KiloCode SQLite fixture builder (from scratch, §7) ─────────────────────── + +function createKiloTestDb(dir: string): string { + const kiloDir = join(dir, 'kilo') + mkdirSync(kiloDir, { recursive: true }) + const dbPath = join(kiloDir, 'kilo.db') + + const { DatabaseSync: Database } = require('node:sqlite') + const db = new Database(dbPath) + db.exec(` + CREATE TABLE session ( + id TEXT PRIMARY KEY, project_id TEXT NOT NULL, parent_id TEXT, + slug TEXT NOT NULL, directory TEXT NOT NULL, title TEXT NOT NULL, + version TEXT NOT NULL, time_created INTEGER, time_updated INTEGER, + 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 + ) + `) + db.exec(` + CREATE TABLE message ( + id TEXT PRIMARY KEY, session_id TEXT NOT NULL, + time_created INTEGER, time_updated INTEGER, data TEXT NOT NULL + ) + `) + db.exec(` + CREATE TABLE part ( + id TEXT PRIMARY KEY, message_id TEXT NOT NULL, + session_id TEXT NOT NULL, time_created INTEGER, + time_updated INTEGER, data TEXT NOT NULL + ) + `) + db.close() + return dbPath +} + +async function collectKiloCalls(dbPath: string, sessionId: string, seenKeys?: Set): Promise { + process.env['XDG_DATA_HOME'] = tmpDir + const provider = createKiloCodeProvider() + const source = { path: `${dbPath}:${sessionId}`, project: 'myproject', provider: provider.name } + const calls: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser(source, seenKeys ?? new Set()).parse()) { + calls.push(priceProviderCall(call)) + } + return calls +} + +// ── File-based fixture builder (copied from opencode-file.test.ts) ─────────── + +type Msg = { id: string; data: Record; parts?: Array> } + +async function writeSession(opts: { + sessionId?: string + projectId?: string + directory?: string + title?: string + root?: string + messages: Msg[] +}) { + const storage = join(opts.root ?? join(tmpDir, 'opencode'), 'storage') + const sessionId = opts.sessionId ?? 'ses_test1' + const projectId = opts.projectId ?? 'global' + + const sessionDir = join(storage, 'session', projectId) + await mkdir(sessionDir, { recursive: true }) + await writeFile(join(sessionDir, `${sessionId}.json`), JSON.stringify({ + id: sessionId, + slug: 'cosmic-engine', + version: '1.1.65', + projectID: projectId, + directory: opts.directory ?? '/Users/test/myproject', + title: opts.title ?? 'Test session', + time: { created: 1781886356809, updated: 1781886683506 }, + })) + + const messageDir = join(storage, 'message', sessionId) + await mkdir(messageDir, { recursive: true }) + for (const m of opts.messages) { + await writeFile(join(messageDir, `${m.id}.json`), JSON.stringify({ id: m.id, sessionID: sessionId, ...m.data })) + if (m.parts?.length) { + const partDir = join(storage, 'part', m.id) + await mkdir(partDir, { recursive: true }) + let i = 0 + for (const p of m.parts) { + await writeFile(join(partDir, `prt_${m.id}_${String(i++).padStart(3, '0')}.json`), JSON.stringify(p)) + } + } + } + return { sessionId, storage } +} + +async function collectFileCalls(seen = new Set()): Promise { + const provider = createOpenCodeProvider(tmpDir) + const sources = await provider.discoverSessions() + const calls: ParsedProviderCall[] = [] + for (const source of sources) { + for await (const call of provider.createSessionParser(source, seen).parse()) { + calls.push(priceProviderCall(call)) + } + } + return calls +} + +const skipUnlessSqlite = isSqliteAvailable() ? describe : describe.skip + +// ═════════════════════════════════════════════════════════════════════════════ +// Shared builder arms (H1-H18), driven through the SQLite arm +// ═════════════════════════════════════════════════════════════════════════════ + +skipUnlessSqlite('shared builder H1-H3, H9, H16, H18 — token shape and precedence', () => { + it('pins normalized tokens, usage fallback, and empty skill/subagent arrays', async () => { + const dbPath = createTestDb(tmpDir) + withTestDb(dbPath, (db) => { + insertSession(db, 'sess-1') + + insertMessage(db, 'msg-1', 'sess-1', 1700000000000, { role: 'user' }) + insertPart(db, 'part-1', 'msg-1', 'sess-1', { type: 'text', text: 'hello' }) + + insertMessage(db, 'msg-2', 'sess-1', 1700000001000, { + role: 'assistant', + modelID: 'claude-opus-4-6', + cost: 0.05, + tokens: { input: 100, output: 200, reasoning: 50, cache: { read: 500, write: 300 } }, + }) + }) + + const calls = await collectCalls(createOpenCodeProvider(tmpDir), dbPath, 'sess-1') + expect(calls).toEqual([ + { + "bashCommands": [], + "cacheCreationInputTokens": 300, + "cacheReadInputTokens": 500, + "cachedInputTokens": 500, + "costBasis": "estimated", + "costUSD": 0.008875000000000001, + "deduplicationKey": "opencode:sess-1:msg-2", + "fallbackCostUSD": 0.05, + "inputTokens": 100, + "model": "claude-opus-4-6", + "outputTokens": 200, + "provider": "opencode", + "reasoningTokens": 50, + "sessionId": "sess-1", + "skills": [], + "speed": "standard", + "subagentTypes": [], + "timestamp": "2023-11-14T22:13:21.000Z", + "tools": [], + "userMessage": "hello", + "webSearchRequests": 0, + }, +]) + }) + + it('pins usage.* raw shape and precedence (tokens wins over usage)', async () => { + const dbPath = createTestDb(tmpDir) + withTestDb(dbPath, (db) => { + insertSession(db, 'sess-1') + + insertMessage(db, 'msg-1', 'sess-1', 1700000000000, { role: 'user' }) + insertPart(db, 'part-1', 'msg-1', 'sess-1', { type: 'text', text: 'usage test' }) + + insertMessage(db, 'msg-2', 'sess-1', 1700000001000, { + role: 'assistant', + modelID: 'claude-opus-4-6', + cost: 0.05, + tokens: { input: 10, output: 20, reasoning: 5, cache: { read: 50, write: 30 } }, + usage: { input_tokens: 999, output_tokens: 888, cache_creation_input_tokens: 777, cache_read_input_tokens: 666 }, + }) + }) + + const calls = await collectCalls(createOpenCodeProvider(tmpDir), dbPath, 'sess-1') + expect(calls).toEqual([ + { + "bashCommands": [], + "cacheCreationInputTokens": 30, + "cacheReadInputTokens": 50, + "cachedInputTokens": 50, + "costBasis": "estimated", + "costUSD": 0.0008874999999999999, + "deduplicationKey": "opencode:sess-1:msg-2", + "fallbackCostUSD": 0.05, + "inputTokens": 10, + "model": "claude-opus-4-6", + "outputTokens": 20, + "provider": "opencode", + "reasoningTokens": 5, + "sessionId": "sess-1", + "skills": [], + "speed": "standard", + "subagentTypes": [], + "timestamp": "2023-11-14T22:13:21.000Z", + "tools": [], + "userMessage": "usage test", + "webSearchRequests": 0, + }, +]) + }) + + it('fills null/undefined tokens from usage.*', async () => { + const dbPath = createTestDb(tmpDir) + withTestDb(dbPath, (db) => { + insertSession(db, 'sess-1') + + insertMessage(db, 'msg-1', 'sess-1', 1700000000000, { role: 'user' }) + insertPart(db, 'part-1', 'msg-1', 'sess-1', { type: 'text', text: 'fallback test' }) + + insertMessage(db, 'msg-2', 'sess-1', 1700000001000, { + role: 'assistant', + modelID: 'claude-opus-4-6', + cost: 0.05, + usage: { input_tokens: 42, output_tokens: 7, cache_creation_input_tokens: 3, cache_read_input_tokens: 9 }, + }) + }) + + const calls = await collectCalls(createOpenCodeProvider(tmpDir), dbPath, 'sess-1') + expect(calls).toEqual([ + { + "bashCommands": [], + "cacheCreationInputTokens": 3, + "cacheReadInputTokens": 9, + "cachedInputTokens": 9, + "costBasis": "estimated", + "costUSD": 0.00040825000000000003, + "deduplicationKey": "opencode:sess-1:msg-2", + "fallbackCostUSD": 0.05, + "inputTokens": 42, + "model": "claude-opus-4-6", + "outputTokens": 7, + "provider": "opencode", + "reasoningTokens": 0, + "sessionId": "sess-1", + "skills": [], + "speed": "standard", + "subagentTypes": [], + "timestamp": "2023-11-14T22:13:21.000Z", + "tools": [], + "userMessage": "fallback test", + "webSearchRequests": 0, + }, +]) + }) +}) + +skipUnlessSqlite('shared builder H4 — part type counting', () => { + it('counts tool/tool-call/tool_call as tools and tool-result/tool_result as activity', async () => { + const dbPath = createTestDb(tmpDir) + withTestDb(dbPath, (db) => { + insertSession(db, 'sess-1') + + insertMessage(db, 'msg-1', 'sess-1', 1700000000000, { role: 'user' }) + insertPart(db, 'part-1', 'msg-1', 'sess-1', { type: 'text', text: 'parts' }) + + insertMessage(db, 'msg-2', 'sess-1', 1700000001000, { + role: 'assistant', + modelID: 'claude-opus-4-6', + cost: 0.05, + tokens: { input: 10, output: 10, reasoning: 0, cache: { read: 0, write: 0 } }, + }) + insertPart(db, 'part-2a', 'msg-2', 'sess-1', { type: 'tool', tool: 'bash', state: { status: 'completed', input: { command: 'ls' } } }) + insertPart(db, 'part-2b', 'msg-2', 'sess-1', { type: 'tool-call', tool: 'read', state: { status: 'completed', input: {} } }) + insertPart(db, 'part-2c', 'msg-2', 'sess-1', { type: 'tool_call', tool: 'grep', state: { status: 'completed', input: {} } }) + insertPart(db, 'part-2d', 'msg-2', 'sess-1', { type: 'tool-result', tool: 'bash', state: { status: 'completed', input: {} } }) + insertPart(db, 'part-2e', 'msg-2', 'sess-1', { type: 'tool_result', tool: 'bash', state: { status: 'completed', input: {} } }) + }) + + const calls = await collectCalls(createOpenCodeProvider(tmpDir), dbPath, 'sess-1') + expect(calls).toEqual([ + { + "bashCommands": [ + "ls", + ], + "cacheCreationInputTokens": 0, + "cacheReadInputTokens": 0, + "cachedInputTokens": 0, + "costBasis": "estimated", + "costUSD": 0.00030000000000000003, + "deduplicationKey": "opencode:sess-1:msg-2", + "fallbackCostUSD": 0.05, + "inputTokens": 10, + "model": "claude-opus-4-6", + "outputTokens": 10, + "provider": "opencode", + "reasoningTokens": 0, + "sessionId": "sess-1", + "skills": [], + "speed": "standard", + "subagentTypes": [], + "timestamp": "2023-11-14T22:13:21.000Z", + "tools": [ + "Bash", + "Read", + "Grep", + ], + "userMessage": "parts", + "webSearchRequests": 0, + }, +]) + }) +}) + +skipUnlessSqlite('shared builder H5 — normalizeToolName', () => { + it('pins builtin map, mcp__ passthrough, server_tool conversion, and underscore edge cases', async () => { + const dbPath = createTestDb(tmpDir) + withTestDb(dbPath, (db) => { + insertSession(db, 'sess-1') + + insertMessage(db, 'msg-1', 'sess-1', 1700000000000, { role: 'user' }) + insertPart(db, 'part-1', 'msg-1', 'sess-1', { type: 'text', text: 'tools' }) + + insertMessage(db, 'msg-2', 'sess-1', 1700000001000, { + role: 'assistant', + modelID: 'claude-opus-4-6', + cost: 0.05, + tokens: { input: 10, output: 10, reasoning: 0, cache: { read: 0, write: 0 } }, + }) + insertPart(db, 'p-bash', 'msg-2', 'sess-1', { type: 'tool', tool: 'bash', state: { status: 'completed', input: {} } }) + insertPart(db, 'p-mcp', 'msg-2', 'sess-1', { type: 'tool', tool: 'mcp__x__y', state: { status: 'completed', input: {} } }) + insertPart(db, 'p-srv', 'msg-2', 'sess-1', { type: 'tool', tool: 'server_tool', state: { status: 'completed', input: {} } }) + insertPart(db, 'p-leading', 'msg-2', 'sess-1', { type: 'tool', tool: '_leading', state: { status: 'completed', input: {} } }) + insertPart(db, 'p-trailing', 'msg-2', 'sess-1', { type: 'tool', tool: 'trailing_', state: { status: 'completed', input: {} } }) + }) + + const calls = await collectCalls(createOpenCodeProvider(tmpDir), dbPath, 'sess-1') + expect(calls).toEqual([ + { + "bashCommands": [], + "cacheCreationInputTokens": 0, + "cacheReadInputTokens": 0, + "cachedInputTokens": 0, + "costBasis": "estimated", + "costUSD": 0.00030000000000000003, + "deduplicationKey": "opencode:sess-1:msg-2", + "fallbackCostUSD": 0.05, + "inputTokens": 10, + "model": "claude-opus-4-6", + "outputTokens": 10, + "provider": "opencode", + "reasoningTokens": 0, + "sessionId": "sess-1", + "skills": [], + "speed": "standard", + "subagentTypes": [], + "timestamp": "2023-11-14T22:13:21.000Z", + "tools": [ + "Bash", + "_leading", + "mcp__x__y", + "mcp__server__tool", + "trailing_", + ], + "userMessage": "tools", + "webSearchRequests": 0, + }, +]) + }) +}) + +skipUnlessSqlite('shared builder H6-H8 — bash, skill, task extraction', () => { + it('extracts bashCommands, skills, and subagentTypes', async () => { + const dbPath = createTestDb(tmpDir) + withTestDb(dbPath, (db) => { + insertSession(db, 'sess-1') + + insertMessage(db, 'msg-1', 'sess-1', 1700000000000, { role: 'user' }) + insertPart(db, 'part-1', 'msg-1', 'sess-1', { type: 'text', text: 'do things' }) + + insertMessage(db, 'msg-2', 'sess-1', 1700000001000, { + role: 'assistant', + modelID: 'claude-opus-4-6', + cost: 0.05, + tokens: { input: 10, output: 10, reasoning: 0, cache: { read: 0, write: 0 } }, + }) + insertPart(db, 'p-bash', 'msg-2', 'sess-1', { type: 'tool', tool: 'bash', state: { status: 'completed', input: { command: 'npm test && git push' } } }) + insertPart(db, 'p-skill', 'msg-2', 'sess-1', { type: 'tool', tool: 'skill', state: { status: 'completed', input: { name: 'commit' } } }) + insertPart(db, 'p-task', 'msg-2', 'sess-1', { type: 'tool', tool: 'task', state: { status: 'completed', input: { subagent_type: 'general-purpose' } } }) + }) + + const calls = await collectCalls(createOpenCodeProvider(tmpDir), dbPath, 'sess-1') + expect(calls).toEqual([ + { + "bashCommands": [ + "npm", + "git", + ], + "cacheCreationInputTokens": 0, + "cacheReadInputTokens": 0, + "cachedInputTokens": 0, + "costBasis": "estimated", + "costUSD": 0.00030000000000000003, + "deduplicationKey": "opencode:sess-1:msg-2", + "fallbackCostUSD": 0.05, + "inputTokens": 10, + "model": "claude-opus-4-6", + "outputTokens": 10, + "provider": "opencode", + "reasoningTokens": 0, + "sessionId": "sess-1", + "skills": [ + "commit", + ], + "speed": "standard", + "subagentTypes": [ + "general-purpose", + ], + "timestamp": "2023-11-14T22:13:21.000Z", + "tools": [ + "Bash", + "Skill", + "Agent", + ], + "userMessage": "do things", + "webSearchRequests": 0, + }, +]) + }) +}) + +skipUnlessSqlite('shared builder H10-H14 — skip semantics', () => { + it('H10 skips all-zero tokens with falsy cost and no substantive parts', async () => { + const dbPath = createTestDb(tmpDir) + withTestDb(dbPath, (db) => { + insertSession(db, 'sess-1') + insertMessage(db, 'msg-2', 'sess-1', 1700000001000, { + role: 'assistant', + modelID: 'claude-opus-4-6', + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + }) + }) + + const calls = await collectCalls(createOpenCodeProvider(tmpDir), dbPath, 'sess-1') + expect(calls).toEqual([]) + }) + + it('H11 yields zero-token assistant with non-empty text', async () => { + const dbPath = createTestDb(tmpDir) + withTestDb(dbPath, (db) => { + insertSession(db, 'sess-1') + insertMessage(db, 'msg-1', 'sess-1', 1700000000000, { role: 'user' }) + insertPart(db, 'part-1', 'msg-1', 'sess-1', { type: 'text', text: 'prompt' }) + insertMessage(db, 'msg-2', 'sess-1', 1700000001000, { + role: 'assistant', + modelID: 'claude-opus-4-6', + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + }) + insertPart(db, 'part-2', 'msg-2', 'sess-1', { type: 'text', text: 'I will help' }) + }) + + const calls = await collectCalls(createOpenCodeProvider(tmpDir), dbPath, 'sess-1') + expect(calls).toEqual([ + { + "bashCommands": [], + "cacheCreationInputTokens": 0, + "cacheReadInputTokens": 0, + "cachedInputTokens": 0, + "costBasis": "estimated", + "costUSD": 0, + "deduplicationKey": "opencode:sess-1:msg-2", + "inputTokens": 0, + "model": "claude-opus-4-6", + "outputTokens": 0, + "provider": "opencode", + "reasoningTokens": 0, + "sessionId": "sess-1", + "skills": [], + "speed": "standard", + "subagentTypes": [], + "timestamp": "2023-11-14T22:13:21.000Z", + "tools": [], + "userMessage": "prompt", + "webSearchRequests": 0, + }, +]) + }) + + it('H12 yields zero-token assistant with a tool-call part', async () => { + const dbPath = createTestDb(tmpDir) + withTestDb(dbPath, (db) => { + insertSession(db, 'sess-1') + insertMessage(db, 'msg-1', 'sess-1', 1700000000000, { role: 'user' }) + insertPart(db, 'part-1', 'msg-1', 'sess-1', { type: 'text', text: 'prompt' }) + insertMessage(db, 'msg-2', 'sess-1', 1700000001000, { + role: 'assistant', + modelID: 'claude-opus-4-6', + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + }) + insertPart(db, 'part-2', 'msg-2', 'sess-1', { type: 'tool', tool: 'bash', state: { status: 'completed', input: { command: 'ls' } } }) + }) + + const calls = await collectCalls(createOpenCodeProvider(tmpDir), dbPath, 'sess-1') + expect(calls).toEqual([ + { + "bashCommands": [ + "ls", + ], + "cacheCreationInputTokens": 0, + "cacheReadInputTokens": 0, + "cachedInputTokens": 0, + "costBasis": "estimated", + "costUSD": 0, + "deduplicationKey": "opencode:sess-1:msg-2", + "inputTokens": 0, + "model": "claude-opus-4-6", + "outputTokens": 0, + "provider": "opencode", + "reasoningTokens": 0, + "sessionId": "sess-1", + "skills": [], + "speed": "standard", + "subagentTypes": [], + "timestamp": "2023-11-14T22:13:21.000Z", + "tools": [ + "Bash", + ], + "userMessage": "prompt", + "webSearchRequests": 0, + }, +]) + }) + + it('H13 yields zero-token assistant with reasoning or file part', async () => { + const dbPath = createTestDb(tmpDir) + withTestDb(dbPath, (db) => { + insertSession(db, 'sess-1') + insertMessage(db, 'msg-1', 'sess-1', 1700000000000, { role: 'user' }) + insertPart(db, 'part-1', 'msg-1', 'sess-1', { type: 'text', text: 'prompt' }) + insertMessage(db, 'msg-2', 'sess-1', 1700000001000, { + role: 'assistant', + modelID: 'claude-opus-4-6', + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + }) + insertPart(db, 'part-2', 'msg-2', 'sess-1', { type: 'reasoning' }) + insertMessage(db, 'msg-3', 'sess-1', 1700000002000, { + role: 'assistant', + modelID: 'claude-opus-4-6', + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + }) + insertPart(db, 'part-3', 'msg-3', 'sess-1', { type: 'file' }) + }) + + const calls = await collectCalls(createOpenCodeProvider(tmpDir), dbPath, 'sess-1') + expect(calls).toEqual([ + { + "bashCommands": [], + "cacheCreationInputTokens": 0, + "cacheReadInputTokens": 0, + "cachedInputTokens": 0, + "costBasis": "estimated", + "costUSD": 0, + "deduplicationKey": "opencode:sess-1:msg-2", + "inputTokens": 0, + "model": "claude-opus-4-6", + "outputTokens": 0, + "provider": "opencode", + "reasoningTokens": 0, + "sessionId": "sess-1", + "skills": [], + "speed": "standard", + "subagentTypes": [], + "timestamp": "2023-11-14T22:13:21.000Z", + "tools": [], + "userMessage": "prompt", + "webSearchRequests": 0, + }, + { + "bashCommands": [], + "cacheCreationInputTokens": 0, + "cacheReadInputTokens": 0, + "cachedInputTokens": 0, + "costBasis": "estimated", + "costUSD": 0, + "deduplicationKey": "opencode:sess-1:msg-3", + "inputTokens": 0, + "model": "claude-opus-4-6", + "outputTokens": 0, + "provider": "opencode", + "reasoningTokens": 0, + "sessionId": "sess-1", + "skills": [], + "speed": "standard", + "subagentTypes": [], + "timestamp": "2023-11-14T22:13:22.000Z", + "tools": [], + "userMessage": "prompt", + "webSearchRequests": 0, + }, +]) + }) + + it('H14 yields all-zero tokens when cost > 0', async () => { + const dbPath = createTestDb(tmpDir) + withTestDb(dbPath, (db) => { + insertSession(db, 'sess-1') + insertMessage(db, 'msg-2', 'sess-1', 1700000001000, { + role: 'assistant', + modelID: 'claude-opus-4-6', + cost: 0.01, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + }) + }) + + const calls = await collectCalls(createOpenCodeProvider(tmpDir), dbPath, 'sess-1') + expect(calls).toEqual([ + { + "bashCommands": [], + "cacheCreationInputTokens": 0, + "cacheReadInputTokens": 0, + "cachedInputTokens": 0, + "costBasis": "estimated", + "costUSD": 0.01, + "deduplicationKey": "opencode:sess-1:msg-2", + "fallbackCostUSD": 0.01, + "inputTokens": 0, + "model": "claude-opus-4-6", + "outputTokens": 0, + "provider": "opencode", + "reasoningTokens": 0, + "sessionId": "sess-1", + "skills": [], + "speed": "standard", + "subagentTypes": [], + "timestamp": "2023-11-14T22:13:21.000Z", + "tools": [], + "userMessage": "", + "webSearchRequests": 0, + }, +]) + }) +}) + +skipUnlessSqlite('shared builder H15 — fallbackCostUSD guard', () => { + it('pins fallbackCostUSD presence for cost 0 and absence for undefined cost', async () => { + const dbPath = createTestDb(tmpDir) + withTestDb(dbPath, (db) => { + insertSession(db, 'sess-1') + + insertMessage(db, 'msg-1', 'sess-1', 1700000000000, { role: 'user' }) + insertPart(db, 'part-1', 'msg-1', 'sess-1', { type: 'text', text: 'costs' }) + + insertMessage(db, 'msg-2', 'sess-1', 1700000001000, { + role: 'assistant', + modelID: 'claude-opus-4-6', + cost: 0, + tokens: { input: 10, output: 10, reasoning: 0, cache: { read: 0, write: 0 } }, + }) + insertMessage(db, 'msg-3', 'sess-1', 1700000002000, { + role: 'assistant', + modelID: 'claude-opus-4-6', + tokens: { input: 10, output: 10, reasoning: 0, cache: { read: 0, write: 0 } }, + }) + }) + + const calls = await collectCalls(createOpenCodeProvider(tmpDir), dbPath, 'sess-1') + expect(calls).toEqual([ + { + "bashCommands": [], + "cacheCreationInputTokens": 0, + "cacheReadInputTokens": 0, + "cachedInputTokens": 0, + "costBasis": "estimated", + "costUSD": 0.00030000000000000003, + "deduplicationKey": "opencode:sess-1:msg-2", + "fallbackCostUSD": 0, + "inputTokens": 10, + "model": "claude-opus-4-6", + "outputTokens": 10, + "provider": "opencode", + "reasoningTokens": 0, + "sessionId": "sess-1", + "skills": [], + "speed": "standard", + "subagentTypes": [], + "timestamp": "2023-11-14T22:13:21.000Z", + "tools": [], + "userMessage": "costs", + "webSearchRequests": 0, + }, + { + "bashCommands": [], + "cacheCreationInputTokens": 0, + "cacheReadInputTokens": 0, + "cachedInputTokens": 0, + "costBasis": "estimated", + "costUSD": 0.00030000000000000003, + "deduplicationKey": "opencode:sess-1:msg-3", + "inputTokens": 10, + "model": "claude-opus-4-6", + "outputTokens": 10, + "provider": "opencode", + "reasoningTokens": 0, + "sessionId": "sess-1", + "skills": [], + "speed": "standard", + "subagentTypes": [], + "timestamp": "2023-11-14T22:13:22.000Z", + "tools": [], + "userMessage": "costs", + "webSearchRequests": 0, + }, +]) + // Key-presence gate for H15: `cost: 0` must PRODUCE the key (the guard is + // `typeof data.cost === 'number'`, not a truthy check), and an absent cost + // must OMIT it — not set it to undefined, which `toEqual` would accept. + expect(Object.keys(calls[0]!)).toContain('fallbackCostUSD') + expect(Object.keys(calls[1]!)).not.toContain('fallbackCostUSD') + }) +}) + +skipUnlessSqlite('shared builder H16 — model precedence', () => { + it('pins modelID -> model -> unknown precedence', async () => { + const dbPath = createTestDb(tmpDir) + withTestDb(dbPath, (db) => { + insertSession(db, 'sess-1') + insertMessage(db, 'msg-1', 'sess-1', 1700000000000, { role: 'user' }) + insertPart(db, 'part-1', 'msg-1', 'sess-1', { type: 'text', text: 'model' }) + + insertMessage(db, 'msg-2', 'sess-1', 1700000001000, { + role: 'assistant', + model: 'from-model-field', + tokens: { input: 10, output: 10, reasoning: 0, cache: { read: 0, write: 0 } }, + }) + insertMessage(db, 'msg-3', 'sess-1', 1700000002000, { + role: 'assistant', + tokens: { input: 10, output: 10, reasoning: 0, cache: { read: 0, write: 0 } }, + }) + }) + + const calls = await collectCalls(createOpenCodeProvider(tmpDir), dbPath, 'sess-1') + expect(calls).toEqual([ + { + "bashCommands": [], + "cacheCreationInputTokens": 0, + "cacheReadInputTokens": 0, + "cachedInputTokens": 0, + "costBasis": "estimated", + "costUSD": 0, + "deduplicationKey": "opencode:sess-1:msg-2", + "inputTokens": 10, + "model": "from-model-field", + "outputTokens": 10, + "provider": "opencode", + "reasoningTokens": 0, + "sessionId": "sess-1", + "skills": [], + "speed": "standard", + "subagentTypes": [], + "timestamp": "2023-11-14T22:13:21.000Z", + "tools": [], + "userMessage": "model", + "webSearchRequests": 0, + }, + { + "bashCommands": [], + "cacheCreationInputTokens": 0, + "cacheReadInputTokens": 0, + "cachedInputTokens": 0, + "costBasis": "estimated", + "costUSD": 0, + "deduplicationKey": "opencode:sess-1:msg-3", + "inputTokens": 10, + "model": "unknown", + "outputTokens": 10, + "provider": "opencode", + "reasoningTokens": 0, + "sessionId": "sess-1", + "skills": [], + "speed": "standard", + "subagentTypes": [], + "timestamp": "2023-11-14T22:13:22.000Z", + "tools": [], + "userMessage": "model", + "webSearchRequests": 0, + }, +]) + }) +}) + +skipUnlessSqlite('shared builder H17 — parseTimestamp', () => { + it('pins <1e12 seconds and >=1e12 ms handling', async () => { + const dbPath = createTestDb(tmpDir) + withTestDb(dbPath, (db) => { + insertSession(db, 'sess-1') + insertMessage(db, 'msg-1', 'sess-1', 1700000000, { role: 'user' }) + insertPart(db, 'part-1', 'msg-1', 'sess-1', { type: 'text', text: 'ts' }) + insertMessage(db, 'msg-2', 'sess-1', 1700000001000, { + role: 'assistant', + modelID: 'claude-opus-4-6', + tokens: { input: 10, output: 10, reasoning: 0, cache: { read: 0, write: 0 } }, + }) + }) + + const calls = await collectCalls(createOpenCodeProvider(tmpDir), dbPath, 'sess-1') + expect(calls).toEqual([ + { + "bashCommands": [], + "cacheCreationInputTokens": 0, + "cacheReadInputTokens": 0, + "cachedInputTokens": 0, + "costBasis": "estimated", + "costUSD": 0.00030000000000000003, + "deduplicationKey": "opencode:sess-1:msg-2", + "inputTokens": 10, + "model": "claude-opus-4-6", + "outputTokens": 10, + "provider": "opencode", + "reasoningTokens": 0, + "sessionId": "sess-1", + "skills": [], + "speed": "standard", + "subagentTypes": [], + "timestamp": "2023-11-14T22:13:21.000Z", + "tools": [], + "userMessage": "ts", + "webSearchRequests": 0, + }, +]) + }) +}) + +// ═════════════════════════════════════════════════════════════════════════════ +// SQLite-only arms (S1-S12) +// ═════════════════════════════════════════════════════════════════════════════ + +skipUnlessSqlite('SQLite arm S1-S2 — session_tree CTE and archived exclusion', () => { + it('pins child and grandchild calls attributed to root, excluding archived', async () => { + const dbPath = createTestDb(tmpDir) + withTestDb(dbPath, (db) => { + insertSession(db, 'root', { timeCreated: 1700000000000 }) + insertSession(db, 'child', { parentId: 'root', timeCreated: 1700000000100 }) + insertSession(db, 'grandchild', { parentId: 'child', timeCreated: 1700000000200 }) + insertSession(db, 'archived', { parentId: 'root', archived: 1700000000300, timeCreated: 1700000000300 }) + + // user in root + insertMessage(db, 'm-root-user', 'root', 1700000000001, { role: 'user' }) + insertPart(db, 'p-root-user', 'm-root-user', 'root', { type: 'text', text: 'root prompt' }) + + // assistant in child + insertMessage(db, 'm-child', 'child', 1700000000101, { + role: 'assistant', + modelID: 'claude-opus-4-6', + cost: 0.05, + tokens: { input: 10, output: 10, reasoning: 0, cache: { read: 0, write: 0 } }, + }) + + // assistant in grandchild + insertMessage(db, 'm-grandchild', 'grandchild', 1700000000201, { + role: 'assistant', + modelID: 'claude-opus-4-6', + cost: 0.05, + tokens: { input: 20, output: 20, reasoning: 0, cache: { read: 0, write: 0 } }, + }) + + // assistant in archived (should not appear) + insertMessage(db, 'm-archived', 'archived', 1700000000301, { + role: 'assistant', + modelID: 'claude-opus-4-6', + cost: 0.05, + tokens: { input: 99, output: 99, reasoning: 0, cache: { read: 0, write: 0 } }, + }) + }) + + const calls = await collectCalls(createOpenCodeProvider(tmpDir), dbPath, 'root') + expect(calls).toEqual([ + { + "bashCommands": [], + "cacheCreationInputTokens": 0, + "cacheReadInputTokens": 0, + "cachedInputTokens": 0, + "costBasis": "estimated", + "costUSD": 0.00030000000000000003, + "deduplicationKey": "opencode:child:m-child", + "fallbackCostUSD": 0.05, + "inputTokens": 10, + "model": "claude-opus-4-6", + "outputTokens": 10, + "provider": "opencode", + "reasoningTokens": 0, + "sessionId": "root", + "skills": [], + "speed": "standard", + "subagentTypes": [], + "timestamp": "2023-11-14T22:13:20.101Z", + "tools": [], + "userMessage": "", + "webSearchRequests": 0, + }, + { + "bashCommands": [], + "cacheCreationInputTokens": 0, + "cacheReadInputTokens": 0, + "cachedInputTokens": 0, + "costBasis": "estimated", + "costUSD": 0.0006000000000000001, + "deduplicationKey": "opencode:grandchild:m-grandchild", + "fallbackCostUSD": 0.05, + "inputTokens": 20, + "model": "claude-opus-4-6", + "outputTokens": 20, + "provider": "opencode", + "reasoningTokens": 0, + "sessionId": "root", + "skills": [], + "speed": "standard", + "subagentTypes": [], + "timestamp": "2023-11-14T22:13:20.201Z", + "tools": [], + "userMessage": "", + "webSearchRequests": 0, + }, +]) + // Key-presence gate for a SQLite message-arm call: skills/subagentTypes are + // emitted unconditionally (session-message.ts:147-148), never as undefined. + for (const call of calls) { + const keys = Object.keys(call) + expect(keys).toContain('skills') + expect(keys).toContain('subagentTypes') + expect(keys).toContain('bashCommands') + expect(keys).toContain('fallbackCostUSD') + expect(keys).not.toContain('project') + expect(keys).not.toContain('projectPath') + } + }) +}) + +skipUnlessSqlite('SQLite arm S3-S4 — corrupt JSON handling', () => { + it('skips corrupt message data and corrupt part data, keeping siblings', async () => { + const dbPath = createTestDb(tmpDir) + withTestDb(dbPath, (db) => { + insertSession(db, 'sess-1') + + insertMessage(db, 'msg-1', 'sess-1', 1700000000000, { role: 'user' }) + insertPart(db, 'part-1', 'msg-1', 'sess-1', { type: 'text', text: 'prompt' }) + + // corrupt message JSON + db.prepare(`INSERT INTO message (id, session_id, time_created, data) VALUES (?, ?, ?, ?)`) + .run('msg-bad', 'sess-1', 1700000000500, 'not-json') + + // assistant with one corrupt part and one good part + insertMessage(db, 'msg-2', 'sess-1', 1700000001000, { + role: 'assistant', + modelID: 'claude-opus-4-6', + cost: 0.05, + tokens: { input: 10, output: 10, reasoning: 0, cache: { read: 0, write: 0 } }, + }) + db.prepare(`INSERT INTO part (id, message_id, session_id, data) VALUES (?, ?, ?, ?)`) + .run('part-bad', 'msg-2', 'sess-1', 'not-json') + insertPart(db, 'part-good', 'msg-2', 'sess-1', { type: 'tool', tool: 'read', state: { status: 'completed', input: {} } }) + }) + + const calls = await collectCalls(createOpenCodeProvider(tmpDir), dbPath, 'sess-1') + expect(calls).toEqual([ + { + "bashCommands": [], + "cacheCreationInputTokens": 0, + "cacheReadInputTokens": 0, + "cachedInputTokens": 0, + "costBasis": "estimated", + "costUSD": 0.00030000000000000003, + "deduplicationKey": "opencode:sess-1:msg-2", + "fallbackCostUSD": 0.05, + "inputTokens": 10, + "model": "claude-opus-4-6", + "outputTokens": 10, + "provider": "opencode", + "reasoningTokens": 0, + "sessionId": "sess-1", + "skills": [], + "speed": "standard", + "subagentTypes": [], + "timestamp": "2023-11-14T22:13:21.000Z", + "tools": [ + "Read", + ], + "userMessage": "prompt", + "webSearchRequests": 0, + }, +]) + }) +}) + +skipUnlessSqlite('SQLite arm S5-S7 — role handling', () => { + it('pins user message accumulation, model alias, and role skip counting', async () => { + const dbPath = createTestDb(tmpDir) + withTestDb(dbPath, (db) => { + insertSession(db, 'sess-1') + + // user with two text parts + insertMessage(db, 'msg-user', 'sess-1', 1700000000000, { role: 'user' }) + insertPart(db, 'p-user-1', 'msg-user', 'sess-1', { type: 'text', text: 'first' }) + insertPart(db, 'p-user-2', 'msg-user', 'sess-1', { type: 'text', text: 'second' }) + + // system role skipped + insertMessage(db, 'msg-system', 'sess-1', 1700000000200, { role: 'system' }) + + // model role accepted as assistant + insertMessage(db, 'msg-model', 'sess-1', 1700000001000, { + role: 'model', + modelID: 'claude-opus-4-6', + cost: 0.05, + tokens: { input: 10, output: 10, reasoning: 0, cache: { read: 0, write: 0 } }, + }) + }) + + const calls = await collectCalls(createOpenCodeProvider(tmpDir), dbPath, 'sess-1') + expect(calls).toEqual([ + { + "bashCommands": [], + "cacheCreationInputTokens": 0, + "cacheReadInputTokens": 0, + "cachedInputTokens": 0, + "costBasis": "estimated", + "costUSD": 0.00030000000000000003, + "deduplicationKey": "opencode:sess-1:msg-model", + "fallbackCostUSD": 0.05, + "inputTokens": 10, + "model": "claude-opus-4-6", + "outputTokens": 10, + "provider": "opencode", + "reasoningTokens": 0, + "sessionId": "sess-1", + "skills": [], + "speed": "standard", + "subagentTypes": [], + "timestamp": "2023-11-14T22:13:21.000Z", + "tools": [], + "userMessage": "first second", + "webSearchRequests": 0, + }, +]) + }) +}) + +skipUnlessSqlite('SQLite arm S8 — dedup adds after successful build', () => { + it('does not add dedup key when buildAssistantCall returns null', async () => { + const dbPath = createTestDb(tmpDir) + withTestDb(dbPath, (db) => { + insertSession(db, 'sess-1') + // first message skipped (no tokens, no cost, no activity) + insertMessage(db, 'msg-skip', 'sess-1', 1700000000000, { + role: 'assistant', + modelID: 'claude-opus-4-6', + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + }) + // a second, independently-keyed message still yields + insertMessage(db, 'msg-2', 'sess-1', 1700000001000, { + role: 'assistant', + modelID: 'claude-opus-4-6', + cost: 0.05, + tokens: { input: 10, output: 10, reasoning: 0, cache: { read: 0, write: 0 } }, + }) + }) + + const calls = await collectCalls(createOpenCodeProvider(tmpDir), dbPath, 'sess-1') + expect(calls).toEqual([ + { + "bashCommands": [], + "cacheCreationInputTokens": 0, + "cacheReadInputTokens": 0, + "cachedInputTokens": 0, + "costBasis": "estimated", + "costUSD": 0.00030000000000000003, + "deduplicationKey": "opencode:sess-1:msg-2", + "fallbackCostUSD": 0.05, + "inputTokens": 10, + "model": "claude-opus-4-6", + "outputTokens": 10, + "provider": "opencode", + "reasoningTokens": 0, + "sessionId": "sess-1", + "skills": [], + "speed": "standard", + "subagentTypes": [], + "timestamp": "2023-11-14T22:13:21.000Z", + "tools": [], + "userMessage": "", + "webSearchRequests": 0, + }, +]) + }) + + // The arm above is not discriminating on its own: `msg-skip` and `msg-2` have + // DIFFERENT dedup keys, so hoisting `seenKeys.add` above the build would not + // change its output. This one pins the actual semantic — a null build must + // leave its key UNCLAIMED, the opposite of the vscode-cline arm, which burns + // the key before deciding to skip. + it('leaves the dedup key unclaimed when the build returns null, so a later run can yield it', async () => { + const dbPath = createTestDb(tmpDir) + withTestDb(dbPath, (db) => { + insertSession(db, 'sess-1') + insertMessage(db, 'msg-1', 'sess-1', 1700000000000, { + role: 'assistant', + modelID: 'claude-opus-4-6', + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + }) + }) + + const provider = createOpenCodeProvider(tmpDir) + const seen = new Set() + expect(await collectCalls(provider, dbPath, 'sess-1', seen)).toEqual([]) + expect(seen.has('opencode:sess-1:msg-1')).toBe(false) + + // The same message now carries tokens. Because the skipped build never + // claimed the key, the second pass over the shared seenKeys set yields it. + withTestDb(dbPath, (db) => { + db.prepare(`UPDATE message SET data = ? WHERE id = ?`).run( + JSON.stringify({ + role: 'assistant', + modelID: 'claude-opus-4-6', + cost: 0.05, + tokens: { input: 10, output: 10, reasoning: 0, cache: { read: 0, write: 0 } }, + }), + 'msg-1', + ) + }) + + const second = await collectCalls(provider, dbPath, 'sess-1', seen) + expect(second).toHaveLength(1) + expect(second[0]!.deduplicationKey).toBe('opencode:sess-1:msg-1') + expect(seen.has('opencode:sess-1:msg-1')).toBe(true) + }) +}) + +skipUnlessSqlite('SQLite arm S9-S12 — session-level fallback', () => { + it('S9 emits session-level fallback when zero calls but session row has tokens/cost', async () => { + const dbPath = createTestDb(tmpDir) + withTestDb(dbPath, (db) => { + 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') + }) + + const calls = await collectCalls(createOpenCodeProvider(tmpDir), dbPath, 'sess-1') + expect(calls).toEqual([ + { + "bashCommands": [], + "cacheCreationInputTokens": 20, + "cacheReadInputTokens": 10, + "cachedInputTokens": 10, + "costBasis": "estimated", + "costUSD": 1, + "deduplicationKey": "opencode:sess-1:session-level", + "fallbackCostUSD": 1, + "inputTokens": 100, + "model": "session-model", + "outputTokens": 50, + "provider": "opencode", + "reasoningTokens": 5, + "sessionId": "sess-1", + "speed": "standard", + "timestamp": "2023-11-14T22:13:20.000Z", + "tools": [], + "userMessage": "", + "webSearchRequests": 0, + }, +]) + // Key-presence gate: pre-migration the session-level call emitted NO + // skills/subagentTypes keys (sqlite-session-parser.ts:234-253), unlike the + // message arm. `toEqual` would accept `skills: undefined` here, so assert + // absence directly. + 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') + }) + + it('S10/S11 omits fallback when session row is all zeros, omits fallbackCostUSD when cost is 0', async () => { + const dbPath = createTestDb(tmpDir) + withTestDb(dbPath, (db) => { + 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' }) + // all zeros + db.prepare(`UPDATE session SET cost=0, tokens_input=0, tokens_output=0, tokens_reasoning=0, tokens_cache_read=0, tokens_cache_write=0 WHERE id=?`) + .run('sess-1') + }) + + const calls = await collectCalls(createOpenCodeProvider(tmpDir), dbPath, 'sess-1') + expect(calls).toEqual([]) + }) + + it('S12 yields nothing for session with only user messages and no session rollup', async () => { + const dbPath = createTestDb(tmpDir) + withTestDb(dbPath, (db) => { + 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' }) + }) + + const calls = await collectCalls(createOpenCodeProvider(tmpDir), dbPath, 'sess-1') + expect(calls).toEqual([]) + }) +}) + +// ═════════════════════════════════════════════════════════════════════════════ +// File arm (F1-F9) +// ═════════════════════════════════════════════════════════════════════════════ + +describe('file arm F1-F9', () => { + it('F1-F3 pins message ordering, id fallback, and sorted parts', async () => { + await writeSession({ + messages: [ + { + id: 'msg_b', + data: { role: 'assistant', modelID: 'claude-opus-4-6', cost: 0.05, tokens: { input: 10, output: 10, reasoning: 0, cache: { read: 0, write: 0 } }, time: { created: 2 } }, + parts: [ + { type: 'text', text: 'b-first' }, + { type: 'text', text: 'b-second' }, + ], + }, + { + id: 'msg_a', + data: { role: 'assistant', modelID: 'claude-opus-4-6', cost: 0.05, tokens: { input: 20, output: 20, reasoning: 0, cache: { read: 0, write: 0 } }, time: { created: 2 } }, + parts: [{ type: 'text', text: 'a-wins-tie' }], + }, + { + id: 'msg_c', + data: { role: 'assistant', modelID: 'claude-opus-4-6', cost: 0.05, tokens: { input: 30, output: 30, reasoning: 0, cache: { read: 0, write: 0 } }, time: { created: 3 } }, + parts: [{ type: 'text', text: 'c-latest' }], + }, + ], + }) + + const calls = await collectFileCalls() + expect(calls).toEqual([ + { + "bashCommands": [], + "cacheCreationInputTokens": 0, + "cacheReadInputTokens": 0, + "cachedInputTokens": 0, + "costBasis": "estimated", + "costUSD": 0.0006000000000000001, + "deduplicationKey": "opencode:ses_test1:msg_a", + "fallbackCostUSD": 0.05, + "inputTokens": 20, + "model": "claude-opus-4-6", + "outputTokens": 20, + "provider": "opencode", + "reasoningTokens": 0, + "sessionId": "ses_test1", + "skills": [], + "speed": "standard", + "subagentTypes": [], + "timestamp": "1970-01-01T00:00:02.000Z", + "tools": [], + "userMessage": "", + "webSearchRequests": 0, + }, + { + "bashCommands": [], + "cacheCreationInputTokens": 0, + "cacheReadInputTokens": 0, + "cachedInputTokens": 0, + "costBasis": "estimated", + "costUSD": 0.00030000000000000003, + "deduplicationKey": "opencode:ses_test1:msg_b", + "fallbackCostUSD": 0.05, + "inputTokens": 10, + "model": "claude-opus-4-6", + "outputTokens": 10, + "provider": "opencode", + "reasoningTokens": 0, + "sessionId": "ses_test1", + "skills": [], + "speed": "standard", + "subagentTypes": [], + "timestamp": "1970-01-01T00:00:02.000Z", + "tools": [], + "userMessage": "", + "webSearchRequests": 0, + }, + { + "bashCommands": [], + "cacheCreationInputTokens": 0, + "cacheReadInputTokens": 0, + "cachedInputTokens": 0, + "costBasis": "estimated", + "costUSD": 0.0009, + "deduplicationKey": "opencode:ses_test1:msg_c", + "fallbackCostUSD": 0.05, + "inputTokens": 30, + "model": "claude-opus-4-6", + "outputTokens": 30, + "provider": "opencode", + "reasoningTokens": 0, + "sessionId": "ses_test1", + "skills": [], + "speed": "standard", + "subagentTypes": [], + "timestamp": "1970-01-01T00:00:03.000Z", + "tools": [], + "userMessage": "", + "webSearchRequests": 0, + }, +]) + // Key-presence gate for a file message-arm call. + for (const call of calls) { + const keys = Object.keys(call) + expect(keys).toContain('skills') + expect(keys).toContain('subagentTypes') + expect(keys).toContain('bashCommands') + expect(keys).toContain('fallbackCostUSD') + expect(keys).not.toContain('project') + expect(keys).not.toContain('projectPath') + } + }) + + it('F2 falls back message id to filename minus .json', async () => { + await writeSession({ + messages: [ + { + id: 'from_filename', + data: { role: 'assistant', modelID: 'claude-opus-4-6', cost: 0.05, tokens: { input: 10, output: 10, reasoning: 0, cache: { read: 0, write: 0 } }, time: { created: 1 } }, + parts: [{ type: 'text', text: 'fallback id' }], + }, + ], + }) + + const calls = await collectFileCalls() + expect(calls).toEqual([ + { + "bashCommands": [], + "cacheCreationInputTokens": 0, + "cacheReadInputTokens": 0, + "cachedInputTokens": 0, + "costBasis": "estimated", + "costUSD": 0.00030000000000000003, + "deduplicationKey": "opencode:ses_test1:from_filename", + "fallbackCostUSD": 0.05, + "inputTokens": 10, + "model": "claude-opus-4-6", + "outputTokens": 10, + "provider": "opencode", + "reasoningTokens": 0, + "sessionId": "ses_test1", + "skills": [], + "speed": "standard", + "subagentTypes": [], + "timestamp": "1970-01-01T00:00:01.000Z", + "tools": [], + "userMessage": "", + "webSearchRequests": 0, + }, +]) + }) + + it('F3 skips non-json part files and F4 skips unparseable part files', async () => { + await writeSession({ + messages: [ + { + id: 'msg_a', + data: { role: 'assistant', modelID: 'claude-opus-4-6', cost: 0.05, tokens: { input: 10, output: 10, reasoning: 0, cache: { read: 0, write: 0 } }, time: { created: 1 } }, + parts: [{ type: 'text', text: 'ok' }], + }, + ], + }) + + // Write extra non-json and corrupt json files directly into the part dir + const partDir = join(tmpDir, 'opencode', 'storage', 'part', 'msg_a') + await writeFile(join(partDir, 'ignored.txt'), 'not json') + await writeFile(join(partDir, 'corrupt.json'), 'not-json') + + const calls = await collectFileCalls() + expect(calls).toEqual([ + { + "bashCommands": [], + "cacheCreationInputTokens": 0, + "cacheReadInputTokens": 0, + "cachedInputTokens": 0, + "costBasis": "estimated", + "costUSD": 0.00030000000000000003, + "deduplicationKey": "opencode:ses_test1:msg_a", + "fallbackCostUSD": 0.05, + "inputTokens": 10, + "model": "claude-opus-4-6", + "outputTokens": 10, + "provider": "opencode", + "reasoningTokens": 0, + "sessionId": "ses_test1", + "skills": [], + "speed": "standard", + "subagentTypes": [], + "timestamp": "1970-01-01T00:00:01.000Z", + "tools": [], + "userMessage": "", + "webSearchRequests": 0, + }, +]) + }) + + it('F5 timeCreatedMs precedence: data.time.created -> meta.time.created -> 0', async () => { + await writeSession({ + messages: [ + { + id: 'msg_no_time', + data: { role: 'assistant', modelID: 'claude-opus-4-6', cost: 0.05, tokens: { input: 10, output: 10, reasoning: 0, cache: { read: 0, write: 0 } } }, + parts: [{ type: 'text', text: 'uses meta time' }], + }, + ], + }) + + const calls = await collectFileCalls() + expect(calls).toEqual([ + { + "bashCommands": [], + "cacheCreationInputTokens": 0, + "cacheReadInputTokens": 0, + "cachedInputTokens": 0, + "costBasis": "estimated", + "costUSD": 0.00030000000000000003, + "deduplicationKey": "opencode:ses_test1:msg_no_time", + "fallbackCostUSD": 0.05, + "inputTokens": 10, + "model": "claude-opus-4-6", + "outputTokens": 10, + "provider": "opencode", + "reasoningTokens": 0, + "sessionId": "ses_test1", + "skills": [], + "speed": "standard", + "subagentTypes": [], + "timestamp": "2026-06-19T16:25:56.809Z", + "tools": [], + "userMessage": "", + "webSearchRequests": 0, + }, +]) + }) + + it('F6 missing session meta id yields nothing', async () => { + const storage = join(tmpDir, 'opencode', 'storage') + const sessionDir = join(storage, 'session', 'global') + await mkdir(sessionDir, { recursive: true }) + await writeFile(join(sessionDir, 'bad.json'), JSON.stringify({ directory: '/x', title: 'y' })) + + const provider = createOpenCodeProvider(tmpDir) + const sources = await provider.discoverSessions() + expect(sources).toEqual([]) + expect(await collectFileCalls()).toEqual([]) + }) + + it('F7 missing message directory yields nothing', async () => { + await writeSession({ messages: [] }) + // remove the message directory + await rm(join(tmpDir, 'opencode', 'storage', 'message', 'ses_test1'), { recursive: true, force: true }) + + const calls = await collectFileCalls() + expect(calls).toEqual([]) + }) + + it('F8 currentUserMessage is only overwritten by non-empty joined text', async () => { + await writeSession({ + messages: [ + { + id: 'msg_user1', + data: { role: 'user', time: { created: 1 } }, + parts: [{ type: 'text', text: 'first prompt' }], + }, + { + id: 'msg_user2_empty', + data: { role: 'user', time: { created: 2 } }, + parts: [{ type: 'text', text: '' }], + }, + { + id: 'msg_assistant', + data: { role: 'assistant', modelID: 'claude-opus-4-6', cost: 0.05, tokens: { input: 10, output: 10, reasoning: 0, cache: { read: 0, write: 0 } }, time: { created: 3 } }, + parts: [{ type: 'text', text: 'reply' }], + }, + ], + }) + + const calls = await collectFileCalls() + expect(calls).toEqual([ + { + "bashCommands": [], + "cacheCreationInputTokens": 0, + "cacheReadInputTokens": 0, + "cachedInputTokens": 0, + "costBasis": "estimated", + "costUSD": 0.00030000000000000003, + "deduplicationKey": "opencode:ses_test1:msg_assistant", + "fallbackCostUSD": 0.05, + "inputTokens": 10, + "model": "claude-opus-4-6", + "outputTokens": 10, + "provider": "opencode", + "reasoningTokens": 0, + "sessionId": "ses_test1", + "skills": [], + "speed": "standard", + "subagentTypes": [], + "timestamp": "1970-01-01T00:00:03.000Z", + "tools": [], + "userMessage": "first prompt", + "webSearchRequests": 0, + }, +]) + }) + + // Two distinct message FILES that resolve to the same message id (`data.id` + // overrides the filename), so both produce the dedup key + // `opencode:ses_test1:msg_skip`. The earlier one builds to null. If + // `seenKeys.add` were hoisted above the build, the later one would be skipped + // and this would yield []. The original writes the id into both files but + // under distinct filenames — writing the same filename twice would silently + // overwrite and test nothing. + it('F9 dedup adds only after successful build', async () => { + await writeSession({ + messages: [ + { + id: 'file_a', + data: { id: 'msg_skip', role: 'assistant', modelID: 'claude-opus-4-6', tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, time: { created: 1 } }, + }, + { + id: 'file_b', + data: { id: 'msg_skip', role: 'assistant', modelID: 'claude-opus-4-6', cost: 0.05, tokens: { input: 10, output: 10, reasoning: 0, cache: { read: 0, write: 0 } }, time: { created: 2 } }, + }, + ], + }) + + const calls = await collectFileCalls() + expect(calls).toEqual([ + { + "bashCommands": [], + "cacheCreationInputTokens": 0, + "cacheReadInputTokens": 0, + "cachedInputTokens": 0, + "costBasis": "estimated", + "costUSD": 0.00030000000000000003, + "deduplicationKey": "opencode:ses_test1:msg_skip", + "fallbackCostUSD": 0.05, + "inputTokens": 10, + "model": "claude-opus-4-6", + "outputTokens": 10, + "provider": "opencode", + "reasoningTokens": 0, + "sessionId": "ses_test1", + "skills": [], + "speed": "standard", + "subagentTypes": [], + "timestamp": "1970-01-01T00:00:02.000Z", + "tools": [], + "userMessage": "", + "webSearchRequests": 0, + }, +]) + }) +}) + +// ═════════════════════════════════════════════════════════════════════════════ +// Cross-run dedup +// ═════════════════════════════════════════════════════════════════════════════ + +skipUnlessSqlite('cross-run dedup — SQLite', () => { + it('second parse with shared seenKeys yields empty', async () => { + const dbPath = createTestDb(tmpDir) + withTestDb(dbPath, (db) => { + insertSession(db, 'sess-1') + insertMessage(db, 'msg-1', 'sess-1', 1700000001000, { + role: 'assistant', + modelID: 'claude-opus-4-6', + cost: 0.05, + tokens: { input: 10, output: 10, reasoning: 0, cache: { read: 0, write: 0 } }, + }) + }) + + const provider = createOpenCodeProvider(tmpDir) + const seen = new Set() + const first = await collectCalls(provider, dbPath, 'sess-1', seen) + expect(first).toHaveLength(1) + const second = await collectCalls(provider, dbPath, 'sess-1', seen) + expect(second).toEqual([]) + }) +}) + +describe('cross-run dedup — file', () => { + it('second parse with shared seenKeys yields empty', async () => { + await writeSession({ + messages: [ + { + id: 'msg_a', + data: { role: 'assistant', modelID: 'claude-opus-4-6', cost: 0.05, tokens: { input: 10, output: 10, reasoning: 0, cache: { read: 0, write: 0 } }, time: { created: 1 } }, + parts: [{ type: 'text', text: 'hello' }], + }, + ], + }) + + const provider = createOpenCodeProvider(tmpDir) + const seen = new Set() + const sources = await provider.discoverSessions() + const first: ParsedProviderCall[] = [] + for (const source of sources) { + for await (const call of provider.createSessionParser(source, seen).parse()) first.push(priceProviderCall(call)) + } + expect(first).toHaveLength(1) + const second: ParsedProviderCall[] = [] + for (const source of sources) { + for await (const call of provider.createSessionParser(source, seen).parse()) second.push(priceProviderCall(call)) + } + expect(second).toEqual([]) + }) +}) + +// ═════════════════════════════════════════════════════════════════════════════ +// KiloCode SQLite arm (from-scratch fixture, §7) +// ═════════════════════════════════════════════════════════════════════════════ + +skipUnlessSqlite('kilo-code SQLite arm golden', () => { + it('pins the full-object output from an unmodified kilo-code SQLite session', async () => { + const dbPath = createKiloTestDb(tmpDir) + withTestDb(dbPath, (db) => { + insertSession(db, 'kilo-sess-1', { directory: '/home/user/kiloproject', title: 'Kilo Session' }) + + insertMessage(db, 'msg-user', 'kilo-sess-1', 1700000000000, { role: 'user' }) + insertPart(db, 'p-user', 'msg-user', 'kilo-sess-1', { type: 'text', text: 'kilocode prompt' }) + + insertMessage(db, 'msg-1', 'kilo-sess-1', 1700000001000, { + role: 'assistant', + modelID: 'claude-opus-4-6', + cost: 0.05, + tokens: { input: 100, output: 200, reasoning: 50, cache: { read: 500, write: 300 } }, + }) + insertPart(db, 'p-bash', 'msg-1', 'kilo-sess-1', { type: 'tool', tool: 'bash', state: { status: 'completed', input: { command: 'npm test' } } }) + }) + + const calls = await collectKiloCalls(dbPath, 'kilo-sess-1') + expect(calls).toEqual([ + { + "bashCommands": [ + "npm", + ], + "cacheCreationInputTokens": 300, + "cacheReadInputTokens": 500, + "cachedInputTokens": 500, + "costBasis": "estimated", + "costUSD": 0.008875000000000001, + "deduplicationKey": "kilo-code:kilo-sess-1:msg-1", + "fallbackCostUSD": 0.05, + "inputTokens": 100, + "model": "claude-opus-4-6", + "outputTokens": 200, + "provider": "kilo-code", + "reasoningTokens": 50, + "sessionId": "kilo-sess-1", + "skills": [], + "speed": "standard", + "subagentTypes": [], + "timestamp": "2023-11-14T22:13:21.000Z", + "tools": [ + "Bash", + ], + "userMessage": "kilocode prompt", + "webSearchRequests": 0, + }, +]) + // Key-presence gate: kilo-code's SQLite arm goes through the same + // toOpenCodeProviderCall as opencode, so the message-arm key shape must match. + const keys = Object.keys(calls[0]!) + expect(keys).toContain('skills') + expect(keys).toContain('subagentTypes') + expect(keys).toContain('bashCommands') + expect(keys).not.toContain('project') + expect(keys).not.toContain('projectPath') + }) +}) + +// ═════════════════════════════════════════════════════════════════════════════ +// CODEBURN_VERBOSE stderr parity +// ═════════════════════════════════════════════════════════════════════════════ + +skipUnlessSqlite('CODEBURN_VERBOSE stderr parity', () => { + it('OpenCode emits the exact pre-migration stderr line on zero-yield SQLite session', async () => { + const dbPath = createTestDb(tmpDir) + withTestDb(dbPath, (db) => { + insertSession(db, 'sess-verbose') + // user-only messages => zero yield + insertMessage(db, 'msg-user1', 'sess-verbose', 1700000000000, { role: 'user' }) + insertPart(db, 'p-user1', 'msg-user1', 'sess-verbose', { type: 'text', text: 'hello' }) + insertMessage(db, 'msg-system', 'sess-verbose', 1700000000100, { role: 'system' }) + insertMessage(db, 'msg-bad', 'sess-verbose', 1700000000200, { role: 'assistant' }) + // corrupt part data to exercise parseFail/part count path + db.prepare(`INSERT INTO part (id, message_id, session_id, data) VALUES (?, ?, ?, ?)`) + .run('part-bad', 'msg-bad', 'sess-verbose', 'not-json') + }) + + const provider = createOpenCodeProvider(tmpDir) + const source = { path: `${dbPath}:sess-verbose`, project: 'myproject', provider: 'opencode' } + const spy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true) + + const prev = process.env['CODEBURN_VERBOSE'] + process.env['CODEBURN_VERBOSE'] = '1' + const calls: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser(source, new Set()).parse()) { + calls.push(priceProviderCall(call)) + } + process.env['CODEBURN_VERBOSE'] = prev + + expect(calls).toEqual([]) + expect(spy).toHaveBeenCalledTimes(1) + expect(spy.mock.calls[0]![0]).toEqual('codeburn: OpenCode session sess-verbose has 3 messages (0 unparseable, 1 non-user/assistant roles) but yielded 0 calls. Parts: 2.\n') + spy.mockRestore() + }) + + it('KiloCode emits the exact pre-migration stderr line on zero-yield SQLite session', async () => { + const dbPath = createKiloTestDb(tmpDir) + withTestDb(dbPath, (db) => { + insertSession(db, 'kilo-sess-verbose') + insertMessage(db, 'msg-user1', 'kilo-sess-verbose', 1700000000000, { role: 'user' }) + insertPart(db, 'p-user1', 'msg-user1', 'kilo-sess-verbose', { type: 'text', text: 'hello' }) + insertMessage(db, 'msg-system', 'kilo-sess-verbose', 1700000000100, { role: 'system' }) + }) + + process.env['XDG_DATA_HOME'] = tmpDir + const provider = createKiloCodeProvider() + const source = { path: `${dbPath}:kilo-sess-verbose`, project: 'myproject', provider: provider.name } + const spy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true) + + const prev = process.env['CODEBURN_VERBOSE'] + process.env['CODEBURN_VERBOSE'] = '1' + const calls: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser(source, new Set()).parse()) { + calls.push(priceProviderCall(call)) + } + process.env['CODEBURN_VERBOSE'] = prev + + expect(calls).toEqual([]) + expect(spy).toHaveBeenCalledTimes(1) + expect(spy.mock.calls[0]![0]).toEqual('codeburn: KiloCode session kilo-sess-verbose has 2 messages (0 unparseable, 1 non-user/assistant roles) but yielded 0 calls. Parts: 1.\n') + spy.mockRestore() + }) + + // The verbose notice belongs to the SQLite arm only. opencode serves both arms + // from one provider instance, so the message/part counts handed from + // readRecords to decode must not survive into a subsequent file-arm source. + it('does not emit the SQLite verbose line for a zero-yield file session after a SQLite source', async () => { + const dbPath = createTestDb(tmpDir) + withTestDb(dbPath, (db) => { + insertSession(db, 'sess-db') + insertMessage(db, 'msg-1', 'sess-db', 1700000000000, { + role: 'assistant', + modelID: 'claude-opus-4-6', + tokens: { input: 10, output: 10, reasoning: 0, cache: { read: 0, write: 0 } }, + }) + }) + // A file session with only a user message yields nothing. + await writeSession({ + sessionId: 'ses_fileonly', + messages: [ + { id: 'msg_u', data: { role: 'user', time: { created: 1 } }, parts: [{ type: 'text', text: 'hi' }] }, + ], + }) + + const provider = createOpenCodeProvider(tmpDir) + const seen = new Set() + const spy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true) + + const prev = process.env['CODEBURN_VERBOSE'] + process.env['CODEBURN_VERBOSE'] = '1' + const calls: ParsedProviderCall[] = [] + const sqliteSource = { path: `${dbPath}:sess-db`, project: 'myproject', provider: 'opencode' } + for await (const call of provider.createSessionParser(sqliteSource, seen).parse()) calls.push(call) + const fileSource = { path: join(tmpDir, 'opencode', 'storage', 'session', 'global', 'ses_fileonly.json'), project: 'myproject', provider: 'opencode' } + for await (const call of provider.createSessionParser(fileSource, seen).parse()) calls.push(call) + process.env['CODEBURN_VERBOSE'] = prev + + const writes = spy.mock.calls.map(c => String(c[0])) + spy.mockRestore() + expect(calls).toHaveLength(1) + expect(writes.filter(w => w.includes('yielded 0 calls'))).toEqual([]) + }) +}) diff --git a/packages/core/package.json b/packages/core/package.json index 5ff4c7e3..ec414908 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -146,6 +146,10 @@ "./providers/vscode-cline": { "types": "./dist/providers/vscode-cline/index.d.ts", "import": "./dist/providers/vscode-cline/index.js" + }, + "./providers/opencode-session": { + "types": "./dist/providers/opencode-session/index.d.ts", + "import": "./dist/providers/opencode-session/index.js" } }, "files": [ diff --git a/packages/core/src/providers/opencode-session/decode.ts b/packages/core/src/providers/opencode-session/decode.ts new file mode 100644 index 00000000..95d2f234 --- /dev/null +++ b/packages/core/src/providers/opencode-session/decode.ts @@ -0,0 +1,341 @@ +// OpenCode-session rich decode. +// +// Pure record decode: no fs, no SQLite, no env, no process, no pricing, no +// strip-ansi. The CLI hands core a tagged envelope; core returns rich calls. + +import type { RecordDiagnostic } from '../../diagnostics.js' +import type { DecodeContext } from '../../contracts.js' +import type { + FileMessageData, + MessageData, + OpenCodeSessionDecodedCall, + OpenCodeSessionEnvelope, + OpenCodeSessionTokens, + PartData, +} from './types.js' + +// ── tool-name normalization (moved verbatim from session-message.ts) ───────── + +export const openCodeToolNameMap: Record = { + bash: 'Bash', + read: 'Read', + edit: 'Edit', + write: 'Write', + glob: 'Glob', + grep: 'Grep', + task: 'Agent', + fetch: 'WebFetch', + search: 'WebSearch', + todo: 'TodoWrite', + skill: 'Skill', + patch: 'Patch', +} + +/** + * Normalize an OpenCode-style tool name. + * + * NOTE: this preserves a pre-existing prototype leak: the bare Record lookup + * followed by a truthy hit check means names like 'constructor' return + * Object.prototype.constructor. It is moved byte-for-byte to keep parity with + * the CLI output; do not "harden" it here. + */ +export function normalizeToolName(rawTool?: string): string { + if (!rawTool) return '' + if (rawTool.startsWith('mcp__')) return rawTool + const builtIn = openCodeToolNameMap[rawTool] + if (builtIn) return builtIn + const serverSeparator = rawTool.indexOf('_') + if (serverSeparator > 0 && serverSeparator < rawTool.length - 1) { + const server = rawTool.slice(0, serverSeparator) + const tool = rawTool.slice(serverSeparator + 1) + return `mcp__${server}__${tool}` + } + return rawTool +} + +export function parseTimestamp(raw: number): string { + const ms = raw < 1e12 ? raw * 1000 : raw + return new Date(ms).toISOString() +} + +// ── shared assistant-turn builder ──────────────────────────────────────────── + +function buildAssistantCall(opts: { + providerName: string + dedupKey: string + sessionId: string + data: MessageData + parts: PartData[] + timeCreatedMs: number + userMessage: string +}): OpenCodeSessionDecodedCall | null { + const { data, parts } = opts + + const tokens = { + input: data.tokens?.input ?? data.usage?.input_tokens ?? 0, + output: data.tokens?.output ?? data.usage?.output_tokens ?? 0, + reasoning: data.tokens?.reasoning ?? 0, + cacheRead: data.tokens?.cache?.read ?? data.usage?.cache_read_input_tokens ?? 0, + cacheWrite: data.tokens?.cache?.write ?? data.usage?.cache_creation_input_tokens ?? 0, + } + + const toolParts = parts.filter((p) => (p.type === 'tool' || p.type === 'tool-call' || p.type === 'tool_call') && normalizeToolName(p.tool)) + const hasTextOutput = parts.some((p) => p.type === 'text' && typeof p.text === 'string' && p.text.trim().length > 0) + const hasToolOrTextParts = hasTextOutput || toolParts.length > 0 + const hasAnySubstantiveParts = parts.some((p) => + p.type === 'text' || p.type === 'tool' || p.type === 'tool-call' || p.type === 'tool_call' || + p.type === 'tool-result' || p.type === 'tool_result' || p.type === 'reasoning' || p.type === 'file' + ) + const hasActivity = hasToolOrTextParts || hasAnySubstantiveParts + + const allZero = + tokens.input === 0 && + tokens.output === 0 && + tokens.reasoning === 0 && + tokens.cacheRead === 0 && + tokens.cacheWrite === 0 + if (allZero && (data.cost ?? 0) === 0 && !hasActivity) return null + + const tools = toolParts + .map((p) => normalizeToolName(p.tool)) + .filter(Boolean) + + const rawBashCommands = toolParts + .filter((p) => p.tool === 'bash' && typeof p.state?.input?.command === 'string') + .map((p) => p.state!.input!.command!) + + const skills = toolParts + .filter((p) => p.tool === 'skill' && typeof p.state?.input?.name === 'string') + .map((p) => p.state!.input!.name!) + .filter(Boolean) + + const subagentTypes = toolParts + .filter((p) => p.tool === 'task' && typeof p.state?.input?.subagent_type === 'string') + .map((p) => p.state!.input!.subagent_type!) + .filter(Boolean) + + const model = data.modelID ?? data.model ?? 'unknown' + + return { + arm: 'message', + provider: opts.providerName, + model, + inputTokens: tokens.input, + outputTokens: tokens.output, + cacheCreationInputTokens: tokens.cacheWrite, + cacheReadInputTokens: tokens.cacheRead, + cachedInputTokens: tokens.cacheRead, + reasoningTokens: tokens.reasoning, + webSearchRequests: 0, + ...(typeof data.cost === 'number' ? { fallbackCostUSD: data.cost } : {}), + tools, + rawBashCommands, + skills, + subagentTypes, + timestamp: parseTimestamp(opts.timeCreatedMs), + speed: 'standard', + deduplicationKey: opts.dedupKey, + userMessage: opts.userMessage, + sessionId: opts.sessionId, + } +} + +// ── arm dispatch ───────────────────────────────────────────────────────────── + +export type OpenCodeSessionDecodeInput = { + records: unknown[] + context: DecodeContext + seenKeys?: Set +} + +export type OpenCodeSessionDecodeResult = { + calls: OpenCodeSessionDecodedCall[] + diagnostics: RecordDiagnostic[] +} + +function decodeSqlite( + envelope: Extract, + providerName: string, + seenKeys: Set, +): OpenCodeSessionDecodeResult { + const calls: OpenCodeSessionDecodedCall[] = [] + const diagnostics: RecordDiagnostic[] = [] + + const partsByMsg = new Map() + for (const part of envelope.parts) { + try { + const parsed = JSON.parse(part.data) as PartData + const list = partsByMsg.get(part.message_id) ?? [] + list.push(parsed) + partsByMsg.set(part.message_id, list) + } catch { + // skip corrupt part data + } + } + + const currentUserMessageBySession = new Map() + + for (const msg of envelope.messages) { + let data: MessageData + try { + data = JSON.parse(msg.data) as MessageData + } catch { + diagnostics.push({ index: 0, code: 'malformed-json' }) + continue + } + + if (data.role === 'user') { + const textParts = (partsByMsg.get(msg.id) ?? []) + .filter((p) => p.type === 'text') + .map((p) => p.text ?? '') + .filter(Boolean) + if (textParts.length > 0) { + currentUserMessageBySession.set(msg.session_id, textParts.join(' ')) + } + continue + } + + if (data.role !== 'assistant' && data.role !== 'model') { + diagnostics.push({ index: 0, code: 'unknown-shape' }) + continue + } + + const dedupKey = `${providerName}:${msg.session_id}:${msg.id}` + if (seenKeys.has(dedupKey)) continue + + const call = buildAssistantCall({ + providerName, + dedupKey, + sessionId: envelope.sessionId, + data, + parts: partsByMsg.get(msg.id) ?? [], + timeCreatedMs: msg.time_created, + userMessage: currentUserMessageBySession.get(msg.session_id) ?? '', + }) + if (!call) continue + + seenKeys.add(dedupKey) + calls.push(call) + } + + if (calls.length === 0 && envelope.messages.length > 0) { + const sessionTokens = envelope.sessionTokens + if ( + sessionTokens && + (sessionTokens.cost > 0 || sessionTokens.input > 0 || sessionTokens.output > 0) + ) { + const dedupKey = `${providerName}:${envelope.sessionId}:session-level` + if (!seenKeys.has(dedupKey)) { + seenKeys.add(dedupKey) + calls.push({ + arm: 'session-level', + provider: providerName, + model: sessionTokens.model ?? 'unknown', + inputTokens: sessionTokens.input, + outputTokens: sessionTokens.output, + cacheCreationInputTokens: sessionTokens.cacheWrite, + cacheReadInputTokens: sessionTokens.cacheRead, + cachedInputTokens: sessionTokens.cacheRead, + reasoningTokens: sessionTokens.reasoning, + webSearchRequests: 0, + ...(sessionTokens.cost > 0 ? { fallbackCostUSD: sessionTokens.cost } : {}), + tools: [], + rawBashCommands: [], + skills: [], + subagentTypes: [], + timestamp: parseTimestamp(envelope.messages[0]!.time_created), + speed: 'standard', + deduplicationKey: dedupKey, + userMessage: '', + sessionId: envelope.sessionId, + }) + } + } + } + + return { calls, diagnostics } +} + +function decodeFile( + envelope: Extract, + providerName: string, + seenKeys: Set, +): OpenCodeSessionDecodeResult { + const calls: OpenCodeSessionDecodedCall[] = [] + + const messages = envelope.messages.slice() + messages.sort((a, b) => { + const byTime = (a.data.time?.created ?? 0) - (b.data.time?.created ?? 0) + if (byTime !== 0) return byTime + return a.id < b.id ? -1 : a.id > b.id ? 1 : 0 + }) + + let currentUserMessage = '' + + for (const { id, data } of messages) { + if (data.role === 'user') { + const parts: PartData[] = [] + for (const raw of envelope.partsRawByMessageId.get(id) ?? []) { + try { + parts.push(JSON.parse(raw) as PartData) + } catch { + // skip corrupt part file + } + } + const text = parts + .filter((p) => p.type === 'text') + .map((p) => p.text ?? '') + .filter(Boolean) + .join(' ') + if (text) currentUserMessage = text + continue + } + + if (data.role !== 'assistant' && data.role !== 'model') continue + + const dedupKey = `${providerName}:${envelope.sessionId}:${id}` + if (seenKeys.has(dedupKey)) continue + + const parts: PartData[] = [] + for (const raw of envelope.partsRawByMessageId.get(id) ?? []) { + try { + parts.push(JSON.parse(raw) as PartData) + } catch { + // skip corrupt part file + } + } + + const call = buildAssistantCall({ + providerName, + dedupKey, + sessionId: envelope.sessionId, + data, + parts, + timeCreatedMs: data.time?.created ?? envelope.metaTimeCreatedMs ?? 0, + userMessage: currentUserMessage, + }) + if (!call) continue + + seenKeys.add(dedupKey) + calls.push(call) + } + + return { calls, diagnostics: [] } +} + +export function decodeOpenCodeSession(input: OpenCodeSessionDecodeInput): OpenCodeSessionDecodeResult { + const seenKeys = input.seenKeys ?? new Set() + const envelope = input.records[0] as OpenCodeSessionEnvelope | undefined + const providerName = input.context.providerId + + if (!envelope) return { calls: [], diagnostics: [] } + + switch (envelope.kind) { + case 'sqlite': + return decodeSqlite(envelope, providerName, seenKeys) + case 'file': + return decodeFile(envelope, providerName, seenKeys) + default: + return { calls: [], diagnostics: [{ index: 0, code: 'unknown-shape' }] } + } +} diff --git a/packages/core/src/providers/opencode-session/index.ts b/packages/core/src/providers/opencode-session/index.ts new file mode 100644 index 00000000..36397806 --- /dev/null +++ b/packages/core/src/providers/opencode-session/index.ts @@ -0,0 +1,30 @@ +// @codeburn/core OpenCode-session provider. +// +// Two layers: +// - Rich pure decode (`decodeOpenCodeSession`): host-facing, NOT part of the stable +// minimized surface. Pure over host-supplied session records; all I/O and +// query strings stay CLI-side, per the Category B recipe. +// - Minimizing transform (`toObservations`): maps the rich decode into the +// strict observation envelope; the content-smuggling guarantees bind here. + +export { + decodeOpenCodeSession, + type OpenCodeSessionDecodeInput, + type OpenCodeSessionDecodeResult, +} from './decode.js' + +export { + toObservations, + type RichOpenCodeSessionDecode, + type OpenCodeSessionToObservationsContext, +} from './observations.js' + +export type { + OpenCodeSessionDecodedCall, + OpenCodeSessionEnvelope, + OpenCodeSessionTokens, + MessageData, + PartData, + SessionMeta, + FileMessageData, +} from './types.js' diff --git a/packages/core/src/providers/opencode-session/observations.ts b/packages/core/src/providers/opencode-session/observations.ts new file mode 100644 index 00000000..60d66291 --- /dev/null +++ b/packages/core/src/providers/opencode-session/observations.ts @@ -0,0 +1,84 @@ +// Minimizing transform: rich OpenCode-session decode -> the strict observation envelope. +// +// Only opaque ids, fingerprints, enums, numbers, timestamps, and canonical tool +// names cross into the output. The OpenCode-session decoder captures free-text +// fields for the HOST, but they are deliberately absent here. + +import { projectRef, sessionRef } from '../../fingerprint.js' +import type { RecordDiagnostic } from '../../diagnostics.js' +import type { CallObservation, SessionObservation } from '../../observations.js' +import type { OpenCodeSessionDecodedCall } from './types.js' + +/** One OpenCode-session decode, as the host holds it before minimization. */ +export interface RichOpenCodeSessionDecode { + sessionId: string + /** Absolute project path; fingerprinted, never emitted raw. */ + projectPath: string + /** Rich calls in decode order. */ + calls: OpenCodeSessionDecodedCall[] +} + +export interface OpenCodeSessionToObservationsContext { + /** HMAC key that scopes every fingerprint. */ + privacyKey: string + /** Provider id stamped onto sessions/calls and folded into sessionRef. */ + provider?: string +} + +const CANONICAL_TOOL_NAME = /^[A-Za-z0-9_.-]{1,64}$/ + +function toCallObservation(call: OpenCodeSessionDecodedCall, turnIndex: number): CallObservation { + return { + provider: call.provider, + model: call.model, + tokens: { + input: call.inputTokens, + output: call.outputTokens, + reasoning: call.reasoningTokens, + cacheRead: call.cacheReadInputTokens, + cacheCreate: call.cacheCreationInputTokens, + }, + webSearchRequests: call.webSearchRequests, + speed: call.speed, + costBasis: 'estimated', + timestamp: call.timestamp, + dedupKey: call.deduplicationKey, + toolNames: call.tools.filter(t => CANONICAL_TOOL_NAME.test(t)), + turnIndex, + } +} + +function toSessionObservation( + decode: RichOpenCodeSessionDecode, + ctx: OpenCodeSessionToObservationsContext, +): SessionObservation { + const provider = ctx.provider ?? 'opencode' + const calls: CallObservation[] = decode.calls.map((call, i) => toCallObservation(call, i)) + + const timestamps = calls.map(c => c.timestamp).filter(t => t.length > 0).sort() + const startedAt = timestamps[0] ?? '' + const endedAt = timestamps.length > 0 ? timestamps[timestamps.length - 1]! : '' + + return { + sessionRef: sessionRef(ctx.privacyKey, provider, decode.sessionId), + projectRef: projectRef(ctx.privacyKey, decode.projectPath), + providerId: provider, + startedAt, + ...(endedAt ? { endedAt } : {}), + calls, + turnCount: calls.length, + } +} + +/** + * Map a rich OpenCode-session decode into the minimized observation layer. + * Returns the `sessions` array plus any per-record `diagnostics`. + */ +export function toObservations( + decode: RichOpenCodeSessionDecode | RichOpenCodeSessionDecode[], + ctx: OpenCodeSessionToObservationsContext, +): { sessions: SessionObservation[]; diagnostics: RecordDiagnostic[] } { + const decodes = Array.isArray(decode) ? decode : [decode] + const sessions = decodes.map(d => toSessionObservation(d, ctx)) + return { sessions, diagnostics: [] } +} diff --git a/packages/core/src/providers/opencode-session/types.ts b/packages/core/src/providers/opencode-session/types.ts new file mode 100644 index 00000000..c81c1378 --- /dev/null +++ b/packages/core/src/providers/opencode-session/types.ts @@ -0,0 +1,113 @@ +// OpenCode-session shared decode types. +// +// This module is the core home of the OpenCode-style session decode family +// (OpenCode SQLite, OpenCode file-based JSON, and Kilo Code SQLite). The +// message/part shape is identical across all three stores; only the I/O +// envelope differs. + +/** The message blob shared by SQLite and file-based OpenCode stores. */ +export type MessageData = { + role: string + modelID?: string + model?: string + cost?: number + tokens?: { + input?: number + output?: number + reasoning?: number + cache?: { read?: number; write?: number } + } + usage?: { + input_tokens?: number + output_tokens?: number + cache_creation_input_tokens?: number + cache_read_input_tokens?: number + } +} + +/** One part of an assistant/user message. */ +export type PartData = { + type: string + text?: string + tool?: string + state?: { input?: { command?: string; name?: string; subagent_type?: string } } +} + +/** Session metadata from the file-based JSON layout. */ +export type SessionMeta = { + id?: string + directory?: string + title?: string + time?: { created?: number } +} + +/** Message file payload in the file-based JSON layout. */ +export type FileMessageData = MessageData & { + id?: string + time?: { created?: number } +} + +/** + * Tagged input envelope. The CLI reads the raw bytes / rows and core does the + * decode, so the SQLite driver and SQL queries stay CLI-side. + */ +export type OpenCodeSessionEnvelope = + | { + kind: 'sqlite' + /** The ROOT session id from the source path; every call is attributed to it. */ + sessionId: string + /** Rows from the message query, in query order. `data` is blob-decoded text. */ + messages: Array<{ session_id: string; id: string; time_created: number; data: string }> + /** Rows from the part query, in query order. `data` is blob-decoded text. */ + parts: Array<{ message_id: string; data: string }> + /** Pre-fetched session-row rollup for the zero-yield fallback; null when unavailable. */ + sessionTokens: OpenCodeSessionTokens | null + } + | { + kind: 'file' + sessionId: string + /** Parsed message files; ordering is decided in core, not here. */ + messages: Array<{ id: string; data: FileMessageData }> + /** messageId -> raw JSON strings of its part files, in sorted filename order. */ + partsRawByMessageId: ReadonlyMap + /** meta.time.created, for the timeCreatedMs fallback chain. */ + metaTimeCreatedMs: number | undefined + } + +/** Pre-fetched session-row token rollup for the SQLite session-level fallback. */ +export type OpenCodeSessionTokens = { + cost: number + input: number + output: number + reasoning: number + cacheRead: number + cacheWrite: number + model: string | undefined +} + +/** A decoded, cost-free call from an OpenCode-style session. */ +export interface OpenCodeSessionDecodedCall { + /** Which arm produced this call. The host switches on it only if it ever needs to. */ + arm: 'message' | 'session-level' + provider: string + model: string + inputTokens: number + outputTokens: number + cacheCreationInputTokens: number + cacheReadInputTokens: number + cachedInputTokens: number + reasoningTokens: number + webSearchRequests: number + /** Provider-reported dollars used ONLY as an estimated-path fallback. Never 'measured'. */ + fallbackCostUSD?: number + tools: string[] + /** RAW command strings; the host reduces these. */ + rawBashCommands: string[] + skills: string[] + subagentTypes: string[] + timestamp: string + speed: 'standard' + deduplicationKey: string + userMessage: string + sessionId: string +} diff --git a/packages/core/tests/architecture-gate.test.ts b/packages/core/tests/architecture-gate.test.ts index d74564a2..8cfde696 100644 --- a/packages/core/tests/architecture-gate.test.ts +++ b/packages/core/tests/architecture-gate.test.ts @@ -171,6 +171,8 @@ const USER_MESSAGE_ALLOWLIST = new Set([ 'src/providers/copilot/types.ts', 'src/providers/vscode-cline/decode.ts', 'src/providers/vscode-cline/types.ts', + 'src/providers/opencode-session/decode.ts', + 'src/providers/opencode-session/types.ts', ]) describe('architecture gate: no classification or free text in @codeburn/core source', () => { diff --git a/packages/core/tests/content-smuggling.test.ts b/packages/core/tests/content-smuggling.test.ts index af0a3911..9f7a2966 100644 --- a/packages/core/tests/content-smuggling.test.ts +++ b/packages/core/tests/content-smuggling.test.ts @@ -36,6 +36,7 @@ import { decodeQuickdesk, toObservations as toQuickdeskObservations } from '../s import { decodeDevin, toObservations as toDevinObservations } from '../src/providers/devin/index.js' import { decodeCopilot, toObservations as toCopilotObservations } from '../src/providers/copilot/index.js' import { decodeVscodeCline, toObservations as toVscodeClineObservations } from '../src/providers/vscode-cline/index.js' +import { decodeOpenCodeSession, toObservations as toOpenCodeSessionObservations } from '../src/providers/opencode-session/index.js' import type { DecodeContext } from '../src/contracts.js' import type { ZedThreadRow } from '../src/providers/zed/index.js' @@ -1420,3 +1421,79 @@ describe('content-smuggling guardrail: real devin decode -> toObservations is se }) }) + +describe('content-smuggling guardrail: real opencode-session decode -> toObservations is secret-free', () => { + // A hostile OpenCode-session SQLite envelope planting every secret in the + // free-text fields the decode captures: user message text, a bash command, a + // skill name, a subagent type, and a tool NAME carrying a command line. + // Minimizing MUST surface none of them. + const opencodeContext: DecodeContext = { privacyKey: 'test-privacy-key', providerId: 'opencode', sourceRef: 'ref' } + + function decodeAndMinimize() { + const records = [{ + kind: 'sqlite' as const, + sessionId: 'sess-hostile', + messages: [ + { + session_id: 'sess-hostile', + id: 'msg-user', + time_created: 1700000000000, + data: JSON.stringify({ + role: 'user', + // user message text parts carry the prompt, api key, and file content + }), + }, + { + session_id: 'sess-hostile', + id: 'msg-assistant', + time_created: 1700000001000, + data: JSON.stringify({ + role: 'assistant', + modelID: 'claude-opus-4-6', + cost: 0.05, + tokens: { input: 100, output: 50, reasoning: 0, cache: { read: 0, write: 0 } }, + }), + }, + ], + parts: [ + { message_id: 'msg-user', data: JSON.stringify({ type: 'text', text: `${SECRETS.prompt} ${SECRETS.apiKey} ${SECRETS.fileContent}` }) }, + { message_id: 'msg-assistant', data: JSON.stringify({ type: 'tool', tool: 'bash', state: { input: { command: SECRETS.commandLine } } }) }, + { message_id: 'msg-assistant', data: JSON.stringify({ type: 'tool', tool: 'skill', state: { input: { name: SECRETS.absPath } } }) }, + { message_id: 'msg-assistant', data: JSON.stringify({ type: 'tool', tool: 'task', state: { input: { subagent_type: SECRETS.apiKey } } }) }, + // A hostile tool NAME carrying a command line: normalizeToolName passes it + // through unchanged, so the only thing stopping it is the CANONICAL_TOOL_NAME + // filter in observations.ts. + { message_id: 'msg-assistant', data: JSON.stringify({ type: 'tool', tool: SECRETS.commandLine }) }, + ], + sessionTokens: null, + }] + const { calls } = decodeOpenCodeSession({ records, context: opencodeContext }) + const { sessions } = toOpenCodeSessionObservations( + { sessionId: 'sess-hostile', projectPath: SECRETS.absPath, calls }, + { privacyKey: 'test-privacy-key', provider: 'opencode' }, + ) + return { + schemaVersion: OBSERVATION_SCHEMA_VERSION, + generator: { name: '@codeburn/core', version: '0.0.0-test' }, + sessions, + } + } + + it('produces a schema-valid envelope from the hostile session', () => { + expect(ObservationEnvelope.safeParse(decodeAndMinimize()).success).toBe(true) + }) + + it('the serialized envelope contains none of the planted secrets', () => { + const serialized = JSON.stringify(decodeAndMinimize()) + for (const secret of ALL_SECRETS) { + expect(serialized).not.toContain(secret) + } + }) + + it('keeps canonical tool names (Bash) and drops the argument-carrying name', () => { + const env = decodeAndMinimize() + const allToolNames = env.sessions.flatMap(s => s.calls.flatMap(c => c.toolNames)) + expect(allToolNames).toContain('Bash') + expect(allToolNames).not.toContain(SECRETS.commandLine) + }) +}) diff --git a/packages/core/tests/providers/opencode-session-decode.test.ts b/packages/core/tests/providers/opencode-session-decode.test.ts new file mode 100644 index 00000000..84e2c3d0 --- /dev/null +++ b/packages/core/tests/providers/opencode-session-decode.test.ts @@ -0,0 +1,636 @@ +import { describe, expect, it } from 'vitest' + +import { decodeOpenCodeSession, toObservations } from '../../src/providers/opencode-session/index.js' +import { ObservationEnvelope } from '../../src/observations.js' +import { OBSERVATION_SCHEMA_VERSION } from '../../src/schema.js' +import type { DecodeContext } from '../../src/contracts.js' +import type { + FileMessageData, + MessageData, + OpenCodeSessionEnvelope, + PartData, +} from '../../src/providers/opencode-session/types.js' + +const context: DecodeContext = { privacyKey: 'k', providerId: 'opencode', sourceRef: 'ref' } + +function sqliteEnvelope(overrides: Partial> = {}): Extract { + return { + kind: 'sqlite', + sessionId: 'sess-a', + messages: [], + parts: [], + sessionTokens: null, + ...overrides, + } +} + +function fileEnvelope(overrides: Partial> = {}): Extract { + return { + kind: 'file', + sessionId: 'sess-a', + messages: [], + partsRawByMessageId: new Map(), + metaTimeCreatedMs: undefined, + ...overrides, + } +} + +function msg(data: MessageData, overrides: Partial<{ id: string; session_id: string; time_created: number }> = {}): { session_id: string; id: string; time_created: number; data: string } { + return { + session_id: overrides.session_id ?? 'sess-a', + id: overrides.id ?? 'msg-1', + time_created: overrides.time_created ?? 1700000001000, + data: JSON.stringify(data), + } +} + +function part(messageId: string, data: PartData): { message_id: string; data: string } { + return { message_id: messageId, data: JSON.stringify(data) } +} + +// ── Shared builder arms ───────────────────────────────────────────────────── + +describe('opencode-session rich decode: shared builder', () => { + it('H1-H3: normalizes tokens, usage fallback, and fills both cacheReadInputTokens and cachedInputTokens', () => { + const { calls } = decodeOpenCodeSession({ + records: [sqliteEnvelope({ + messages: [ + msg({ role: 'user' }, { id: 'msg-user' }), + msg({ + role: 'assistant', + modelID: 'claude-opus-4-6', + cost: 0.05, + tokens: { input: 100, output: 200, reasoning: 50, cache: { read: 500, write: 300 } }, + }), + ], + parts: [ + part('msg-user', { type: 'text', text: 'hello' }), + ], + })], + context, + }) + expect(calls).toHaveLength(1) + expect(calls[0]).toMatchObject({ + inputTokens: 100, + outputTokens: 200, + reasoningTokens: 50, + cacheReadInputTokens: 500, + cacheCreationInputTokens: 300, + cachedInputTokens: 500, + fallbackCostUSD: 0.05, + }) + expect(calls[0]!.cacheReadInputTokens).toBe(calls[0]!.cachedInputTokens) + }) + + it('H2: tokens.* wins over usage.*; usage.* fills null/undefined tokens', () => { + const { calls } = decodeOpenCodeSession({ + records: [sqliteEnvelope({ + messages: [ + msg({ role: 'user' }, { id: 'msg-user' }), + msg({ + role: 'assistant', + cost: 0.05, + tokens: { input: 10, output: 20, cache: { read: 50, write: 30 } }, + usage: { input_tokens: 999, output_tokens: 888, cache_creation_input_tokens: 777, cache_read_input_tokens: 666 }, + }), + ], + parts: [], + })], + context, + }) + expect(calls[0]).toMatchObject({ + inputTokens: 10, + outputTokens: 20, + cacheReadInputTokens: 50, + cacheCreationInputTokens: 30, + }) + }) + + it('H4: counts tool/tool-call/tool_call; tool-result/tool_result are activity but not tools', () => { + const { calls } = decodeOpenCodeSession({ + records: [sqliteEnvelope({ + messages: [ + msg({ role: 'assistant', cost: 0.05, tokens: { input: 10, output: 10 } }), + ], + parts: [ + part('msg-1', { type: 'tool', tool: 'bash', state: { input: { command: 'ls' } } }), + part('msg-1', { type: 'tool-call', tool: 'read' }), + part('msg-1', { type: 'tool_call', tool: 'grep' }), + part('msg-1', { type: 'tool-result', tool: 'bash' }), + part('msg-1', { type: 'tool_result', tool: 'bash' }), + ], + })], + context, + }) + expect(calls[0]!.tools).toEqual(['Bash', 'Read', 'Grep']) + expect(calls[0]!.rawBashCommands).toEqual(['ls']) + }) + + it('H5: normalizeToolName builtins, mcp__ passthrough, server_tool conversion, underscore edge cases', () => { + const { calls } = decodeOpenCodeSession({ + records: [sqliteEnvelope({ + messages: [ + msg({ role: 'assistant', cost: 0.05, tokens: { input: 10, output: 10 } }), + ], + parts: [ + part('msg-1', { type: 'tool', tool: 'bash' }), + part('msg-1', { type: 'tool', tool: 'mcp__x__y' }), + part('msg-1', { type: 'tool', tool: 'server_tool' }), + part('msg-1', { type: 'tool', tool: '_leading' }), + part('msg-1', { type: 'tool', tool: 'trailing_' }), + ], + })], + context, + }) + expect(calls[0]!.tools).toEqual(['Bash', 'mcp__x__y', 'mcp__server__tool', '_leading', 'trailing_']) + }) + + it('H6-H8: extracts rawBashCommands, skills, and subagentTypes', () => { + const { calls } = decodeOpenCodeSession({ + records: [sqliteEnvelope({ + messages: [ + msg({ role: 'assistant', cost: 0.05, tokens: { input: 10, output: 10 } }), + ], + parts: [ + part('msg-1', { type: 'tool', tool: 'bash', state: { input: { command: 'npm test' } } }), + part('msg-1', { type: 'tool', tool: 'skill', state: { input: { name: 'commit' } } }), + part('msg-1', { type: 'tool', tool: 'task', state: { input: { subagent_type: 'general-purpose' } } }), + ], + })], + context, + }) + expect(calls[0]!.rawBashCommands).toEqual(['npm test']) + expect(calls[0]!.skills).toEqual(['commit']) + expect(calls[0]!.subagentTypes).toEqual(['general-purpose']) + }) + + it('H9: skills and subagentTypes are present as empty arrays when absent', () => { + const { calls } = decodeOpenCodeSession({ + records: [sqliteEnvelope({ + messages: [msg({ role: 'assistant', cost: 0.05, tokens: { input: 10, output: 10 } })], + parts: [], + })], + context, + }) + expect(calls[0]!.skills).toEqual([]) + expect(calls[0]!.subagentTypes).toEqual([]) + }) + + it('H10: skips all-zero tokens with falsy cost and no substantive parts', () => { + const { calls } = decodeOpenCodeSession({ + records: [sqliteEnvelope({ + messages: [msg({ role: 'assistant', tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } } })], + parts: [], + })], + context, + }) + expect(calls).toEqual([]) + }) + + it('H11: yields zero-token assistant with non-empty text', () => { + const { calls } = decodeOpenCodeSession({ + records: [sqliteEnvelope({ + messages: [ + msg({ role: 'user' }, { id: 'msg-user' }), + msg({ role: 'assistant', tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } } }, { id: 'msg-2' }), + ], + parts: [ + part('msg-user', { type: 'text', text: 'prompt' }), + part('msg-2', { type: 'text', text: 'I will help' }), + ], + })], + context, + }) + expect(calls).toHaveLength(1) + expect(calls[0]!.inputTokens).toBe(0) + }) + + it('H12: yields zero-token assistant with a tool-call part', () => { + const { calls } = decodeOpenCodeSession({ + records: [sqliteEnvelope({ + messages: [ + msg({ role: 'user' }, { id: 'msg-user' }), + msg({ role: 'assistant', tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } } }, { id: 'msg-2' }), + ], + parts: [ + part('msg-user', { type: 'text', text: 'prompt' }), + part('msg-2', { type: 'tool', tool: 'bash', state: { input: { command: 'ls' } } }), + ], + })], + context, + }) + expect(calls).toHaveLength(1) + expect(calls[0]!.tools).toEqual(['Bash']) + }) + + it('H13: yields zero-token assistant with reasoning or file part', () => { + const { calls } = decodeOpenCodeSession({ + records: [sqliteEnvelope({ + messages: [ + msg({ role: 'assistant', tokens: { input: 0, output: 0 } }, { id: 'msg-2' }), + msg({ role: 'assistant', tokens: { input: 0, output: 0 } }, { id: 'msg-3' }), + ], + parts: [ + part('msg-2', { type: 'reasoning' }), + part('msg-3', { type: 'file' }), + ], + })], + context, + }) + expect(calls).toHaveLength(2) + }) + + it('H14: yields all-zero tokens when cost > 0', () => { + const { calls } = decodeOpenCodeSession({ + records: [sqliteEnvelope({ + messages: [msg({ role: 'assistant', cost: 0.01, tokens: { input: 0, output: 0 } })], + parts: [], + })], + context, + }) + expect(calls).toHaveLength(1) + expect(calls[0]!.fallbackCostUSD).toBe(0.01) + }) + + it('H15: fallbackCostUSD present for cost 0, absent when cost undefined', () => { + const { calls } = decodeOpenCodeSession({ + records: [sqliteEnvelope({ + messages: [ + msg({ role: 'assistant', cost: 0, tokens: { input: 10, output: 10 } }, { id: 'msg-2' }), + msg({ role: 'assistant', tokens: { input: 10, output: 10 } }, { id: 'msg-3' }), + ], + parts: [], + })], + context, + }) + expect(calls[0]!).toHaveProperty('fallbackCostUSD', 0) + expect(calls[1]!).not.toHaveProperty('fallbackCostUSD') + }) + + it('H16: model precedence modelID -> model -> unknown', () => { + const { calls } = decodeOpenCodeSession({ + records: [sqliteEnvelope({ + messages: [ + msg({ role: 'assistant', tokens: { input: 10, output: 10 }, model: 'from-model' }, { id: 'msg-2' }), + msg({ role: 'assistant', tokens: { input: 10, output: 10 } }, { id: 'msg-3' }), + ], + parts: [], + })], + context, + }) + expect(calls[0]!.model).toBe('from-model') + expect(calls[1]!.model).toBe('unknown') + }) + + it('H17: parseTimestamp treats <1e12 as seconds and >=1e12 as ms', () => { + const { calls } = decodeOpenCodeSession({ + records: [sqliteEnvelope({ + messages: [msg({ role: 'assistant', tokens: { input: 10, output: 10 } }, { time_created: 1700000000 })], + parts: [], + })], + context, + }) + expect(calls[0]!.timestamp).toBe(new Date(1700000000 * 1000).toISOString()) + }) + + it('providerName comes from context.providerId (opencode vs kilo-code)', () => { + const { calls } = decodeOpenCodeSession({ + records: [sqliteEnvelope({ + messages: [msg({ role: 'assistant', tokens: { input: 10, output: 10 } })], + parts: [], + })], + context: { ...context, providerId: 'kilo-code' }, + }) + expect(calls[0]!.provider).toBe('kilo-code') + expect(calls[0]!.deduplicationKey).toBe('kilo-code:sess-a:msg-1') + }) +}) + +// ── SQLite-only arms ──────────────────────────────────────────────────────── + +describe('opencode-session rich decode: SQLite arm', () => { + it('S1: child and grandchild calls are attributed to the root sessionId', () => { + const { calls } = decodeOpenCodeSession({ + records: [sqliteEnvelope({ + sessionId: 'root', + messages: [ + msg({ role: 'assistant', tokens: { input: 10, output: 10 } }, { id: 'm-child', session_id: 'child' }), + msg({ role: 'assistant', tokens: { input: 20, output: 20 } }, { id: 'm-grandchild', session_id: 'grandchild' }), + ], + parts: [], + })], + context, + }) + expect(calls).toHaveLength(2) + expect(calls[0]!.sessionId).toBe('root') + expect(calls[1]!.sessionId).toBe('root') + }) + + it('S3: corrupt message data is counted as malformed-json diagnostic', () => { + const { calls, diagnostics } = decodeOpenCodeSession({ + records: [sqliteEnvelope({ + messages: [ + { session_id: 'sess-a', id: 'msg-bad', time_created: 1700000000000, data: 'not-json' }, + msg({ role: 'assistant', tokens: { input: 10, output: 10 } }, { id: 'msg-good' }), + ], + parts: [], + })], + context, + }) + expect(calls).toHaveLength(1) + expect(diagnostics).toEqual([{ index: 0, code: 'malformed-json' }]) + }) + + it('S4: corrupt part data is skipped silently', () => { + const { calls, diagnostics } = decodeOpenCodeSession({ + records: [sqliteEnvelope({ + messages: [msg({ role: 'assistant', tokens: { input: 10, output: 10 } })], + parts: [ + { message_id: 'msg-1', data: 'not-json' }, + part('msg-1', { type: 'tool', tool: 'read' }), + ], + })], + context, + }) + expect(calls).toHaveLength(1) + expect(diagnostics).toEqual([]) + }) + + it('S5: user messages are accumulated per msg.session_id and joined with space', () => { + const { calls } = decodeOpenCodeSession({ + records: [sqliteEnvelope({ + messages: [ + msg({ role: 'user' }, { id: 'msg-user', session_id: 'sess-a' }), + msg({ role: 'assistant', tokens: { input: 10, output: 10 } }, { id: 'msg-2', session_id: 'sess-a' }), + ], + parts: [ + part('msg-user', { type: 'text', text: 'first' }), + part('msg-user', { type: 'text', text: 'second' }), + ], + })], + context, + }) + expect(calls[0]!.userMessage).toBe('first second') + }) + + it('S6-S7: model role accepted, other roles counted as unknown-shape', () => { + const { calls, diagnostics } = decodeOpenCodeSession({ + records: [sqliteEnvelope({ + messages: [ + msg({ role: 'model', tokens: { input: 10, output: 10 } }, { id: 'msg-model' }), + msg({ role: 'system' }, { id: 'msg-system' }), + ], + parts: [], + })], + context, + }) + expect(calls).toHaveLength(1) + expect(diagnostics).toEqual([{ index: 0, code: 'unknown-shape' }]) + }) + + // Opposite of the vscode-cline arm, which burns the dedup key BEFORE deciding + // to skip. Here a null build must leave the key unclaimed, so the second + // message sharing that key still yields. Asserting only `seen.has(...)` would + // hold under either ordering — the call count is what discriminates. + it('S8: dedup key added only after successful build', () => { + const seen = new Set() + const { calls } = decodeOpenCodeSession({ + records: [sqliteEnvelope({ + messages: [ + msg({ role: 'assistant', tokens: { input: 0, output: 0 } }, { id: 'msg-skip' }), + msg({ role: 'assistant', tokens: { input: 10, output: 10 } }, { id: 'msg-skip' }), + ], + parts: [], + })], + context, + seenKeys: seen, + }) + expect(calls).toHaveLength(1) + expect(calls[0]!.inputTokens).toBe(10) + expect(calls[0]!.deduplicationKey).toBe('opencode:sess-a:msg-skip') + expect(seen.has('opencode:sess-a:msg-skip')).toBe(true) + }) + + it('S9-S11: session-level fallback fires with correct shape and cost guard', () => { + const { calls } = decodeOpenCodeSession({ + records: [sqliteEnvelope({ + messages: [msg({ role: 'user' }, { id: 'msg-user' })], + parts: [part('msg-user', { type: 'text', text: 'hello' })], + sessionTokens: { cost: 1, input: 100, output: 50, reasoning: 5, cacheRead: 10, cacheWrite: 20, model: 'session-model' }, + })], + context, + }) + expect(calls).toHaveLength(1) + expect(calls[0]).toMatchObject({ + arm: 'session-level', + inputTokens: 100, + outputTokens: 50, + reasoningTokens: 5, + cacheReadInputTokens: 10, + cacheCreationInputTokens: 20, + cachedInputTokens: 10, + fallbackCostUSD: 1, + deduplicationKey: 'opencode:sess-a:session-level', + model: 'session-model', + tools: [], + rawBashCommands: [], + userMessage: '', + }) + }) + + it('S10: session-level fallback omitted when session row is all zeros', () => { + const { calls } = decodeOpenCodeSession({ + records: [sqliteEnvelope({ + messages: [msg({ role: 'user' }, { id: 'msg-user' })], + parts: [], + sessionTokens: { cost: 0, input: 0, output: 0, reasoning: 0, cacheRead: 0, cacheWrite: 0, model: undefined }, + })], + context, + }) + expect(calls).toEqual([]) + }) + + it('S11: session-level fallback omits fallbackCostUSD when cost is 0', () => { + const { calls } = decodeOpenCodeSession({ + records: [sqliteEnvelope({ + messages: [msg({ role: 'user' }, { id: 'msg-user' })], + parts: [], + sessionTokens: { cost: 0, input: 100, output: 50, reasoning: 0, cacheRead: 0, cacheWrite: 0, model: undefined }, + })], + context, + }) + expect(calls[0]!).not.toHaveProperty('fallbackCostUSD') + }) + + it('S12: session with only user messages yields nothing', () => { + const { calls } = decodeOpenCodeSession({ + records: [sqliteEnvelope({ + messages: [msg({ role: 'user' }, { id: 'msg-user' })], + parts: [part('msg-user', { type: 'text', text: 'hello' })], + sessionTokens: null, + })], + context, + }) + expect(calls).toEqual([]) + }) +}) + +// ── File arm ──────────────────────────────────────────────────────────────── + +describe('opencode-session rich decode: file arm', () => { + it('F1-F3: message ordering, id fallback, and sorted parts', async () => { + const messages: Array<{ id: string; data: FileMessageData }> = [ + { id: 'msg_b', data: { role: 'assistant', cost: 0.05, tokens: { input: 10, output: 10 }, time: { created: 2 } } }, + { id: 'msg_a', data: { role: 'assistant', cost: 0.05, tokens: { input: 20, output: 20 }, time: { created: 2 } } }, + { id: 'msg_c', data: { role: 'assistant', cost: 0.05, tokens: { input: 30, output: 30 }, time: { created: 3 } } }, + ] + const parts = new Map([ + ['msg_b', [JSON.stringify({ type: 'text', text: 'b-first' }), JSON.stringify({ type: 'text', text: 'b-second' })]], + ['msg_a', [JSON.stringify({ type: 'text', text: 'a-wins-tie' })]], + ['msg_c', [JSON.stringify({ type: 'text', text: 'c-latest' })]], + ]) + const { calls } = decodeOpenCodeSession({ + records: [fileEnvelope({ messages, partsRawByMessageId: parts })], + context, + }) + expect(calls.map(c => c.deduplicationKey)).toEqual([ + 'opencode:sess-a:msg_a', + 'opencode:sess-a:msg_b', + 'opencode:sess-a:msg_c', + ]) + }) + + it('F2: message id falls back to filename minus .json', () => { + const messages: Array<{ id: string; data: FileMessageData }> = [ + { id: 'from_filename', data: { role: 'assistant', cost: 0.05, tokens: { input: 10, output: 10 }, time: { created: 1 } } }, + ] + const { calls } = decodeOpenCodeSession({ + records: [fileEnvelope({ messages, partsRawByMessageId: new Map([['from_filename', [JSON.stringify({ type: 'text', text: 'ok' })]]]) })], + context, + }) + expect(calls[0]!.deduplicationKey).toBe('opencode:sess-a:from_filename') + }) + + it('F4: unparseable part file is skipped', () => { + const messages: Array<{ id: string; data: FileMessageData }> = [ + { id: 'msg_a', data: { role: 'assistant', cost: 0.05, tokens: { input: 10, output: 10 }, time: { created: 1 } } }, + ] + const { calls } = decodeOpenCodeSession({ + records: [fileEnvelope({ + messages, + partsRawByMessageId: new Map([['msg_a', ['not-json', JSON.stringify({ type: 'text', text: 'ok' })]]]), + })], + context, + }) + expect(calls).toHaveLength(1) + }) + + it('F5: timeCreatedMs precedence data.time.created -> meta.time.created -> 0', () => { + const messages: Array<{ id: string; data: FileMessageData }> = [ + { id: 'msg_a', data: { role: 'assistant', cost: 0.05, tokens: { input: 10, output: 10 } } }, + ] + const { calls } = decodeOpenCodeSession({ + records: [fileEnvelope({ + messages, + partsRawByMessageId: new Map([['msg_a', [JSON.stringify({ type: 'text', text: 'ok' })]]]), + metaTimeCreatedMs: 1781886356809, + })], + context, + }) + expect(calls[0]!.timestamp).toBe(new Date(1781886356809).toISOString()) + }) + + it('F8: currentUserMessage only overwritten by non-empty joined text', () => { + const messages: Array<{ id: string; data: FileMessageData }> = [ + { id: 'msg_user1', data: { role: 'user', time: { created: 1 } } }, + { id: 'msg_user2_empty', data: { role: 'user', time: { created: 2 } } }, + { id: 'msg_assistant', data: { role: 'assistant', cost: 0.05, tokens: { input: 10, output: 10 }, time: { created: 3 } } }, + ] + const { calls } = decodeOpenCodeSession({ + records: [fileEnvelope({ + messages, + partsRawByMessageId: new Map([ + ['msg_user1', [JSON.stringify({ type: 'text', text: 'first prompt' })]], + ['msg_user2_empty', [JSON.stringify({ type: 'text', text: '' })]], + ['msg_assistant', [JSON.stringify({ type: 'text', text: 'reply' })]] + ]), + })], + context, + }) + expect(calls[0]!.userMessage).toBe('first prompt') + }) + + // Both messages resolve to the same id, so they share a dedup key. The first + // must build to null — no parts, or it would gain activity from the shared + // part list and yield instead — leaving the key unclaimed for the second. + it('F9: dedup adds only after successful build', () => { + const seen = new Set() + const { calls } = decodeOpenCodeSession({ + records: [fileEnvelope({ + messages: [ + { id: 'msg_skip', data: { role: 'assistant', tokens: { input: 0, output: 0 }, time: { created: 1 } } }, + { id: 'msg_skip', data: { role: 'assistant', cost: 0.05, tokens: { input: 10, output: 10 }, time: { created: 2 } } }, + ], + partsRawByMessageId: new Map(), + })], + context, + seenKeys: seen, + }) + expect(calls).toHaveLength(1) + expect(calls[0]!.inputTokens).toBe(10) + expect(calls[0]!.deduplicationKey).toBe('opencode:sess-a:msg_skip') + expect(seen.has('opencode:sess-a:msg_skip')).toBe(true) + }) +}) + +// ── Cross-cutting ─────────────────────────────────────────────────────────── + +describe('opencode-session rich decode: cross-cutting', () => { + it('cross-run dedup drops a repeated session id', () => { + const seen = new Set() + const first = decodeOpenCodeSession({ + records: [sqliteEnvelope({ + messages: [msg({ role: 'assistant', tokens: { input: 10, output: 10 } })], + parts: [], + })], + context, + seenKeys: seen, + }) + expect(first.calls).toHaveLength(1) + const again = decodeOpenCodeSession({ + records: [sqliteEnvelope({ + messages: [msg({ role: 'assistant', tokens: { input: 10, output: 10 } })], + parts: [], + })], + context, + seenKeys: seen, + }) + expect(again.calls).toEqual([]) + }) + + it('Map partsRawByMessageId does not return prototype members for constructor/__proto__ keys', () => { + const parts = new Map() + expect(parts.get('constructor')).toBeUndefined() + expect(parts.get('__proto__')).toBeUndefined() + }) + + it('toObservations produces a schema-valid envelope', () => { + const { calls } = decodeOpenCodeSession({ + records: [sqliteEnvelope({ + messages: [msg({ role: 'assistant', cost: 0.05, tokens: { input: 10, output: 10 } })], + parts: [], + })], + context, + }) + const { sessions } = toObservations( + { sessionId: 'sess-a', projectPath: '/Users/t/alpha', calls }, + { privacyKey: 'test-privacy-key', provider: 'opencode' }, + ) + const envelope = { + schemaVersion: OBSERVATION_SCHEMA_VERSION, + generator: { name: '@codeburn/core', version: '0.0.0-test' }, + sessions, + } + expect(ObservationEnvelope.safeParse(envelope).success).toBe(true) + }) +}) diff --git a/packages/core/tsup.config.ts b/packages/core/tsup.config.ts index 2dc05d13..93a5f19c 100644 --- a/packages/core/tsup.config.ts +++ b/packages/core/tsup.config.ts @@ -39,6 +39,7 @@ export default defineConfig({ 'src/providers/devin/index.ts', 'src/providers/copilot/index.ts', 'src/providers/vscode-cline/index.ts', + 'src/providers/opencode-session/index.ts', ], format: ['esm'], target: 'node20',