mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-08-18 13:04:32 +00:00
refactor(core): opencode-session shared decode into core — opencode, kilo-code (phase 8, shared batch S2)
Unifies the three-file OpenCode decode family — session-message.ts, sqlite-session-parser.ts, and opencode-file-parser.ts — into a single core module, packages/core/src/providers/opencode-session, exposing a two-arm tagged envelope (kind: 'sqlite' | 'file') over one shared assistant-turn builder. The SQLite driver, both WITH RECURSIVE session_tree queries, blobToText, discovery, and extractBashCommands all stay CLI-side. session-message.ts shrinks to the discovery-side sanitize helper; sqlite-session-parser.ts and opencode-file-parser.ts keep their SQL and directory walks and expose readRecords adapters. No decode logic remains CLI-side in any of the three. kilo-code is now fully converted: both arms — cline task dirs via the batch-S1 core vscode-cline module and SQLite via the new module — run through core decodes from one bridged provider, and the thin createClineParser adapter S1 retained is deleted. The CODEBURN_VERBOSE zero-yield notice is reconstructed host-side by zed-style decode wrappers, byte-identical for both the OpenCode and KiloCode labels, with parseFailCount/roleSkipCount derived from the decode's malformed-json / unknown-shape diagnostics. The session-level SQLite fallback is pre-fetched onto the envelope instead of queried lazily — output-neutral, changing only I/O volume — and keeps its distinct key shape, emitting no skills/subagentTypes keys. Validator fixes on top of the migration: - opencode's file arm emitted a spurious SQLite verbose stderr line, reporting a previous SQLite source's session id and message count, because the readRecords -> decode count handoff was never cleared when switching arms. The handoff is now reset on the file path. Pinned by a new regression test. - The S8 and F9 dedup goldens did not actually pin add-after-build: the SQLite fixture used two different message ids, and both file fixtures were degenerate (the CLI one wrote the same filename twice and silently overwrote; the core one shared a text part across both messages, so the "skipped" message built successfully). All four now fail if seenKeys.add is hoisted above the build. - Added key-presence gates to the goldens, since toEqual accepts a present-but-undefined key: the session-level fallback must omit skills/subagentTypes, a message-arm call per arm must carry them, and fallbackCostUSD must be present for cost 0 and absent for an absent cost. Goldens were captured pre-migration and re-verified against the original tree by restoring 38172892's sources in place; all 36 pass on both sides. Pre-existing issues moved verbatim and left alone: the bare-Record tool map with a truthy hit check in normalizeToolName (prototype leak on names like 'constructor'), opencode.ts's duplicated display-only tool map, and the dead inner role guard in the SQLite arm. Every new lookup introduced by the unification is a Map.
This commit is contained in:
parent
3817289238
commit
4808b115cc
16 changed files with 3512 additions and 438 deletions
|
|
@ -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<KiloRich>({
|
||||
name: PROVIDER_NAME,
|
||||
displayName: 'KiloCode',
|
||||
|
||||
|
|
@ -41,13 +58,59 @@ export function createKiloCodeProvider(overrideDir?: string | string[]): Provide
|
|||
return [...oldSessions, ...dbSessions]
|
||||
},
|
||||
|
||||
createSessionParser(source: SessionSource, seenKeys: Set<string>): 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()
|
||||
|
|
|
|||
|
|
@ -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/<projectID>/<sessionID>.json session metadata
|
||||
// storage/message/<sessionID>/<messageID>.json one file per message
|
||||
// storage/part/<messageID>/<partID>.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<T>(path: string): Promise<T | null> {
|
|||
}
|
||||
}
|
||||
|
||||
async function readParts(dataDir: string, messageId: string): Promise<PartData[]> {
|
||||
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<PartData>(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<string>,
|
||||
dataDir: string,
|
||||
providerName: string,
|
||||
): SessionParser {
|
||||
return {
|
||||
async *parse(): AsyncGenerator<ParsedProviderCall> {
|
||||
const meta = await readJson<SessionMeta>(source.path)
|
||||
if (!meta?.id) return
|
||||
const sessionId = meta.id
|
||||
): Promise<unknown[] | null> {
|
||||
const meta = await readJson<SessionMeta>(source.path)
|
||||
if (!meta?.id) return null
|
||||
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<FileMessageData>(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
|
||||
}
|
||||
|
||||
if (data.role !== 'assistant' && data.role !== 'model') continue
|
||||
|
||||
const dedupKey = `${providerName}:${sessionId}:${id}`
|
||||
if (seenKeys.has(dedupKey)) continue
|
||||
|
||||
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
|
||||
|
||||
seenKeys.add(dedupKey)
|
||||
yield call
|
||||
}
|
||||
},
|
||||
const messageDir = join(dataDir, 'storage', 'message', sessionId)
|
||||
let messageFiles: string[]
|
||||
try {
|
||||
messageFiles = await readdir(messageDir)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
// 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<FileMessageData>(join(messageDir, f))
|
||||
if (!data) continue
|
||||
messages.push({ id: data.id ?? f.replace(/\.json$/, ''), data })
|
||||
}
|
||||
|
||||
// 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<string, string[]>()
|
||||
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,
|
||||
}]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string, string> = {
|
||||
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<OpenCodeSessionDecodedCall>({
|
||||
name: 'opencode',
|
||||
displayName: 'OpenCode',
|
||||
|
||||
|
|
@ -91,13 +125,43 @@ export function createOpenCodeProvider(dataDir?: string): Provider {
|
|||
return [...fileSessions, ...sqliteSessions]
|
||||
},
|
||||
|
||||
createSessionParser(source: SessionSource, seenKeys: Set<string>): 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()
|
||||
|
|
|
|||
|
|
@ -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<string, string> = {
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string>,
|
||||
config: SqliteProviderConfig,
|
||||
): SessionParser {
|
||||
return {
|
||||
async *parse(): AsyncGenerator<ParsedProviderCall> {
|
||||
if (!isSqliteAvailable()) {
|
||||
process.stderr.write(getSqliteLoadError() + '\n')
|
||||
return
|
||||
}
|
||||
): Promise<{ records: unknown[]; messageCount: number; partCount: number } | null> {
|
||||
if (!isSqliteAvailable()) {
|
||||
process.stderr.write(getSqliteLoadError() + '\n')
|
||||
return null
|
||||
}
|
||||
|
||||
const segments = source.path.split(':')
|
||||
const sessionId = segments[segments.length - 1]!
|
||||
const dbPath = segments.slice(0, -1).join(':')
|
||||
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
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
try {
|
||||
const schema = validateSchemaDetailed(db)
|
||||
if (!schema.ok) {
|
||||
warnUnrecognizedSchemaOnce(config.displayName, schema.missing)
|
||||
return
|
||||
}
|
||||
try {
|
||||
const schema = validateSchemaDetailed(db)
|
||||
if (!schema.ok) {
|
||||
warnUnrecognizedSchemaOnce(config.displayName, schema.missing)
|
||||
return null
|
||||
}
|
||||
|
||||
const messages = db.query<MessageRow>(
|
||||
`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 messages = db.query<MessageRow>(
|
||||
`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<PartRow>(
|
||||
`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 parts = db.query<PartRow>(
|
||||
`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<string, PartData[]>()
|
||||
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 sessionTokens = tryQuerySessionTokens(db, sessionId)
|
||||
|
||||
const currentUserMessageBySession = new Map<string, string>()
|
||||
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
|
||||
}
|
||||
|
||||
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') {
|
||||
if (data.role !== 'user') roleSkipCount++
|
||||
continue
|
||||
}
|
||||
|
||||
const dedupKey = `${config.providerName}:${msg.session_id}:${msg.id}`
|
||||
if (seenKeys.has(dedupKey)) continue
|
||||
|
||||
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
|
||||
|
||||
seenKeys.add(dedupKey)
|
||||
yieldCount++
|
||||
yield call
|
||||
}
|
||||
|
||||
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++
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<string>,
|
||||
providerName: string,
|
||||
fallbackModel = 'cline-auto',
|
||||
): SessionParser {
|
||||
return {
|
||||
async *parse(): AsyncGenerator<ParsedProviderCall> {
|
||||
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)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
1926
packages/cli/tests/providers/opencode-session-shared-bridge.test.ts
Normal file
1926
packages/cli/tests/providers/opencode-session-shared-bridge.test.ts
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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": [
|
||||
|
|
|
|||
341
packages/core/src/providers/opencode-session/decode.ts
Normal file
341
packages/core/src/providers/opencode-session/decode.ts
Normal file
|
|
@ -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<string, string> = {
|
||||
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<string>
|
||||
}
|
||||
|
||||
export type OpenCodeSessionDecodeResult = {
|
||||
calls: OpenCodeSessionDecodedCall[]
|
||||
diagnostics: RecordDiagnostic[]
|
||||
}
|
||||
|
||||
function decodeSqlite(
|
||||
envelope: Extract<OpenCodeSessionEnvelope, { kind: 'sqlite' }>,
|
||||
providerName: string,
|
||||
seenKeys: Set<string>,
|
||||
): OpenCodeSessionDecodeResult {
|
||||
const calls: OpenCodeSessionDecodedCall[] = []
|
||||
const diagnostics: RecordDiagnostic[] = []
|
||||
|
||||
const partsByMsg = new Map<string, PartData[]>()
|
||||
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<string, string>()
|
||||
|
||||
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<OpenCodeSessionEnvelope, { kind: 'file' }>,
|
||||
providerName: string,
|
||||
seenKeys: Set<string>,
|
||||
): 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<string>()
|
||||
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' }] }
|
||||
}
|
||||
}
|
||||
30
packages/core/src/providers/opencode-session/index.ts
Normal file
30
packages/core/src/providers/opencode-session/index.ts
Normal file
|
|
@ -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'
|
||||
84
packages/core/src/providers/opencode-session/observations.ts
Normal file
84
packages/core/src/providers/opencode-session/observations.ts
Normal file
|
|
@ -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: [] }
|
||||
}
|
||||
113
packages/core/src/providers/opencode-session/types.ts
Normal file
113
packages/core/src/providers/opencode-session/types.ts
Normal file
|
|
@ -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<string, string[]>
|
||||
/** 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
|
||||
}
|
||||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
636
packages/core/tests/providers/opencode-session-decode.test.ts
Normal file
636
packages/core/tests/providers/opencode-session-decode.test.ts
Normal file
|
|
@ -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<OpenCodeSessionEnvelope, { kind: 'sqlite' }>> = {}): Extract<OpenCodeSessionEnvelope, { kind: 'sqlite' }> {
|
||||
return {
|
||||
kind: 'sqlite',
|
||||
sessionId: 'sess-a',
|
||||
messages: [],
|
||||
parts: [],
|
||||
sessionTokens: null,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function fileEnvelope(overrides: Partial<Extract<OpenCodeSessionEnvelope, { kind: 'file' }>> = {}): Extract<OpenCodeSessionEnvelope, { kind: 'file' }> {
|
||||
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<string>()
|
||||
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<string, string[]>([
|
||||
['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<string>()
|
||||
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<string>()
|
||||
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<string, string[]>()
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue