mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-08-06 23:24:32 +00:00
refactor(core): tail migrations — crush, zcode, zed, forge, goose (phase 8, sqlite batch 1)
Category B (sqlite) variant of the bridge migration: the sqlite driver and every
SQL query stay CLI-side. Each provider's `readRecords` opens the database, runs
the same queries as before, and hands the resulting rows (blob and all) to a
pure core decoder; `toProviderCall` maps the rich, cost-free decode back onto
ParsedProviderCall, where cost re-enters via the parser.ts pricing pass.
Per provider:
- crush: session row + dominant-model query -> one combined record. Crush stores
cost in dollars, so a row with cost > 0 carries `measuredCostUSD` (costBasis
'measured'); a zero-cost row falls back to token estimation, arm order intact.
- zcode: model_usage + tool_usage row sets -> one composite record. Each turn's
tools still attach to the first non-skipped usage row of that turn only.
- zed: threads rows handed over compressed; zstd decompression, JSON parsing and
per-request/cumulative-remainder accounting are pure. The Node >= 22.15 zstd
capability check stays host-side.
- forge: conversation row handed over with `context` still serialized; JSON
parsing and per-message decode are pure. Bash base-name extraction (and its
strip-ansi dependency) stays CLI-side over the decoder's raw command strings.
- goose: session + assistant tool-message + first-user-message rows, BLOB
columns pre-converted to text host-side, bundled into one composite record.
Validator fixes (original behavior is the authority):
- forge: the draft replaced the pre-migration `mapToolName` switch with an
object-literal lookup. Tool names come straight from conversation JSON, so
names colliding with Object.prototype members ("constructor", "toString",
"__proto__", "hasOwnProperty") resolved to inherited Functions / the prototype
object and were pushed into `tools` as non-strings instead of falling through
to the identity default. Restored the switch and pinned the arm in the fixture.
- zed: the draft routed the "skipped N unreadable Zed threads" notice into
record diagnostics, which the bridge discards, silently dropping a warning the
pre-migration decode printed. Re-emitted host-side from the diagnostics count
and pinned with a stderr assertion.
- Fixture coverage extended for the arms that were regression-blind: forge's
prototype-named tool calls, zed's aggregate stderr line, and goose's
single-turn `toolSequence` omission plus the unparseable-timestamp fallback.
Parity was verified independently of the bridge tests with a git-show harness
that runs the same fixtures through the pre-migration provider files and asserts
field-for-field equality, including the extra arms above.
This commit is contained in:
parent
104365414f
commit
afaf9ffc8a
39 changed files with 3626 additions and 775 deletions
|
|
@ -2,8 +2,12 @@ import { readFile } from 'fs/promises'
|
|||
import { join, resolve } from 'path'
|
||||
import { homedir, platform } from 'os'
|
||||
|
||||
import { decodeCrush } from '@codeburn/core/providers/crush'
|
||||
import type { CrushDecodedCall, CrushRawRecord } from '@codeburn/core/providers/crush'
|
||||
|
||||
import { isSqliteAvailable, getSqliteLoadError, openDatabase, type SqliteDatabase } from '../sqlite.js'
|
||||
import type { Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js'
|
||||
import { createBridgedProvider } from './bridge.js'
|
||||
import type { Provider, SessionSource, ParsedProviderCall } from './types.js'
|
||||
|
||||
/// Crush stores per-project SQLite databases discovered through a JSON registry.
|
||||
/// We only read both. Schema source: charmbracelet/crush
|
||||
|
|
@ -93,13 +97,6 @@ function validateSchema(db: SqliteDatabase): boolean {
|
|||
}
|
||||
}
|
||||
|
||||
function epochSecondsToIso(epochSeconds: number | null): string {
|
||||
if (epochSeconds === null || !Number.isFinite(epochSeconds)) {
|
||||
return new Date(0).toISOString()
|
||||
}
|
||||
return new Date(epochSeconds * 1000).toISOString()
|
||||
}
|
||||
|
||||
function dominantModel(db: SqliteDatabase, sessionId: string): string {
|
||||
try {
|
||||
const rows = db.query<{ model: string | null }>(
|
||||
|
|
@ -117,80 +114,34 @@ function dominantModel(db: SqliteDatabase, sessionId: string): string {
|
|||
}
|
||||
}
|
||||
|
||||
function createParser(source: SessionSource, seenKeys: Set<string>): SessionParser {
|
||||
// Map one rich, cost-free-or-measured decoder call into the host's
|
||||
// ParsedProviderCall. Crush already stores cost in dollars, so a row with
|
||||
// `measuredCostUSD` maps to `costBasis: 'measured'` (the pricing pass leaves it
|
||||
// untouched); otherwise the row falls back to token-based estimation. Crush
|
||||
// never captures a user message, so it is hardcoded empty here rather than
|
||||
// carried through the rich decode.
|
||||
function toProviderCall(rich: CrushDecodedCall): ParsedProviderCall {
|
||||
const measured = rich.measuredCostUSD !== undefined
|
||||
return {
|
||||
async *parse(): AsyncGenerator<ParsedProviderCall> {
|
||||
if (!isSqliteAvailable()) {
|
||||
process.stderr.write(getSqliteLoadError() + '\n')
|
||||
return
|
||||
}
|
||||
|
||||
// Source paths are encoded as `<dbPath>:<sessionId>`. Split from the
|
||||
// right because dbPath may contain a colon on Windows (drive letter).
|
||||
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 Crush database: ${err instanceof Error ? err.message : err}\n`,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
if (!validateSchema(db)) return
|
||||
|
||||
const rows = db.query<SessionRow>(
|
||||
`SELECT id, prompt_tokens, completion_tokens, cost, created_at, updated_at, message_count
|
||||
FROM sessions
|
||||
WHERE id = ? AND parent_session_id IS NULL`,
|
||||
[sessionId],
|
||||
)
|
||||
if (rows.length === 0) return
|
||||
const session = rows[0]!
|
||||
|
||||
const inputTokens = session.prompt_tokens ?? 0
|
||||
const outputTokens = session.completion_tokens ?? 0
|
||||
const cost = session.cost ?? 0
|
||||
if (inputTokens === 0 && outputTokens === 0 && cost === 0) return
|
||||
|
||||
const dedupKey = `crush:${sessionId}`
|
||||
if (seenKeys.has(dedupKey)) return
|
||||
seenKeys.add(dedupKey)
|
||||
|
||||
const model = dominantModel(db, sessionId)
|
||||
// Crush already records cost in dollars; trust it. Fall back to
|
||||
// host-side pricing-table calculation only when the row is missing a cost.
|
||||
|
||||
yield {
|
||||
provider: 'crush',
|
||||
model,
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
cacheCreationInputTokens: 0,
|
||||
cacheReadInputTokens: 0,
|
||||
cachedInputTokens: 0,
|
||||
reasoningTokens: 0,
|
||||
webSearchRequests: 0,
|
||||
...(cost > 0
|
||||
? { costUSD: cost, costBasis: 'measured' as const }
|
||||
: { costBasis: 'estimated' as const }),
|
||||
tools: [],
|
||||
bashCommands: [],
|
||||
timestamp: epochSecondsToIso(session.updated_at ?? session.created_at),
|
||||
speed: 'standard',
|
||||
deduplicationKey: dedupKey,
|
||||
userMessage: '',
|
||||
sessionId,
|
||||
}
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
},
|
||||
provider: 'crush',
|
||||
model: rich.model,
|
||||
inputTokens: rich.inputTokens,
|
||||
outputTokens: rich.outputTokens,
|
||||
cacheCreationInputTokens: rich.cacheCreationInputTokens,
|
||||
cacheReadInputTokens: rich.cacheReadInputTokens,
|
||||
cachedInputTokens: rich.cachedInputTokens,
|
||||
reasoningTokens: rich.reasoningTokens,
|
||||
webSearchRequests: rich.webSearchRequests,
|
||||
...(measured
|
||||
? { costUSD: rich.measuredCostUSD, costBasis: 'measured' as const }
|
||||
: { costBasis: 'estimated' as const }),
|
||||
tools: rich.tools,
|
||||
bashCommands: [],
|
||||
timestamp: rich.timestamp,
|
||||
speed: rich.speed,
|
||||
deduplicationKey: rich.deduplicationKey,
|
||||
userMessage: '',
|
||||
sessionId: rich.sessionId,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -222,7 +173,7 @@ async function discoverFromDb(dbPath: string, project: string): Promise<SessionS
|
|||
}
|
||||
|
||||
export function createCrushProvider(): Provider {
|
||||
return {
|
||||
return createBridgedProvider<CrushDecodedCall>({
|
||||
name: 'crush',
|
||||
displayName: 'Crush',
|
||||
|
||||
|
|
@ -247,10 +198,53 @@ export function createCrushProvider(): Provider {
|
|||
return sources
|
||||
},
|
||||
|
||||
createSessionParser(source: SessionSource, seenKeys: Set<string>): SessionParser {
|
||||
return createParser(source, seenKeys)
|
||||
// I/O adapter: open the db, run the session-row query and the dominant-model
|
||||
// query (both sqlite-side), and hand the core decoder one combined record.
|
||||
async readRecords(source: SessionSource): Promise<unknown[] | null> {
|
||||
if (!isSqliteAvailable()) {
|
||||
process.stderr.write(getSqliteLoadError() + '\n')
|
||||
return null
|
||||
}
|
||||
|
||||
// Source paths are encoded as `<dbPath>:<sessionId>`. Split from the
|
||||
// right because dbPath may contain a colon on Windows (drive letter).
|
||||
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 Crush database: ${err instanceof Error ? err.message : err}\n`,
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
if (!validateSchema(db)) return null
|
||||
|
||||
const rows = db.query<SessionRow>(
|
||||
`SELECT id, prompt_tokens, completion_tokens, cost, created_at, updated_at, message_count
|
||||
FROM sessions
|
||||
WHERE id = ? AND parent_session_id IS NULL`,
|
||||
[sessionId],
|
||||
)
|
||||
if (rows.length === 0) return null
|
||||
const session = rows[0]!
|
||||
|
||||
const model = dominantModel(db, sessionId)
|
||||
const record: CrushRawRecord = { ...session, model }
|
||||
return [record]
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
decode: decodeCrush,
|
||||
toProviderCall,
|
||||
})
|
||||
}
|
||||
|
||||
export const crush = createCrushProvider()
|
||||
|
|
|
|||
|
|
@ -2,18 +2,13 @@ import { existsSync } from 'fs'
|
|||
import { homedir } from 'os'
|
||||
import { join } from 'path'
|
||||
|
||||
import { decodeForge } from '@codeburn/core/providers/forge'
|
||||
import type { ForgeConversationRow, ForgeDecodedCall } from '@codeburn/core/providers/forge'
|
||||
|
||||
import { extractBashCommands } from '../bash-utils.js'
|
||||
import { getSqliteLoadError, isSqliteAvailable, openDatabase, type SqliteDatabase } from '../sqlite.js'
|
||||
import type { ParsedProviderCall, Provider, SessionParser, SessionSource } from './types.js'
|
||||
|
||||
type ConversationRow = {
|
||||
conversation_id: string
|
||||
title: string | null
|
||||
workspace_id: number | string
|
||||
context: string | null
|
||||
created_at: string | null
|
||||
updated_at: string | null
|
||||
}
|
||||
import { createBridgedProvider } from './bridge.js'
|
||||
import type { ParsedProviderCall, Provider, SessionSource } from './types.js'
|
||||
|
||||
type DiscoveryRow = {
|
||||
conversation_id: string
|
||||
|
|
@ -21,18 +16,6 @@ type DiscoveryRow = {
|
|||
workspace_id: string
|
||||
}
|
||||
|
||||
type ContextMessage = {
|
||||
message?: {
|
||||
text?: {
|
||||
role?: unknown
|
||||
content?: unknown
|
||||
model?: unknown
|
||||
tool_calls?: unknown
|
||||
}
|
||||
}
|
||||
usage?: unknown
|
||||
}
|
||||
|
||||
const DEFAULT_DB_PATH = join(homedir(), '.forge', '.forge.db')
|
||||
|
||||
function validateSchema(db: SqliteDatabase): boolean {
|
||||
|
|
@ -44,186 +27,36 @@ function validateSchema(db: SqliteDatabase): boolean {
|
|||
}
|
||||
}
|
||||
|
||||
function sqliteTimestampToIso(value: string | null | undefined): string {
|
||||
if (!value) return new Date(0).toISOString()
|
||||
|
||||
const match = value.match(/^(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2}:\d{2})(?:\.(\d+))?$/)
|
||||
if (match) {
|
||||
const ms = (match[3] ?? '').padEnd(3, '0').slice(0, 3)
|
||||
const parsed = new Date(`${match[1]}T${match[2]}.${ms}Z`)
|
||||
if (!Number.isNaN(parsed.getTime())) return parsed.toISOString()
|
||||
}
|
||||
|
||||
const parsed = new Date(value)
|
||||
return Number.isNaN(parsed.getTime()) ? new Date(0).toISOString() : parsed.toISOString()
|
||||
}
|
||||
|
||||
function actual(value: unknown): number {
|
||||
if (!value || typeof value !== 'object') return 0
|
||||
const raw = (value as Record<string, unknown>)['actual']
|
||||
return typeof raw === 'number' && Number.isFinite(raw) ? raw : 0
|
||||
}
|
||||
|
||||
function usageActual(usage: unknown, key: string): number {
|
||||
if (!usage || typeof usage !== 'object') return 0
|
||||
return actual((usage as Record<string, unknown>)[key])
|
||||
}
|
||||
|
||||
function mapToolName(name: string): string {
|
||||
switch (name) {
|
||||
case 'shell':
|
||||
case 'bash':
|
||||
return 'Bash'
|
||||
case 'read':
|
||||
case 'Read':
|
||||
return 'Read'
|
||||
case 'write':
|
||||
case 'Write':
|
||||
return 'Write'
|
||||
case 'patch':
|
||||
case 'Edit':
|
||||
case 'edit':
|
||||
return 'Edit'
|
||||
case 'fs_search':
|
||||
case 'grep':
|
||||
return 'Grep'
|
||||
case 'task':
|
||||
case 'dispatch_agent':
|
||||
return 'Agent'
|
||||
default:
|
||||
return name
|
||||
}
|
||||
}
|
||||
|
||||
function pushUnique(values: string[], value: string): void {
|
||||
if (!values.includes(value)) values.push(value)
|
||||
}
|
||||
|
||||
function toolCalls(value: unknown): Record<string, unknown>[] {
|
||||
return Array.isArray(value) ? value.filter(v => v && typeof v === 'object') as Record<string, unknown>[] : []
|
||||
}
|
||||
|
||||
function extractToolsAndCommands(calls: Record<string, unknown>[]): { tools: string[]; bashCommands: string[]; firstCallId?: string } {
|
||||
const tools: string[] = []
|
||||
const bashCommands: string[] = []
|
||||
let firstCallId: string | undefined
|
||||
|
||||
for (const call of calls) {
|
||||
const rawName = call['name']
|
||||
if (typeof rawName !== 'string') continue
|
||||
if (!firstCallId && typeof call['call_id'] === 'string') firstCallId = call['call_id']
|
||||
|
||||
const tool = mapToolName(rawName)
|
||||
pushUnique(tools, tool)
|
||||
|
||||
if (tool === 'Bash') {
|
||||
const args = call['arguments']
|
||||
if (args && typeof args === 'object') {
|
||||
const command = (args as Record<string, unknown>)['command']
|
||||
if (typeof command === 'string') {
|
||||
for (const cmd of extractBashCommands(command)) pushUnique(bashCommands, cmd)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { tools, bashCommands, firstCallId }
|
||||
}
|
||||
|
||||
function splitSourcePath(path: string): { dbPath: string; conversationId: string } | null {
|
||||
const idx = path.lastIndexOf(':')
|
||||
if (idx < 0) return null
|
||||
return { dbPath: path.slice(0, idx), conversationId: path.slice(idx + 1) }
|
||||
}
|
||||
|
||||
function createParser(source: SessionSource, seenKeys: Set<string>): SessionParser {
|
||||
// Map one rich, cost-free decoder call into the host's ParsedProviderCall. Cost
|
||||
// re-enters here: `costBasis: 'estimated'` marks the call so the parser.ts
|
||||
// pricing pass fills `costUSD` from the token buckets. Bash base-name
|
||||
// extraction (and its `strip-ansi` dependency) stays CLI-side: the core
|
||||
// decoder carries the raw command strings; the host reduces them here.
|
||||
function toProviderCall(rich: ForgeDecodedCall): ParsedProviderCall {
|
||||
return {
|
||||
async *parse(): AsyncGenerator<ParsedProviderCall> {
|
||||
if (!isSqliteAvailable()) {
|
||||
process.stderr.write(getSqliteLoadError() + '\n')
|
||||
return
|
||||
}
|
||||
|
||||
const split = splitSourcePath(source.path)
|
||||
if (!split) return
|
||||
|
||||
let db: SqliteDatabase
|
||||
try {
|
||||
db = openDatabase(split.dbPath)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
if (!validateSchema(db)) return
|
||||
const rows = db.query<ConversationRow>(
|
||||
`SELECT conversation_id, title, CAST(workspace_id AS TEXT) AS workspace_id, context, created_at, updated_at
|
||||
FROM conversations
|
||||
WHERE conversation_id = ?`,
|
||||
[split.conversationId],
|
||||
)
|
||||
const row = rows[0]
|
||||
if (!row?.context) return
|
||||
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(row.context)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
const messages = Array.isArray((parsed as { messages?: unknown }).messages)
|
||||
? (parsed as { messages: ContextMessage[] }).messages
|
||||
: []
|
||||
|
||||
let userMessage = ''
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
const text = messages[i]?.message?.text
|
||||
const role = typeof text?.role === 'string' ? text.role.toLowerCase() : ''
|
||||
const content = typeof text?.content === 'string' ? text.content : ''
|
||||
|
||||
if (role === 'user') {
|
||||
userMessage = content.length > 500 ? content.slice(0, 500) : content
|
||||
continue
|
||||
}
|
||||
if (role !== 'assistant') continue
|
||||
|
||||
const promptTokens = usageActual(messages[i]?.usage, 'prompt_tokens')
|
||||
const outputTokens = usageActual(messages[i]?.usage, 'completion_tokens')
|
||||
const cachedInputTokens = usageActual(messages[i]?.usage, 'cached_tokens')
|
||||
const inputTokens = Math.max(0, promptTokens - cachedInputTokens)
|
||||
if (inputTokens === 0 && outputTokens === 0) continue
|
||||
|
||||
const model = typeof text?.model === 'string' ? text.model : 'unknown'
|
||||
const calls = toolCalls(text?.tool_calls)
|
||||
const { tools, bashCommands, firstCallId } = extractToolsAndCommands(calls)
|
||||
const stableId = firstCallId ?? `${model}:${promptTokens}:${outputTokens}:${i}`
|
||||
const deduplicationKey = `forge:${row.conversation_id}:${stableId}`
|
||||
if (seenKeys.has(deduplicationKey)) continue
|
||||
seenKeys.add(deduplicationKey)
|
||||
yield {
|
||||
provider: 'forge',
|
||||
model,
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
cacheCreationInputTokens: 0,
|
||||
cacheReadInputTokens: cachedInputTokens,
|
||||
cachedInputTokens,
|
||||
reasoningTokens: 0,
|
||||
webSearchRequests: 0,
|
||||
costBasis: 'estimated',
|
||||
tools,
|
||||
bashCommands,
|
||||
timestamp: sqliteTimestampToIso(row.updated_at ?? row.created_at),
|
||||
speed: 'standard',
|
||||
deduplicationKey,
|
||||
userMessage,
|
||||
sessionId: row.conversation_id,
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
},
|
||||
provider: 'forge',
|
||||
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',
|
||||
tools: rich.tools,
|
||||
bashCommands: [...new Set(rich.rawBashCommands.flatMap(c => extractBashCommands(c)))],
|
||||
timestamp: rich.timestamp,
|
||||
speed: rich.speed,
|
||||
deduplicationKey: rich.deduplicationKey,
|
||||
userMessage: rich.userMessage,
|
||||
sessionId: rich.sessionId,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -257,7 +90,7 @@ async function discoverFromDb(dbPath: string): Promise<SessionSource[]> {
|
|||
}
|
||||
|
||||
export function createForgeProvider(dbPath = DEFAULT_DB_PATH): Provider {
|
||||
return {
|
||||
return createBridgedProvider<ForgeDecodedCall>({
|
||||
name: 'forge',
|
||||
displayName: 'Forge',
|
||||
|
||||
|
|
@ -274,10 +107,43 @@ export function createForgeProvider(dbPath = DEFAULT_DB_PATH): Provider {
|
|||
return discoverFromDb(dbPath)
|
||||
},
|
||||
|
||||
createSessionParser(source: SessionSource, seenKeys: Set<string>): SessionParser {
|
||||
return createParser(source, seenKeys)
|
||||
// I/O adapter: open the db and run the conversation-row query (sqlite-side).
|
||||
// The `context` JSON blob is handed to the core decoder still serialized;
|
||||
// parsing it is decode logic.
|
||||
async readRecords(source: SessionSource): Promise<unknown[] | null> {
|
||||
if (!isSqliteAvailable()) {
|
||||
process.stderr.write(getSqliteLoadError() + '\n')
|
||||
return null
|
||||
}
|
||||
|
||||
const split = splitSourcePath(source.path)
|
||||
if (!split) return null
|
||||
|
||||
let db: SqliteDatabase
|
||||
try {
|
||||
db = openDatabase(split.dbPath)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
if (!validateSchema(db)) return null
|
||||
const rows = db.query<ForgeConversationRow>(
|
||||
`SELECT conversation_id, title, CAST(workspace_id AS TEXT) AS workspace_id, context, created_at, updated_at
|
||||
FROM conversations
|
||||
WHERE conversation_id = ?`,
|
||||
[split.conversationId],
|
||||
)
|
||||
if (rows.length === 0) return null
|
||||
return [rows[0]!]
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
decode: decodeForge,
|
||||
toProviderCall,
|
||||
})
|
||||
}
|
||||
|
||||
export const forge = createForgeProvider()
|
||||
|
|
|
|||
|
|
@ -1,13 +1,16 @@
|
|||
import { join } from 'path'
|
||||
import { homedir, platform } from 'os'
|
||||
|
||||
import { decodeGoose, gooseToolNameMap } from '@codeburn/core/providers/goose'
|
||||
import type { GooseDecodedCall, GooseMessageRow, GooseSessionRecords, GooseSessionRow } from '@codeburn/core/providers/goose'
|
||||
|
||||
import { getShortModelName } from '../models.js'
|
||||
import { extractBashCommands } from '../bash-utils.js'
|
||||
import { isSqliteAvailable, getSqliteLoadError, openDatabase, blobToText, type SqliteDatabase } from '../sqlite.js'
|
||||
import type { ToolCall } from '../types.js'
|
||||
import type { Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js'
|
||||
import { createBridgedProvider } from './bridge.js'
|
||||
import type { Provider, SessionSource, ParsedProviderCall } from './types.js'
|
||||
|
||||
type SessionRow = {
|
||||
type RawSessionRow = {
|
||||
id: string
|
||||
name: string
|
||||
working_dir: string | null
|
||||
|
|
@ -19,33 +22,6 @@ type SessionRow = {
|
|||
model_config_json: Uint8Array | string | null
|
||||
}
|
||||
|
||||
type ModelConfig = {
|
||||
model_name?: string
|
||||
reasoning?: boolean
|
||||
}
|
||||
|
||||
type MessageRow = {
|
||||
message_id: string
|
||||
role: string
|
||||
content_json: Uint8Array | string
|
||||
created_timestamp: number
|
||||
}
|
||||
|
||||
type ContentItem = {
|
||||
type: string
|
||||
toolCall?: { value?: { name?: string; arguments?: Record<string, unknown> } }
|
||||
}
|
||||
|
||||
const toolNameMap: Record<string, string> = {
|
||||
developer__shell: 'Bash',
|
||||
developer__text_editor: 'Edit',
|
||||
developer__read_file: 'Read',
|
||||
developer__write_file: 'Write',
|
||||
developer__list_directory: 'LS',
|
||||
developer__search_files: 'Grep',
|
||||
computercontroller__shell: 'Bash',
|
||||
}
|
||||
|
||||
function sanitize(dir: string): string {
|
||||
return dir.replace(/^\//, '').replace(/\//g, '-')
|
||||
}
|
||||
|
|
@ -72,157 +48,37 @@ function validateSchema(db: SqliteDatabase): boolean {
|
|||
}
|
||||
}
|
||||
|
||||
function parseModelConfig(raw: string | null): ModelConfig {
|
||||
if (!raw) return {}
|
||||
try {
|
||||
return JSON.parse(raw) as ModelConfig
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
const SESSION_COLUMNS = 'id, name, working_dir, created_at, updated_at, accumulated_input_tokens, accumulated_output_tokens, provider_name, CAST(model_config_json AS BLOB) AS model_config_json'
|
||||
|
||||
function extractToolsFromMessages(db: SqliteDatabase, sessionId: string): { tools: string[]; bashCommands: string[]; toolSequence: ToolCall[][] } {
|
||||
const tools: string[] = []
|
||||
const bashCommands: string[] = []
|
||||
const seen = new Set<string>()
|
||||
const toolSequence: ToolCall[][] = []
|
||||
|
||||
try {
|
||||
const rows = db.query<{ content_json: Uint8Array | string }>(
|
||||
"SELECT CAST(content_json AS BLOB) AS content_json FROM messages WHERE session_id = ? AND role = 'assistant' AND content_json LIKE '%toolRequest%' ORDER BY created_timestamp ASC",
|
||||
[sessionId],
|
||||
)
|
||||
|
||||
for (const row of rows) {
|
||||
let items: ContentItem[]
|
||||
try {
|
||||
items = JSON.parse(blobToText(row.content_json)) as ContentItem[]
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
const msgCalls: ToolCall[] = []
|
||||
for (const item of items) {
|
||||
if (item.type !== 'toolRequest') continue
|
||||
const rawName = item.toolCall?.value?.name ?? ''
|
||||
if (!rawName) continue
|
||||
const mapped = toolNameMap[rawName] ?? rawName.split('__').pop() ?? rawName
|
||||
if (!seen.has(mapped)) {
|
||||
seen.add(mapped)
|
||||
tools.push(mapped)
|
||||
}
|
||||
const call: ToolCall = { tool: mapped }
|
||||
const args = item.toolCall?.value?.arguments
|
||||
if (args && typeof args === 'object') {
|
||||
const fp = (args as Record<string, unknown>)['file_path']
|
||||
if (typeof fp === 'string') call.file = fp
|
||||
const cmd = (args as Record<string, unknown>)['command']
|
||||
if (typeof cmd === 'string') call.command = cmd
|
||||
}
|
||||
msgCalls.push(call)
|
||||
if (mapped === 'Bash') {
|
||||
const cmd = item.toolCall?.value?.arguments?.command
|
||||
if (typeof cmd === 'string') {
|
||||
for (const c of extractBashCommands(cmd)) {
|
||||
if (!bashCommands.includes(c)) bashCommands.push(c)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (msgCalls.length > 0) toolSequence.push(msgCalls)
|
||||
}
|
||||
} catch { /* best-effort */ }
|
||||
|
||||
return { tools, bashCommands, toolSequence }
|
||||
}
|
||||
|
||||
function getFirstUserMessage(db: SqliteDatabase, sessionId: string): string {
|
||||
try {
|
||||
const rows = db.query<{ content_json: Uint8Array | string }>(
|
||||
"SELECT CAST(content_json AS BLOB) AS content_json FROM messages WHERE session_id = ? AND role = 'user' ORDER BY created_timestamp ASC LIMIT 1",
|
||||
[sessionId],
|
||||
)
|
||||
if (rows.length === 0) return ''
|
||||
const items = JSON.parse(blobToText(rows[0]!.content_json)) as ContentItem[]
|
||||
const text = items.find(i => i.type === 'text') as { text?: string } | undefined
|
||||
return (text?.text ?? '').slice(0, 500)
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
function createParser(source: SessionSource, seenKeys: Set<string>): SessionParser {
|
||||
// Map one rich, cost-free decoder call into the host's ParsedProviderCall. Cost
|
||||
// re-enters here: `costBasis: 'estimated'` marks the call so the parser.ts
|
||||
// pricing pass fills `costUSD` from the token buckets. Bash base-name
|
||||
// extraction (and its `strip-ansi` dependency) stays CLI-side: the core
|
||||
// decoder carries the raw command strings; the host reduces them here.
|
||||
function toProviderCall(rich: GooseDecodedCall): ParsedProviderCall {
|
||||
return {
|
||||
async *parse(): AsyncGenerator<ParsedProviderCall> {
|
||||
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 Goose database: ${err instanceof Error ? err.message : err}\n`)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
if (!validateSchema(db)) return
|
||||
|
||||
const rows = db.query<SessionRow>(
|
||||
'SELECT id, name, working_dir, created_at, updated_at, accumulated_input_tokens, accumulated_output_tokens, provider_name, CAST(model_config_json AS BLOB) AS model_config_json FROM sessions WHERE id = ?',
|
||||
[sessionId],
|
||||
)
|
||||
if (rows.length === 0) return
|
||||
|
||||
const session = rows[0]!
|
||||
const inputTokens = session.accumulated_input_tokens ?? 0
|
||||
const outputTokens = session.accumulated_output_tokens ?? 0
|
||||
if (inputTokens === 0 && outputTokens === 0) return
|
||||
|
||||
const dedupKey = `goose:${sessionId}`
|
||||
if (seenKeys.has(dedupKey)) return
|
||||
seenKeys.add(dedupKey)
|
||||
|
||||
const config = parseModelConfig(blobToText(session.model_config_json))
|
||||
const model = config.model_name ?? 'unknown'
|
||||
|
||||
const { tools, bashCommands, toolSequence } = extractToolsFromMessages(db, sessionId)
|
||||
const userMessage = getFirstUserMessage(db, sessionId)
|
||||
|
||||
const raw = session.updated_at || session.created_at || ''
|
||||
let ts = new Date(raw)
|
||||
if (isNaN(ts.getTime())) ts = new Date(raw + 'Z')
|
||||
if (isNaN(ts.getTime())) ts = new Date()
|
||||
|
||||
yield {
|
||||
provider: 'goose',
|
||||
model,
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
cacheCreationInputTokens: 0,
|
||||
cacheReadInputTokens: 0,
|
||||
cachedInputTokens: 0,
|
||||
reasoningTokens: 0,
|
||||
webSearchRequests: 0,
|
||||
costBasis: 'estimated',
|
||||
tools,
|
||||
bashCommands,
|
||||
toolSequence: toolSequence.length > 1 ? toolSequence : undefined,
|
||||
timestamp: ts.toISOString(),
|
||||
speed: 'standard',
|
||||
deduplicationKey: dedupKey,
|
||||
userMessage,
|
||||
sessionId,
|
||||
}
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
},
|
||||
provider: 'goose',
|
||||
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',
|
||||
tools: rich.tools,
|
||||
bashCommands: [...new Set(rich.rawBashCommands.flatMap(c => extractBashCommands(c)))],
|
||||
toolSequence: rich.toolSequence,
|
||||
// The pre-migration decode fell back to `new Date()` (the current time)
|
||||
// when both updated_at/created_at were unparseable. That clock read can't
|
||||
// live in the pure core decoder, so decodeGoose emits '' in that case and
|
||||
// the fallback is applied here instead.
|
||||
timestamp: rich.timestamp || new Date().toISOString(),
|
||||
speed: rich.speed,
|
||||
deduplicationKey: rich.deduplicationKey,
|
||||
userMessage: rich.userMessage,
|
||||
sessionId: rich.sessionId,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -235,8 +91,8 @@ async function discoverFromDb(dbPath: string): Promise<SessionSource[]> {
|
|||
}
|
||||
|
||||
try {
|
||||
const rows = db.query<SessionRow>(
|
||||
'SELECT id, name, working_dir, created_at, updated_at, accumulated_input_tokens, accumulated_output_tokens, provider_name, CAST(model_config_json AS BLOB) AS model_config_json FROM sessions ORDER BY updated_at DESC',
|
||||
const rows = db.query<RawSessionRow>(
|
||||
`SELECT ${SESSION_COLUMNS} FROM sessions ORDER BY updated_at DESC`,
|
||||
)
|
||||
|
||||
return rows
|
||||
|
|
@ -262,7 +118,7 @@ const modelDisplayNames: Record<string, string> = {
|
|||
}
|
||||
|
||||
export function createGooseProvider(): Provider {
|
||||
return {
|
||||
return createBridgedProvider<GooseDecodedCall>({
|
||||
name: 'goose',
|
||||
displayName: 'Goose',
|
||||
|
||||
|
|
@ -271,7 +127,7 @@ export function createGooseProvider(): Provider {
|
|||
},
|
||||
|
||||
toolDisplayName(rawTool: string): string {
|
||||
return toolNameMap[rawTool] ?? rawTool
|
||||
return gooseToolNameMap[rawTool] ?? rawTool
|
||||
},
|
||||
|
||||
async discoverSessions(): Promise<SessionSource[]> {
|
||||
|
|
@ -280,10 +136,81 @@ export function createGooseProvider(): Provider {
|
|||
return discoverFromDb(dbPath)
|
||||
},
|
||||
|
||||
createSessionParser(source: SessionSource, seenKeys: Set<string>): SessionParser {
|
||||
return createParser(source, seenKeys)
|
||||
// I/O adapter: open the db and run the session query, the assistant
|
||||
// tool-message query, and the first-user-message query (all sqlite-side),
|
||||
// converting each BLOB column to text (the same charset-safe conversion
|
||||
// fs-utils performs for file reads). The core decoder gets one composite
|
||||
// record bundling all three; JSON parsing and per-message decode are pure
|
||||
// and happen there.
|
||||
async readRecords(source: SessionSource): Promise<unknown[] | 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(':')
|
||||
|
||||
let db: SqliteDatabase
|
||||
try {
|
||||
db = openDatabase(dbPath)
|
||||
} catch (err) {
|
||||
process.stderr.write(`codeburn: cannot open Goose database: ${err instanceof Error ? err.message : err}\n`)
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
if (!validateSchema(db)) return null
|
||||
|
||||
const rows = db.query<RawSessionRow>(
|
||||
`SELECT ${SESSION_COLUMNS} FROM sessions WHERE id = ?`,
|
||||
[sessionId],
|
||||
)
|
||||
if (rows.length === 0) return null
|
||||
const raw = rows[0]!
|
||||
|
||||
const session: GooseSessionRow = {
|
||||
id: raw.id,
|
||||
workingDir: raw.working_dir,
|
||||
createdAt: raw.created_at,
|
||||
updatedAt: raw.updated_at,
|
||||
accumulatedInputTokens: raw.accumulated_input_tokens,
|
||||
accumulatedOutputTokens: raw.accumulated_output_tokens,
|
||||
modelConfigJson: blobToText(raw.model_config_json) || null,
|
||||
}
|
||||
|
||||
let assistantToolMessages: GooseMessageRow[] = []
|
||||
let firstUserMessage: GooseMessageRow | null = null
|
||||
try {
|
||||
const toolRows = db.query<{ content_json: Uint8Array | string }>(
|
||||
"SELECT CAST(content_json AS BLOB) AS content_json FROM messages WHERE session_id = ? AND role = 'assistant' AND content_json LIKE '%toolRequest%' ORDER BY created_timestamp ASC",
|
||||
[sessionId],
|
||||
)
|
||||
assistantToolMessages = toolRows.map(r => ({ contentJson: blobToText(r.content_json) }))
|
||||
} catch {
|
||||
// best-effort, matches the pre-migration decode
|
||||
}
|
||||
try {
|
||||
const userRows = db.query<{ content_json: Uint8Array | string }>(
|
||||
"SELECT CAST(content_json AS BLOB) AS content_json FROM messages WHERE session_id = ? AND role = 'user' ORDER BY created_timestamp ASC LIMIT 1",
|
||||
[sessionId],
|
||||
)
|
||||
if (userRows.length > 0) firstUserMessage = { contentJson: blobToText(userRows[0]!.content_json) }
|
||||
} catch {
|
||||
// best-effort, matches the pre-migration decode
|
||||
}
|
||||
|
||||
const record: GooseSessionRecords = { sessionId, session, assistantToolMessages, firstUserMessage }
|
||||
return [record]
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
decode: decodeGoose,
|
||||
toProviderCall,
|
||||
})
|
||||
}
|
||||
|
||||
export const goose = createGooseProvider()
|
||||
|
|
|
|||
|
|
@ -1,8 +1,12 @@
|
|||
import { join } from 'path'
|
||||
import { homedir } from 'os'
|
||||
|
||||
import { decodeZcode } from '@codeburn/core/providers/zcode'
|
||||
import type { ZcodeDecodedCall, ZcodeSessionRecords, ZcodeToolRow, ZcodeUsageRow } from '@codeburn/core/providers/zcode'
|
||||
|
||||
import { isSqliteAvailable, getSqliteLoadError, openDatabase, type SqliteDatabase } from '../sqlite.js'
|
||||
import type { Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js'
|
||||
import { createBridgedProvider } from './bridge.js'
|
||||
import type { Provider, SessionSource, ParsedProviderCall } from './types.js'
|
||||
|
||||
/// ZCode (CLI v0.14.x) records usage in a single SQLite database at
|
||||
/// ~/.zcode/cli/db/db.sqlite. We read it because the other on-disk sources are
|
||||
|
|
@ -16,24 +20,6 @@ type SessionRow = {
|
|||
directory: string
|
||||
}
|
||||
|
||||
type UsageRow = {
|
||||
id: string
|
||||
turn_id: string | null
|
||||
model_id: string
|
||||
input_tokens: number
|
||||
output_tokens: number
|
||||
reasoning_tokens: number
|
||||
cache_creation_input_tokens: number
|
||||
cache_read_input_tokens: number
|
||||
started_at: number
|
||||
completed_at: number | null
|
||||
}
|
||||
|
||||
type ToolRow = {
|
||||
turn_id: string | null
|
||||
tool_name: string
|
||||
}
|
||||
|
||||
function getDbPath(override?: string): string {
|
||||
return override ?? join(homedir(), '.zcode', 'cli', 'db', 'db.sqlite')
|
||||
}
|
||||
|
|
@ -42,11 +28,6 @@ function sanitizeProject(path: string): string {
|
|||
return path.replace(/^\//, '').replace(/\//g, '-')
|
||||
}
|
||||
|
||||
function epochMsToIso(ms: number | null): string {
|
||||
if (ms === null || !Number.isFinite(ms) || ms <= 0) return new Date(0).toISOString()
|
||||
return new Date(ms).toISOString()
|
||||
}
|
||||
|
||||
function validateSchema(db: SqliteDatabase): boolean {
|
||||
try {
|
||||
db.query<{ cnt: number }>('SELECT COUNT(*) as cnt FROM model_usage LIMIT 1')
|
||||
|
|
@ -85,121 +66,37 @@ function discover(dbPath: string): SessionSource[] {
|
|||
}
|
||||
}
|
||||
|
||||
function createParser(source: SessionSource, seenKeys: Set<string>): SessionParser {
|
||||
// Map one rich, cost-free decoder call into the host's ParsedProviderCall. Cost
|
||||
// re-enters here: `costBasis: 'estimated'` marks the call so the parser.ts
|
||||
// pricing pass fills `costUSD` from the token buckets. ZCode never captures a
|
||||
// user message, so it is hardcoded empty here rather than carried through the
|
||||
// rich decode.
|
||||
function toProviderCall(rich: ZcodeDecodedCall): ParsedProviderCall {
|
||||
return {
|
||||
async *parse(): AsyncGenerator<ParsedProviderCall> {
|
||||
if (!isSqliteAvailable()) {
|
||||
process.stderr.write(getSqliteLoadError() + '\n')
|
||||
return
|
||||
}
|
||||
|
||||
// Source paths are `<dbPath>:<sessionId>`. Split from the right so a colon
|
||||
// in the path (Windows drive letter) doesn't corrupt the session id.
|
||||
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 ZCode database: ${err instanceof Error ? err.message : err}\n`,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
if (!validateSchema(db)) return
|
||||
|
||||
// model_usage rows don't link to individual tool calls, only to a turn,
|
||||
// so collect each turn's tools and attach them to one request per turn
|
||||
// (below) to avoid double-counting across a turn's multiple requests.
|
||||
const toolRows = db.query<ToolRow>(
|
||||
`SELECT turn_id, tool_name FROM tool_usage
|
||||
WHERE session_id = ? AND turn_id IS NOT NULL
|
||||
ORDER BY started_at ASC`,
|
||||
[sessionId],
|
||||
)
|
||||
const toolsByTurn = new Map<string, string[]>()
|
||||
for (const tool of toolRows) {
|
||||
if (!tool.turn_id) continue
|
||||
const list = toolsByTurn.get(tool.turn_id) ?? []
|
||||
list.push(tool.tool_name)
|
||||
toolsByTurn.set(tool.turn_id, list)
|
||||
}
|
||||
|
||||
const rows = db.query<UsageRow>(
|
||||
`SELECT id, turn_id, model_id, input_tokens, output_tokens, reasoning_tokens,
|
||||
cache_creation_input_tokens, cache_read_input_tokens, started_at, completed_at
|
||||
FROM model_usage WHERE session_id = ?
|
||||
ORDER BY started_at ASC`,
|
||||
[sessionId],
|
||||
)
|
||||
|
||||
const turnsWithToolsEmitted = new Set<string>()
|
||||
|
||||
for (const row of rows) {
|
||||
const cacheRead = row.cache_read_input_tokens ?? 0
|
||||
const cacheCreation = row.cache_creation_input_tokens ?? 0
|
||||
const output = row.output_tokens ?? 0
|
||||
const reasoning = row.reasoning_tokens ?? 0
|
||||
// ZCode folds cached tokens into input_tokens (OpenAI-style). Split
|
||||
// them back out so fresh input bills at the input rate and cached at
|
||||
// the cache-read rate, matching the pricing table's Anthropic-style
|
||||
// semantics.
|
||||
const freshInput = Math.max(0, (row.input_tokens ?? 0) - cacheRead - cacheCreation)
|
||||
|
||||
if (freshInput === 0 && output === 0 && reasoning === 0 && cacheRead === 0 && cacheCreation === 0) {
|
||||
continue
|
||||
}
|
||||
|
||||
const dedupKey = `zcode:${row.id}`
|
||||
if (seenKeys.has(dedupKey)) continue
|
||||
seenKeys.add(dedupKey)
|
||||
|
||||
let tools: string[] = []
|
||||
if (row.turn_id && !turnsWithToolsEmitted.has(row.turn_id)) {
|
||||
const turnTools = toolsByTurn.get(row.turn_id)
|
||||
if (turnTools && turnTools.length > 0) {
|
||||
tools = turnTools
|
||||
turnsWithToolsEmitted.add(row.turn_id)
|
||||
}
|
||||
}
|
||||
|
||||
const model = row.model_id
|
||||
|
||||
yield {
|
||||
provider: 'zcode',
|
||||
model,
|
||||
inputTokens: freshInput,
|
||||
outputTokens: output,
|
||||
cacheCreationInputTokens: cacheCreation,
|
||||
cacheReadInputTokens: cacheRead,
|
||||
cachedInputTokens: 0,
|
||||
reasoningTokens: reasoning,
|
||||
webSearchRequests: 0,
|
||||
costBasis: 'estimated',
|
||||
tools,
|
||||
bashCommands: [],
|
||||
timestamp: epochMsToIso(row.completed_at ?? row.started_at),
|
||||
speed: 'standard',
|
||||
deduplicationKey: dedupKey,
|
||||
turnId: row.turn_id ?? undefined,
|
||||
userMessage: '',
|
||||
sessionId,
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
},
|
||||
provider: 'zcode',
|
||||
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',
|
||||
tools: rich.tools,
|
||||
bashCommands: [],
|
||||
timestamp: rich.timestamp,
|
||||
speed: rich.speed,
|
||||
deduplicationKey: rich.deduplicationKey,
|
||||
turnId: rich.turnId,
|
||||
userMessage: '',
|
||||
sessionId: rich.sessionId,
|
||||
}
|
||||
}
|
||||
|
||||
export function createZcodeProvider(dbPathOverride?: string): Provider {
|
||||
const dbPath = getDbPath(dbPathOverride)
|
||||
return {
|
||||
return createBridgedProvider<ZcodeDecodedCall>({
|
||||
name: 'zcode',
|
||||
displayName: 'ZCode',
|
||||
|
||||
|
|
@ -216,10 +113,63 @@ export function createZcodeProvider(dbPathOverride?: string): Provider {
|
|||
return discover(dbPath)
|
||||
},
|
||||
|
||||
createSessionParser(source: SessionSource, seenKeys: Set<string>): SessionParser {
|
||||
return createParser(source, seenKeys)
|
||||
// I/O adapter: open the db, run the model_usage and tool_usage queries for
|
||||
// this session (both sqlite-side), and hand the core decoder one combined
|
||||
// record bundling both row sets.
|
||||
async readRecords(source: SessionSource): Promise<unknown[] | null> {
|
||||
if (!isSqliteAvailable()) {
|
||||
process.stderr.write(getSqliteLoadError() + '\n')
|
||||
return null
|
||||
}
|
||||
|
||||
// Source paths are `<dbPath>:<sessionId>`. Split from the right so a colon
|
||||
// in the path (Windows drive letter) doesn't corrupt the session id.
|
||||
const segments = source.path.split(':')
|
||||
const sessionId = segments[segments.length - 1]!
|
||||
const readDbPath = segments.slice(0, -1).join(':')
|
||||
|
||||
let db: SqliteDatabase
|
||||
try {
|
||||
db = openDatabase(readDbPath)
|
||||
} catch (err) {
|
||||
process.stderr.write(
|
||||
`codeburn: cannot open ZCode database: ${err instanceof Error ? err.message : err}\n`,
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
if (!validateSchema(db)) return null
|
||||
|
||||
// model_usage rows don't link to individual tool calls, only to a turn,
|
||||
// so collect each turn's tools and attach them to one request per turn
|
||||
// (in the decoder) to avoid double-counting across a turn's multiple
|
||||
// requests.
|
||||
const toolRows = db.query<ZcodeToolRow>(
|
||||
`SELECT turn_id, tool_name FROM tool_usage
|
||||
WHERE session_id = ? AND turn_id IS NOT NULL
|
||||
ORDER BY started_at ASC`,
|
||||
[sessionId],
|
||||
)
|
||||
|
||||
const usageRows = db.query<ZcodeUsageRow>(
|
||||
`SELECT id, turn_id, model_id, input_tokens, output_tokens, reasoning_tokens,
|
||||
cache_creation_input_tokens, cache_read_input_tokens, started_at, completed_at
|
||||
FROM model_usage WHERE session_id = ?
|
||||
ORDER BY started_at ASC`,
|
||||
[sessionId],
|
||||
)
|
||||
|
||||
const record: ZcodeSessionRecords = { sessionId, usageRows, toolRows }
|
||||
return [record]
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
decode: decodeZcode,
|
||||
toProviderCall,
|
||||
})
|
||||
}
|
||||
|
||||
export const zcode = createZcodeProvider()
|
||||
|
|
|
|||
|
|
@ -3,16 +3,24 @@ import { join } from 'path'
|
|||
import { homedir } from 'os'
|
||||
import zlib from 'zlib'
|
||||
|
||||
import { decodeZed } from '@codeburn/core/providers/zed'
|
||||
import type { ZedDecodedCall, ZedThreadRow } from '@codeburn/core/providers/zed'
|
||||
|
||||
import { getSqliteLoadError, isSqliteAvailable, openDatabase, type SqliteDatabase } from '../sqlite.js'
|
||||
import type { ParsedProviderCall, Provider, SessionParser, SessionSource } from './types.js'
|
||||
import { createBridgedProvider } from './bridge.js'
|
||||
import type { ParsedProviderCall, Provider, SessionSource } from './types.js'
|
||||
|
||||
// Zed's built-in agent stores one row per thread in a single SQLite database;
|
||||
// the `data` blob is zstd-compressed JSON carrying `request_token_usage`
|
||||
// (per-request Anthropic-shaped token counts) and the thread's model.
|
||||
// Format documented in issue #480.
|
||||
// (per-request Anthropic-shaped token counts) and the thread's model. The
|
||||
// sqlite driver and SQL query stay CLI-side; decompression, JSON parsing, and
|
||||
// per-request token accounting moved to @codeburn/core/providers/zed. Format
|
||||
// documented in issue #480.
|
||||
|
||||
// zstd landed in node:zlib in 22.15 / 23.8; the package floor is 22.13, so the
|
||||
// provider degrades with a notice instead of assuming the export exists.
|
||||
// provider degrades with a notice instead of assuming the export exists. This
|
||||
// Node-version capability check is host-side by nature and stays here — the
|
||||
// core decoder is only ever called once this has confirmed support exists.
|
||||
const zstdDecompress = (zlib as { zstdDecompressSync?: (buf: Buffer) => Buffer }).zstdDecompressSync
|
||||
|
||||
function getZedThreadsDbPath(): string {
|
||||
|
|
@ -31,176 +39,34 @@ const THREADS_QUERY = `
|
|||
ORDER BY updated_at ASC
|
||||
`
|
||||
|
||||
type ThreadRow = {
|
||||
id: string
|
||||
summary: string | null
|
||||
updated_at: string | null
|
||||
data_type: string | null
|
||||
data: Uint8Array | null
|
||||
}
|
||||
|
||||
type TokenUsage = {
|
||||
input_tokens?: number
|
||||
output_tokens?: number
|
||||
cache_creation_input_tokens?: number
|
||||
cache_read_input_tokens?: number
|
||||
}
|
||||
|
||||
type ThreadJson = {
|
||||
model?: { provider?: string; model?: string }
|
||||
request_token_usage?: Record<string, TokenUsage>
|
||||
cumulative_token_usage?: TokenUsage
|
||||
}
|
||||
|
||||
function num(value: number | undefined): number {
|
||||
return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : 0
|
||||
}
|
||||
|
||||
function usageIsEmpty(usage: TokenUsage): boolean {
|
||||
return (
|
||||
num(usage.input_tokens) === 0 &&
|
||||
num(usage.output_tokens) === 0 &&
|
||||
num(usage.cache_creation_input_tokens) === 0 &&
|
||||
num(usage.cache_read_input_tokens) === 0
|
||||
)
|
||||
}
|
||||
|
||||
function buildCall(opts: {
|
||||
threadId: string
|
||||
requestKey: string
|
||||
usage: TokenUsage
|
||||
model: string
|
||||
timestamp: string
|
||||
userMessage: string
|
||||
}): ParsedProviderCall {
|
||||
const input = num(opts.usage.input_tokens)
|
||||
const output = num(opts.usage.output_tokens)
|
||||
const cacheWrite = num(opts.usage.cache_creation_input_tokens)
|
||||
const cacheRead = num(opts.usage.cache_read_input_tokens)
|
||||
// Map one rich, cost-free decoder call into the host's ParsedProviderCall. Cost
|
||||
// re-enters here: `costBasis: 'estimated'` marks the call so the parser.ts
|
||||
// pricing pass fills `costUSD` from the token buckets. Zed never captures tool
|
||||
// calls, so `bashCommands` is always empty (no extractBashCommands needed).
|
||||
function toProviderCall(rich: ZedDecodedCall): ParsedProviderCall {
|
||||
return {
|
||||
provider: 'zed',
|
||||
model: opts.model,
|
||||
inputTokens: input,
|
||||
outputTokens: output,
|
||||
cacheCreationInputTokens: cacheWrite,
|
||||
cacheReadInputTokens: cacheRead,
|
||||
cachedInputTokens: cacheRead,
|
||||
reasoningTokens: 0,
|
||||
webSearchRequests: 0,
|
||||
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',
|
||||
tools: [],
|
||||
tools: rich.tools,
|
||||
bashCommands: [],
|
||||
timestamp: opts.timestamp,
|
||||
speed: 'standard',
|
||||
deduplicationKey: `zed:${opts.threadId}:${opts.requestKey}`,
|
||||
userMessage: opts.userMessage,
|
||||
sessionId: opts.threadId,
|
||||
}
|
||||
}
|
||||
|
||||
function parseThreads(db: SqliteDatabase, seenKeys: Set<string>): ParsedProviderCall[] {
|
||||
const calls: ParsedProviderCall[] = []
|
||||
let skipped = 0
|
||||
|
||||
let rows: ThreadRow[]
|
||||
try {
|
||||
rows = db.query<ThreadRow>(THREADS_QUERY)
|
||||
} catch {
|
||||
return calls
|
||||
}
|
||||
|
||||
for (const row of rows) {
|
||||
try {
|
||||
// Zed's DataType enum is "zstd" (current save path) or "json" (legacy
|
||||
// uncompressed rows); anything else is unknown.
|
||||
if (!row.id || !row.data || (row.data_type !== 'zstd' && row.data_type !== 'json')) {
|
||||
if (row.data != null) skipped++
|
||||
continue
|
||||
}
|
||||
const parsedAt = new Date(row.updated_at ?? '')
|
||||
if (Number.isNaN(parsedAt.getTime())) continue
|
||||
const timestamp = parsedAt.toISOString()
|
||||
|
||||
const jsonText = row.data_type === 'zstd'
|
||||
? zstdDecompress!(Buffer.from(row.data)).toString('utf-8')
|
||||
: Buffer.from(row.data).toString('utf-8')
|
||||
const thread = JSON.parse(jsonText) as ThreadJson
|
||||
const model = thread.model?.model || 'unknown'
|
||||
const userMessage = row.summary ?? ''
|
||||
|
||||
const requests = Object.entries(thread.request_token_usage ?? {}).filter(([, usage]) => usage != null && !usageIsEmpty(usage))
|
||||
// The per-request map is keyed by user message and does not cover every
|
||||
// request (verified on a real thread: cumulative was ~3x the map sum),
|
||||
// so a remainder entry tops the thread up to the exact cumulative
|
||||
// counter. Threads with an empty map degrade to one cumulative call.
|
||||
const entries: Array<[string, TokenUsage]> = [...requests]
|
||||
const cumulative = thread.cumulative_token_usage
|
||||
if (cumulative && !usageIsEmpty(cumulative)) {
|
||||
let sumIn = 0, sumOut = 0, sumWrite = 0, sumRead = 0
|
||||
for (const [, usage] of requests) {
|
||||
sumIn += num(usage.input_tokens)
|
||||
sumOut += num(usage.output_tokens)
|
||||
sumWrite += num(usage.cache_creation_input_tokens)
|
||||
sumRead += num(usage.cache_read_input_tokens)
|
||||
}
|
||||
const remainder: TokenUsage = {
|
||||
input_tokens: Math.max(0, num(cumulative.input_tokens) - sumIn),
|
||||
output_tokens: Math.max(0, num(cumulative.output_tokens) - sumOut),
|
||||
cache_creation_input_tokens: Math.max(0, num(cumulative.cache_creation_input_tokens) - sumWrite),
|
||||
cache_read_input_tokens: Math.max(0, num(cumulative.cache_read_input_tokens) - sumRead),
|
||||
}
|
||||
if (!usageIsEmpty(remainder)) entries.push(['cumulative-remainder', remainder])
|
||||
}
|
||||
|
||||
for (const [requestKey, usage] of entries) {
|
||||
const call = buildCall({ threadId: row.id, requestKey, usage, model, timestamp, userMessage })
|
||||
if (seenKeys.has(call.deduplicationKey)) continue
|
||||
seenKeys.add(call.deduplicationKey)
|
||||
calls.push(call)
|
||||
}
|
||||
} catch {
|
||||
skipped++
|
||||
}
|
||||
}
|
||||
|
||||
if (skipped > 0) {
|
||||
process.stderr.write(`codeburn: skipped ${skipped} unreadable Zed threads\n`)
|
||||
}
|
||||
return calls
|
||||
}
|
||||
|
||||
function createParser(source: SessionSource, seenKeys: Set<string>): SessionParser {
|
||||
return {
|
||||
async *parse(): AsyncGenerator<ParsedProviderCall> {
|
||||
if (!isSqliteAvailable()) {
|
||||
process.stderr.write(getSqliteLoadError() + '\n')
|
||||
return
|
||||
}
|
||||
if (!zstdDecompress) {
|
||||
process.stderr.write('codeburn: Zed threads need Node >= 22.15 (zstd support); skipping Zed usage.\n')
|
||||
return
|
||||
}
|
||||
|
||||
let db: SqliteDatabase
|
||||
try {
|
||||
db = openDatabase(source.path)
|
||||
} catch (err) {
|
||||
process.stderr.write(`codeburn: cannot open Zed database: ${err instanceof Error ? err.message : err}\n`)
|
||||
return
|
||||
}
|
||||
try {
|
||||
for (const call of parseThreads(db, seenKeys)) {
|
||||
yield call
|
||||
}
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
},
|
||||
timestamp: rich.timestamp,
|
||||
speed: rich.speed,
|
||||
deduplicationKey: rich.deduplicationKey,
|
||||
userMessage: rich.userMessage,
|
||||
sessionId: rich.sessionId,
|
||||
}
|
||||
}
|
||||
|
||||
export function createZedProvider(dbPathOverride?: string): Provider {
|
||||
return {
|
||||
return createBridgedProvider<ZedDecodedCall>({
|
||||
name: 'zed',
|
||||
displayName: 'Zed',
|
||||
|
||||
|
|
@ -219,10 +85,49 @@ export function createZedProvider(dbPathOverride?: string): Provider {
|
|||
return [{ path: dbPath, project: 'zed', provider: 'zed' }]
|
||||
},
|
||||
|
||||
createSessionParser(source: SessionSource, seenKeys: Set<string>): SessionParser {
|
||||
return createParser(source, seenKeys)
|
||||
// I/O adapter: open the db and run the threads query (sqlite-side). Rows
|
||||
// (blob and all) are handed straight to the core decoder, which does the
|
||||
// zstd decompression and JSON parsing now that Node-version support for it
|
||||
// is confirmed.
|
||||
async readRecords(source: SessionSource): Promise<unknown[] | null> {
|
||||
if (!isSqliteAvailable()) {
|
||||
process.stderr.write(getSqliteLoadError() + '\n')
|
||||
return null
|
||||
}
|
||||
if (!zstdDecompress) {
|
||||
process.stderr.write('codeburn: Zed threads need Node >= 22.15 (zstd support); skipping Zed usage.\n')
|
||||
return null
|
||||
}
|
||||
|
||||
let db: SqliteDatabase
|
||||
try {
|
||||
db = openDatabase(source.path)
|
||||
} catch (err) {
|
||||
process.stderr.write(`codeburn: cannot open Zed database: ${err instanceof Error ? err.message : err}\n`)
|
||||
return null
|
||||
}
|
||||
try {
|
||||
return db.query<ZedThreadRow>(THREADS_QUERY)
|
||||
} catch {
|
||||
return []
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
// The core decoder reports unreadable/unknown-shape threads as record
|
||||
// diagnostics, which the bridge discards. The pre-migration decode printed
|
||||
// one aggregate stderr line per scan for exactly those rows, so that notice
|
||||
// is re-emitted here rather than silently dropped.
|
||||
decode(input) {
|
||||
const { calls, diagnostics } = decodeZed(input)
|
||||
if (diagnostics.length > 0) {
|
||||
process.stderr.write(`codeburn: skipped ${diagnostics.length} unreadable Zed threads\n`)
|
||||
}
|
||||
return { calls }
|
||||
},
|
||||
toProviderCall,
|
||||
})
|
||||
}
|
||||
|
||||
export const zed = createZedProvider()
|
||||
|
|
|
|||
212
packages/cli/tests/providers/crush-bridge.test.ts
Normal file
212
packages/cli/tests/providers/crush-bridge.test.ts
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
import { mkdtemp, mkdir, writeFile, rm } from 'fs/promises'
|
||||
import { mkdirSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
import { tmpdir } from 'os'
|
||||
import { createRequire } from 'node:module'
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||
|
||||
import { createCrushProvider } from '../../src/providers/crush.js'
|
||||
import { priceProviderCall } from '../../src/pricing-pass.js'
|
||||
import { isSqliteAvailable } from '../../src/sqlite.js'
|
||||
import type { ParsedProviderCall } from '../../src/providers/types.js'
|
||||
|
||||
// Byte-identical parity gate for the crush bridge migration (phase 8, Category
|
||||
// B / sqlite). Crush is not present in the frozen corpus, so a committed
|
||||
// fixture golden is THE parity gate: the bridged provider (sqlite driver + SQL
|
||||
// queries CLI-side, pure row->call decode delegated to
|
||||
// @codeburn/core/providers/crush) must reproduce exactly what the
|
||||
// pre-migration in-CLI decode produced for this fixture DB. The GOLDEN below
|
||||
// was captured from the legacy provider before the migration.
|
||||
|
||||
const requireForTest = createRequire(import.meta.url)
|
||||
|
||||
// CREATE TABLE statements taken verbatim from charmbracelet/crush@v0.66.1
|
||||
// internal/db/migrations/20250424200609_initial.sql (same fixture shape as
|
||||
// tests/providers/crush.test.ts).
|
||||
function createCrushDb(dir: string): string {
|
||||
mkdirSync(dir, { recursive: true })
|
||||
const dbPath = join(dir, 'crush.db')
|
||||
const { DatabaseSync: Database } = requireForTest('node:sqlite')
|
||||
const db = new Database(dbPath)
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
parent_session_id TEXT,
|
||||
title TEXT NOT NULL,
|
||||
message_count INTEGER NOT NULL DEFAULT 0 CHECK (message_count >= 0),
|
||||
prompt_tokens INTEGER NOT NULL DEFAULT 0 CHECK (prompt_tokens >= 0),
|
||||
completion_tokens INTEGER NOT NULL DEFAULT 0 CHECK (completion_tokens >= 0),
|
||||
cost REAL NOT NULL DEFAULT 0.0 CHECK (cost >= 0.0),
|
||||
updated_at INTEGER NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
summary_message_id TEXT,
|
||||
todos TEXT
|
||||
)
|
||||
`)
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id TEXT PRIMARY KEY,
|
||||
session_id TEXT NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
parts TEXT NOT NULL DEFAULT '[]',
|
||||
model TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
finished_at INTEGER,
|
||||
provider TEXT,
|
||||
is_summary_message INTEGER DEFAULT 0 NOT NULL,
|
||||
FOREIGN KEY (session_id) REFERENCES sessions (id) ON DELETE CASCADE
|
||||
)
|
||||
`)
|
||||
db.close()
|
||||
return dbPath
|
||||
}
|
||||
|
||||
function seed(dbPath: string): void {
|
||||
const { DatabaseSync: Database } = requireForTest('node:sqlite')
|
||||
const db = new Database(dbPath)
|
||||
try {
|
||||
db.prepare(`
|
||||
INSERT INTO sessions (id, parent_session_id, title, message_count, prompt_tokens, completion_tokens, cost, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run('sess-alpha', null, 'test session', 3, 1500, 420, 0.0921, 1_700_000_100, 1_700_000_555)
|
||||
for (const [id, model, ts] of [['m1', 'claude-sonnet-4-6', 1_700_000_100], ['m2', 'claude-sonnet-4-6', 1_700_000_200], ['m3', 'gpt-5', 1_700_000_300]] as const) {
|
||||
db.prepare(`INSERT INTO messages (id, session_id, role, parts, model, created_at, updated_at) VALUES (?, ?, ?, '[]', ?, ?, ?)`)
|
||||
.run(id, 'sess-alpha', 'assistant', model, ts, ts)
|
||||
}
|
||||
// No cost -> estimated fallback.
|
||||
db.prepare(`
|
||||
INSERT INTO sessions (id, parent_session_id, title, message_count, prompt_tokens, completion_tokens, cost, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run('sess-beta', null, 'test session 2', 1, 300, 80, 0, 1_700_000_600, 1_700_000_600)
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
}
|
||||
|
||||
// Captured from the pre-migration crush decode (dominant model wins among
|
||||
// tied assistant messages, measured cost from the DB, estimated fallback when
|
||||
// cost is 0). `costUSD` in the GOLDEN below is the raw decoder output
|
||||
// (pre-pricing-pass); the second test proves the pricing pass adds costUSD to
|
||||
// the estimated row and leaves the measured row untouched.
|
||||
const GOLDEN: ParsedProviderCall[] = [
|
||||
{
|
||||
provider: 'crush',
|
||||
model: 'claude-sonnet-4-6',
|
||||
inputTokens: 1500,
|
||||
outputTokens: 420,
|
||||
cacheCreationInputTokens: 0,
|
||||
cacheReadInputTokens: 0,
|
||||
cachedInputTokens: 0,
|
||||
reasoningTokens: 0,
|
||||
webSearchRequests: 0,
|
||||
costUSD: 0.0921,
|
||||
costBasis: 'measured',
|
||||
tools: [],
|
||||
bashCommands: [],
|
||||
timestamp: '2023-11-14T22:22:35.000Z',
|
||||
speed: 'standard',
|
||||
deduplicationKey: 'crush:sess-alpha',
|
||||
userMessage: '',
|
||||
sessionId: 'sess-alpha',
|
||||
},
|
||||
{
|
||||
provider: 'crush',
|
||||
model: 'unknown',
|
||||
inputTokens: 300,
|
||||
outputTokens: 80,
|
||||
cacheCreationInputTokens: 0,
|
||||
cacheReadInputTokens: 0,
|
||||
cachedInputTokens: 0,
|
||||
reasoningTokens: 0,
|
||||
webSearchRequests: 0,
|
||||
costBasis: 'estimated',
|
||||
tools: [],
|
||||
bashCommands: [],
|
||||
timestamp: '2023-11-14T22:23:20.000Z',
|
||||
speed: 'standard',
|
||||
deduplicationKey: 'crush:sess-beta',
|
||||
userMessage: '',
|
||||
sessionId: 'sess-beta',
|
||||
},
|
||||
]
|
||||
|
||||
let tmpRoot: string
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpRoot = await mkdtemp(join(tmpdir(), 'crush-bridge-'))
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tmpRoot, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
async function collect(): Promise<ParsedProviderCall[]> {
|
||||
const projectDir = join(tmpRoot, 'project-alpha')
|
||||
const dbPath = createCrushDb(join(projectDir, '.crush'))
|
||||
seed(dbPath)
|
||||
|
||||
const globalData = join(tmpRoot, 'crush-global')
|
||||
await mkdir(globalData, { recursive: true })
|
||||
await writeFile(join(globalData, 'projects.json'), JSON.stringify({
|
||||
'proj-a': { path: projectDir, data_dir: '.crush' },
|
||||
}))
|
||||
process.env['CRUSH_GLOBAL_DATA'] = globalData
|
||||
|
||||
const provider = createCrushProvider()
|
||||
const sources = await provider.discoverSessions()
|
||||
sources.sort((a, b) => a.path.localeCompare(b.path))
|
||||
const seen = new Set<string>()
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for (const source of sources) {
|
||||
for await (const call of provider.createSessionParser(source, seen).parse()) {
|
||||
calls.push(call)
|
||||
}
|
||||
}
|
||||
return calls
|
||||
}
|
||||
|
||||
describe.skipIf(!isSqliteAvailable())('crush bridge — fixture parity', () => {
|
||||
it('the bridged provider reproduces the pre-migration decode byte-for-byte', async () => {
|
||||
expect(await collect()).toEqual(GOLDEN)
|
||||
})
|
||||
|
||||
it('the priced output survives the pricing pass with only costUSD (for the estimated row) filled in', async () => {
|
||||
const raw = await collect()
|
||||
const priced = raw.map(priceProviderCall)
|
||||
priced.forEach((call, i) => {
|
||||
expect(typeof call.costUSD).toBe('number')
|
||||
expect(Number.isFinite(call.costUSD)).toBe(true)
|
||||
const { costUSD, ...rest } = call
|
||||
const { costUSD: rawCostUSD, ...rawRest } = raw[i]!
|
||||
expect(rest).toEqual(rawRest)
|
||||
// The measured row's costUSD is untouched; the estimated row's is filled in.
|
||||
if (raw[i]!.costBasis === 'measured') expect(costUSD).toBe(rawCostUSD)
|
||||
})
|
||||
})
|
||||
|
||||
it('discovery, I/O, and dedup stay CLI-side; the shared seenKeys set dedups', async () => {
|
||||
const projectDir = join(tmpRoot, 'project-alpha')
|
||||
const dbPath = createCrushDb(join(projectDir, '.crush'))
|
||||
seed(dbPath)
|
||||
const globalData = join(tmpRoot, 'crush-global')
|
||||
await mkdir(globalData, { recursive: true })
|
||||
await writeFile(join(globalData, 'projects.json'), JSON.stringify({ 'proj-a': { path: projectDir, data_dir: '.crush' } }))
|
||||
process.env['CRUSH_GLOBAL_DATA'] = globalData
|
||||
|
||||
const provider = createCrushProvider()
|
||||
const sources = await provider.discoverSessions()
|
||||
const seen = new Set<string>()
|
||||
const first: ParsedProviderCall[] = []
|
||||
for (const source of sources) {
|
||||
for await (const call of provider.createSessionParser(source, seen).parse()) first.push(call)
|
||||
}
|
||||
const second: ParsedProviderCall[] = []
|
||||
for (const source of sources) {
|
||||
for await (const call of provider.createSessionParser(source, seen).parse()) second.push(call)
|
||||
}
|
||||
expect(first.length).toBe(2)
|
||||
expect(second).toEqual([])
|
||||
})
|
||||
})
|
||||
219
packages/cli/tests/providers/forge-bridge.test.ts
Normal file
219
packages/cli/tests/providers/forge-bridge.test.ts
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
import { mkdtemp, rm } from 'fs/promises'
|
||||
import { join } from 'path'
|
||||
import { tmpdir } from 'os'
|
||||
import { createRequire } from 'node:module'
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||
|
||||
import { createForgeProvider } from '../../src/providers/forge.js'
|
||||
import { priceProviderCall } from '../../src/pricing-pass.js'
|
||||
import { isSqliteAvailable } from '../../src/sqlite.js'
|
||||
import type { ParsedProviderCall } from '../../src/providers/types.js'
|
||||
|
||||
// Byte-identical parity gate for the forge bridge migration (phase 8, Category
|
||||
// B / sqlite). Forge is not present in the frozen corpus, so a committed
|
||||
// fixture golden is THE parity gate. The GOLDEN below was captured from the
|
||||
// legacy provider before the migration.
|
||||
|
||||
const requireForTest = createRequire(import.meta.url)
|
||||
|
||||
type TestDb = {
|
||||
exec(sql: string): void
|
||||
prepare(sql: string): { run(...params: unknown[]): void }
|
||||
close(): void
|
||||
}
|
||||
|
||||
function createForgeDb(dir: string): string {
|
||||
const dbPath = join(dir, 'forge.db')
|
||||
const { DatabaseSync: Database } = requireForTest('node:sqlite')
|
||||
const db = new Database(dbPath)
|
||||
db.exec(`CREATE TABLE conversations(
|
||||
conversation_id TEXT PRIMARY KEY NOT NULL, title TEXT, workspace_id BIGINT NOT NULL,
|
||||
context TEXT, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP, metrics TEXT
|
||||
)`)
|
||||
db.close()
|
||||
return dbPath
|
||||
}
|
||||
|
||||
function withTestDb(dbPath: string, fn: (db: TestDb) => void): void {
|
||||
const { DatabaseSync: Database } = requireForTest('node:sqlite')
|
||||
const db = new Database(dbPath)
|
||||
try {
|
||||
fn(db)
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
}
|
||||
|
||||
const CONTEXT = {
|
||||
messages: [
|
||||
{ message: { text: { role: 'User', content: 'implement forge bridge' } } },
|
||||
{
|
||||
message: {
|
||||
text: {
|
||||
role: 'Assistant', content: '', model: 'claude-opus-4-6',
|
||||
tool_calls: [
|
||||
{ name: 'shell', call_id: 'call-1', arguments: { command: 'git status && npm test' } },
|
||||
{ name: 'Read', call_id: 'call-2', arguments: { file_path: '/tmp/a' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
usage: { prompt_tokens: { actual: 1200 }, completion_tokens: { actual: 300 }, cached_tokens: { actual: 200 } },
|
||||
},
|
||||
{ message: { text: { role: 'User', content: 'now write tests' } } },
|
||||
{
|
||||
message: { text: { role: 'Assistant', model: 'claude-sonnet-4-6', tool_calls: [{ name: 'unknown_tool', call_id: 'call-3', arguments: {} }] } },
|
||||
usage: { prompt_tokens: { actual: 400 }, completion_tokens: { actual: 90 } },
|
||||
},
|
||||
// Zero-token assistant message: must be skipped.
|
||||
{ message: { text: { role: 'Assistant', model: 'claude-sonnet-4-6' } }, usage: { prompt_tokens: { actual: 0 }, completion_tokens: { actual: 0 } } },
|
||||
// Tool names that collide with Object.prototype members. Tool names come
|
||||
// straight from the conversation JSON, so mapping them through a plain
|
||||
// object-literal lookup would resolve the INHERITED member (a Function, or
|
||||
// Object.prototype itself for `__proto__`) instead of falling through to
|
||||
// the identity default. The pre-migration decode used a `switch` and
|
||||
// returned each name verbatim; this row pins that.
|
||||
{
|
||||
message: {
|
||||
text: {
|
||||
role: 'Assistant', model: 'claude-haiku-4-5',
|
||||
tool_calls: [
|
||||
{ name: 'constructor', call_id: 'call-4', arguments: {} },
|
||||
{ name: 'toString', call_id: 'call-5', arguments: {} },
|
||||
{ name: '__proto__', call_id: 'call-6', arguments: {} },
|
||||
{ name: 'hasOwnProperty', call_id: 'call-7', arguments: {} },
|
||||
],
|
||||
},
|
||||
},
|
||||
usage: { prompt_tokens: { actual: 50 }, completion_tokens: { actual: 5 } },
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
function seed(dbPath: string): void {
|
||||
withTestDb(dbPath, db => {
|
||||
db.prepare(`INSERT INTO conversations (conversation_id, title, workspace_id, context, created_at, updated_at, metrics) VALUES (?, ?, ?, ?, ?, ?, ?)`)
|
||||
.run('conv-1', 'Forge Project', 123, JSON.stringify(CONTEXT), '2026-05-06 15:00:00', '2026-05-06 15:20:41.379094', null)
|
||||
})
|
||||
}
|
||||
|
||||
const GOLDEN: ParsedProviderCall[] = [
|
||||
{
|
||||
provider: 'forge',
|
||||
model: 'claude-opus-4-6',
|
||||
inputTokens: 1000,
|
||||
outputTokens: 300,
|
||||
cacheCreationInputTokens: 0,
|
||||
cacheReadInputTokens: 200,
|
||||
cachedInputTokens: 200,
|
||||
reasoningTokens: 0,
|
||||
webSearchRequests: 0,
|
||||
costBasis: 'estimated',
|
||||
tools: ['Bash', 'Read'],
|
||||
bashCommands: ['git', 'npm'],
|
||||
timestamp: '2026-05-06T15:20:41.379Z',
|
||||
speed: 'standard',
|
||||
deduplicationKey: 'forge:conv-1:call-1',
|
||||
userMessage: 'implement forge bridge',
|
||||
sessionId: 'conv-1',
|
||||
},
|
||||
{
|
||||
provider: 'forge',
|
||||
model: 'claude-sonnet-4-6',
|
||||
inputTokens: 400,
|
||||
outputTokens: 90,
|
||||
cacheCreationInputTokens: 0,
|
||||
cacheReadInputTokens: 0,
|
||||
cachedInputTokens: 0,
|
||||
reasoningTokens: 0,
|
||||
webSearchRequests: 0,
|
||||
costBasis: 'estimated',
|
||||
tools: ['unknown_tool'],
|
||||
bashCommands: [],
|
||||
timestamp: '2026-05-06T15:20:41.379Z',
|
||||
speed: 'standard',
|
||||
deduplicationKey: 'forge:conv-1:call-3',
|
||||
userMessage: 'now write tests',
|
||||
sessionId: 'conv-1',
|
||||
},
|
||||
{
|
||||
provider: 'forge',
|
||||
model: 'claude-haiku-4-5',
|
||||
inputTokens: 50,
|
||||
outputTokens: 5,
|
||||
cacheCreationInputTokens: 0,
|
||||
cacheReadInputTokens: 0,
|
||||
cachedInputTokens: 0,
|
||||
reasoningTokens: 0,
|
||||
webSearchRequests: 0,
|
||||
costBasis: 'estimated',
|
||||
tools: ['constructor', 'toString', '__proto__', 'hasOwnProperty'],
|
||||
bashCommands: [],
|
||||
timestamp: '2026-05-06T15:20:41.379Z',
|
||||
speed: 'standard',
|
||||
deduplicationKey: 'forge:conv-1:call-4',
|
||||
userMessage: 'now write tests',
|
||||
sessionId: 'conv-1',
|
||||
},
|
||||
]
|
||||
|
||||
let tmpRoot: string
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpRoot = await mkdtemp(join(tmpdir(), 'forge-bridge-'))
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tmpRoot, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
async function collect(dbPath: string, seen = new Set<string>()): Promise<ParsedProviderCall[]> {
|
||||
const provider = createForgeProvider(dbPath)
|
||||
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(call)
|
||||
}
|
||||
}
|
||||
return calls
|
||||
}
|
||||
|
||||
describe.skipIf(!isSqliteAvailable())('forge bridge — fixture parity', () => {
|
||||
it('the bridged provider reproduces the pre-migration decode byte-for-byte', async () => {
|
||||
const dbPath = createForgeDb(tmpRoot)
|
||||
seed(dbPath)
|
||||
expect(await collect(dbPath)).toEqual(GOLDEN)
|
||||
})
|
||||
|
||||
it('the priced output survives the pricing pass with only costUSD added', async () => {
|
||||
const dbPath = createForgeDb(tmpRoot)
|
||||
seed(dbPath)
|
||||
const raw = await collect(dbPath)
|
||||
const priced = raw.map(priceProviderCall)
|
||||
priced.forEach((call, i) => {
|
||||
expect(typeof call.costUSD).toBe('number')
|
||||
expect(Number.isFinite(call.costUSD)).toBe(true)
|
||||
const { costUSD, ...rest } = call
|
||||
expect(rest).toEqual(raw[i])
|
||||
})
|
||||
})
|
||||
|
||||
it('discovery, I/O, and dedup stay CLI-side; the shared seenKeys set dedups', async () => {
|
||||
const dbPath = createForgeDb(tmpRoot)
|
||||
seed(dbPath)
|
||||
const seen = new Set<string>()
|
||||
const first = await collect(dbPath, seen)
|
||||
const second = await collect(dbPath, seen)
|
||||
expect(first.length).toBe(3)
|
||||
expect(second).toEqual([])
|
||||
})
|
||||
|
||||
it('every emitted tool name is a string (no inherited prototype member leaks in)', async () => {
|
||||
const dbPath = createForgeDb(tmpRoot)
|
||||
seed(dbPath)
|
||||
for (const call of await collect(dbPath)) {
|
||||
for (const tool of call.tools) expect(typeof tool).toBe('string')
|
||||
}
|
||||
})
|
||||
})
|
||||
178
packages/cli/tests/providers/goose-bridge.test.ts
Normal file
178
packages/cli/tests/providers/goose-bridge.test.ts
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
import { mkdtemp, rm } from 'fs/promises'
|
||||
import { join } from 'path'
|
||||
import { tmpdir } from 'os'
|
||||
import { createRequire } from 'node:module'
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||
|
||||
import { createGooseProvider } from '../../src/providers/goose.js'
|
||||
import { priceProviderCall } from '../../src/pricing-pass.js'
|
||||
import { isSqliteAvailable } from '../../src/sqlite.js'
|
||||
import type { ParsedProviderCall, SessionSource } from '../../src/providers/types.js'
|
||||
|
||||
// Byte-identical parity gate for the goose bridge migration (phase 8, Category
|
||||
// B / sqlite). Goose is not present in the frozen corpus and had no pre-
|
||||
// existing fixture test, so this fixture-building pattern is modeled on the
|
||||
// sibling sqlite providers (crush/zed/forge). The GOLDEN below was captured
|
||||
// from the legacy in-CLI decode before the migration (createGooseProvider has
|
||||
// no db-path override, so `createSessionParser` is exercised directly against
|
||||
// a manually-built source, exactly as the capture script did).
|
||||
|
||||
const requireForTest = createRequire(import.meta.url)
|
||||
|
||||
function createGooseDb(dir: string): string {
|
||||
const dbPath = join(dir, 'sessions.db')
|
||||
const { DatabaseSync: Database } = requireForTest('node:sqlite')
|
||||
const db = new Database(dbPath)
|
||||
db.exec(`CREATE TABLE sessions (
|
||||
id TEXT PRIMARY KEY, name TEXT, working_dir TEXT, created_at TEXT, updated_at TEXT,
|
||||
accumulated_input_tokens INTEGER, accumulated_output_tokens INTEGER, provider_name TEXT, model_config_json BLOB
|
||||
)`)
|
||||
db.exec(`CREATE TABLE messages (
|
||||
message_id TEXT PRIMARY KEY, session_id TEXT NOT NULL, role TEXT NOT NULL, content_json BLOB NOT NULL, created_timestamp INTEGER NOT NULL
|
||||
)`)
|
||||
db.close()
|
||||
return dbPath
|
||||
}
|
||||
|
||||
function seed(dbPath: string, opts: { badTimestamps?: boolean; singleTurn?: boolean } = {}): void {
|
||||
const { DatabaseSync: Database } = requireForTest('node:sqlite')
|
||||
const db = new Database(dbPath)
|
||||
try {
|
||||
const modelConfig = JSON.stringify({ model_name: 'gpt-5.5', reasoning: true })
|
||||
db.prepare(`INSERT INTO sessions (id, name, working_dir, created_at, updated_at, accumulated_input_tokens, accumulated_output_tokens, provider_name, model_config_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
||||
.run('sess-1', 'goose session', '/Users/me/project',
|
||||
opts.badTimestamps ? 'not-a-date' : '2026-06-01T10:00:00Z',
|
||||
opts.badTimestamps ? 'also-not-a-date' : '2026-06-01T10:05:30Z',
|
||||
1500, 400, 'openai', Buffer.from(modelConfig))
|
||||
|
||||
db.prepare(`INSERT INTO messages (message_id, session_id, role, content_json, created_timestamp) VALUES (?, ?, ?, ?, ?)`)
|
||||
.run('msg-1', 'sess-1', 'user', Buffer.from(JSON.stringify([{ type: 'text', text: 'please refactor the shell wrapper and run tests' }])), 1_780_000_000)
|
||||
|
||||
const assistantContent1 = [
|
||||
{ type: 'text', text: 'working on it' },
|
||||
{ type: 'toolRequest', toolCall: { value: { name: 'developer__shell', arguments: { command: 'npm test && npm run lint' } } } },
|
||||
{ type: 'toolRequest', toolCall: { value: { name: 'developer__read_file', arguments: { file_path: 'src/index.ts' } } } },
|
||||
]
|
||||
db.prepare(`INSERT INTO messages (message_id, session_id, role, content_json, created_timestamp) VALUES (?, ?, ?, ?, ?)`)
|
||||
.run('msg-2', 'sess-1', 'assistant', Buffer.from(JSON.stringify(assistantContent1)), 1_780_000_010)
|
||||
|
||||
if (!opts.singleTurn) {
|
||||
const assistantContent2 = [
|
||||
{ type: 'toolRequest', toolCall: { value: { name: 'some_native_tool', arguments: {} } } },
|
||||
]
|
||||
db.prepare(`INSERT INTO messages (message_id, session_id, role, content_json, created_timestamp) VALUES (?, ?, ?, ?, ?)`)
|
||||
.run('msg-3', 'sess-1', 'assistant', Buffer.from(JSON.stringify(assistantContent2)), 1_780_000_020)
|
||||
}
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
}
|
||||
|
||||
const GOLDEN: ParsedProviderCall[] = [
|
||||
{
|
||||
provider: 'goose',
|
||||
model: 'gpt-5.5',
|
||||
inputTokens: 1500,
|
||||
outputTokens: 400,
|
||||
cacheCreationInputTokens: 0,
|
||||
cacheReadInputTokens: 0,
|
||||
cachedInputTokens: 0,
|
||||
reasoningTokens: 0,
|
||||
webSearchRequests: 0,
|
||||
costBasis: 'estimated',
|
||||
tools: ['Bash', 'Read', 'some_native_tool'],
|
||||
bashCommands: ['npm'],
|
||||
toolSequence: [
|
||||
[
|
||||
{ tool: 'Bash', command: 'npm test && npm run lint' },
|
||||
{ tool: 'Read', file: 'src/index.ts' },
|
||||
],
|
||||
[
|
||||
{ tool: 'some_native_tool' },
|
||||
],
|
||||
],
|
||||
timestamp: '2026-06-01T10:05:30.000Z',
|
||||
speed: 'standard',
|
||||
deduplicationKey: 'goose:sess-1',
|
||||
userMessage: 'please refactor the shell wrapper and run tests',
|
||||
sessionId: 'sess-1',
|
||||
},
|
||||
]
|
||||
|
||||
let tmpRoot: string
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpRoot = await mkdtemp(join(tmpdir(), 'goose-bridge-'))
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tmpRoot, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
async function collect(dbPath: string, seen = new Set<string>()): Promise<ParsedProviderCall[]> {
|
||||
const provider = createGooseProvider()
|
||||
const source: SessionSource = { path: `${dbPath}:sess-1`, project: 'goose-bridge', provider: 'goose' }
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of provider.createSessionParser(source, seen).parse()) {
|
||||
calls.push(call)
|
||||
}
|
||||
return calls
|
||||
}
|
||||
|
||||
describe.skipIf(!isSqliteAvailable())('goose bridge — fixture parity', () => {
|
||||
it('the bridged provider reproduces the pre-migration decode byte-for-byte', async () => {
|
||||
const dbPath = createGooseDb(tmpRoot)
|
||||
seed(dbPath)
|
||||
expect(await collect(dbPath)).toEqual(GOLDEN)
|
||||
})
|
||||
|
||||
it('the priced output survives the pricing pass with only costUSD added', async () => {
|
||||
const dbPath = createGooseDb(tmpRoot)
|
||||
seed(dbPath)
|
||||
const raw = await collect(dbPath)
|
||||
const priced = raw.map(priceProviderCall)
|
||||
priced.forEach((call, i) => {
|
||||
expect(typeof call.costUSD).toBe('number')
|
||||
expect(Number.isFinite(call.costUSD)).toBe(true)
|
||||
const { costUSD, ...rest } = call
|
||||
expect(rest).toEqual(raw[i])
|
||||
})
|
||||
})
|
||||
|
||||
it('discovery, I/O, and dedup stay CLI-side; the shared seenKeys set dedups', async () => {
|
||||
const dbPath = createGooseDb(tmpRoot)
|
||||
seed(dbPath)
|
||||
const seen = new Set<string>()
|
||||
const first = await collect(dbPath, seen)
|
||||
const second = await collect(dbPath, seen)
|
||||
expect(first.length).toBe(1)
|
||||
expect(second).toEqual([])
|
||||
})
|
||||
|
||||
it('omits toolSequence when only one turn carries tool calls', async () => {
|
||||
const dbPath = createGooseDb(tmpRoot)
|
||||
seed(dbPath, { singleTurn: true })
|
||||
const [call] = await collect(dbPath)
|
||||
expect(call).toEqual({
|
||||
...GOLDEN[0]!,
|
||||
tools: ['Bash', 'Read'],
|
||||
toolSequence: undefined,
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to the current time when neither timestamp column parses', async () => {
|
||||
const dbPath = createGooseDb(tmpRoot)
|
||||
seed(dbPath, { badTimestamps: true })
|
||||
const before = Date.now()
|
||||
const [call] = await collect(dbPath)
|
||||
const after = Date.now()
|
||||
// Every other field is identical to the golden; only the timestamp differs,
|
||||
// and it must land on "now" exactly as the pre-migration `new Date()` did.
|
||||
const { timestamp, ...rest } = call!
|
||||
const { timestamp: _goldenTimestamp, ...goldenRest } = GOLDEN[0]!
|
||||
expect(rest).toEqual(goldenRest)
|
||||
expect(Date.parse(timestamp)).toBeGreaterThanOrEqual(before - 1000)
|
||||
expect(Date.parse(timestamp)).toBeLessThanOrEqual(after + 1000)
|
||||
})
|
||||
})
|
||||
172
packages/cli/tests/providers/zcode-bridge.test.ts
Normal file
172
packages/cli/tests/providers/zcode-bridge.test.ts
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
import { mkdtemp, rm } from 'fs/promises'
|
||||
import { join } from 'path'
|
||||
import { tmpdir } from 'os'
|
||||
import { createRequire } from 'node:module'
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||
|
||||
import { createZcodeProvider } from '../../src/providers/zcode.js'
|
||||
import { priceProviderCall } from '../../src/pricing-pass.js'
|
||||
import { isSqliteAvailable } from '../../src/sqlite.js'
|
||||
import type { ParsedProviderCall } from '../../src/providers/types.js'
|
||||
|
||||
// Byte-identical parity gate for the zcode bridge migration (phase 8, Category
|
||||
// B / sqlite). ZCode is not present in the frozen corpus, so a committed
|
||||
// fixture golden is THE parity gate. The GOLDEN below was captured from the
|
||||
// legacy provider before the migration.
|
||||
|
||||
const requireForTest = createRequire(import.meta.url)
|
||||
|
||||
function createZcodeDb(dir: string): string {
|
||||
const dbPath = join(dir, 'db.sqlite')
|
||||
const { DatabaseSync: Database } = requireForTest('node:sqlite')
|
||||
const db = new Database(dbPath)
|
||||
db.exec(`CREATE TABLE session (id TEXT PRIMARY KEY, directory TEXT NOT NULL)`)
|
||||
db.exec(`CREATE TABLE model_usage (
|
||||
id TEXT PRIMARY KEY, session_id TEXT NOT NULL, turn_id TEXT, model_id TEXT NOT NULL,
|
||||
input_tokens INTEGER NOT NULL DEFAULT 0, output_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
reasoning_tokens INTEGER NOT NULL DEFAULT 0, cache_creation_input_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
cache_read_input_tokens INTEGER NOT NULL DEFAULT 0, started_at INTEGER NOT NULL, completed_at INTEGER)`)
|
||||
db.exec(`CREATE TABLE tool_usage (id TEXT PRIMARY KEY, session_id TEXT NOT NULL, turn_id TEXT, tool_name TEXT NOT NULL, started_at INTEGER NOT NULL)`)
|
||||
db.close()
|
||||
return dbPath
|
||||
}
|
||||
|
||||
function seed(dbPath: string): void {
|
||||
const { DatabaseSync: Database } = requireForTest('node:sqlite')
|
||||
const db = new Database(dbPath)
|
||||
try {
|
||||
db.prepare('INSERT INTO session (id, directory) VALUES (?, ?)').run('sess-1', '/Users/me/proj')
|
||||
db.prepare(`INSERT INTO model_usage (id, session_id, turn_id, model_id, input_tokens, output_tokens, reasoning_tokens, cache_creation_input_tokens, cache_read_input_tokens, started_at, completed_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
||||
.run('mu-1', 'sess-1', 'turn-1', 'GLM-5.2', 9125, 27, 12, 0, 8064, 1781981181862, 1781981202412)
|
||||
db.prepare(`INSERT INTO model_usage (id, session_id, turn_id, model_id, input_tokens, output_tokens, reasoning_tokens, cache_creation_input_tokens, cache_read_input_tokens, started_at, completed_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
||||
.run('mu-2', 'sess-1', 'turn-1', 'GLM-5.2', 200, 40, 0, 0, 0, 1781981210000, 1781981220000)
|
||||
// Zero-token row: must be skipped entirely.
|
||||
db.prepare(`INSERT INTO model_usage (id, session_id, turn_id, model_id, input_tokens, output_tokens, reasoning_tokens, cache_creation_input_tokens, cache_read_input_tokens, started_at, completed_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
||||
.run('mu-zero', 'sess-1', 'turn-2', 'GLM-5.2', 0, 0, 0, 0, 0, 1781981230000, 1781981231000)
|
||||
// No turn_id -> no tools attached.
|
||||
db.prepare(`INSERT INTO model_usage (id, session_id, turn_id, model_id, input_tokens, output_tokens, reasoning_tokens, cache_creation_input_tokens, cache_read_input_tokens, started_at, completed_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
||||
.run('mu-3', 'sess-1', null, 'GLM-5.2', 500, 60, 0, 100, 0, 1781981240000, null)
|
||||
|
||||
db.prepare('INSERT INTO tool_usage (id, session_id, turn_id, tool_name, started_at) VALUES (?, ?, ?, ?, ?)').run('tu-1', 'sess-1', 'turn-1', 'Bash', 1781981185000)
|
||||
db.prepare('INSERT INTO tool_usage (id, session_id, turn_id, tool_name, started_at) VALUES (?, ?, ?, ?, ?)').run('tu-2', 'sess-1', 'turn-1', 'Read', 1781981190000)
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
}
|
||||
|
||||
const GOLDEN: ParsedProviderCall[] = [
|
||||
{
|
||||
provider: 'zcode',
|
||||
model: 'GLM-5.2',
|
||||
inputTokens: 1061,
|
||||
outputTokens: 27,
|
||||
cacheCreationInputTokens: 0,
|
||||
cacheReadInputTokens: 8064,
|
||||
cachedInputTokens: 0,
|
||||
reasoningTokens: 12,
|
||||
webSearchRequests: 0,
|
||||
costBasis: 'estimated',
|
||||
tools: ['Bash', 'Read'],
|
||||
bashCommands: [],
|
||||
timestamp: '2026-06-20T18:46:42.412Z',
|
||||
speed: 'standard',
|
||||
deduplicationKey: 'zcode:mu-1',
|
||||
turnId: 'turn-1',
|
||||
userMessage: '',
|
||||
sessionId: 'sess-1',
|
||||
},
|
||||
{
|
||||
provider: 'zcode',
|
||||
model: 'GLM-5.2',
|
||||
inputTokens: 200,
|
||||
outputTokens: 40,
|
||||
cacheCreationInputTokens: 0,
|
||||
cacheReadInputTokens: 0,
|
||||
cachedInputTokens: 0,
|
||||
reasoningTokens: 0,
|
||||
webSearchRequests: 0,
|
||||
costBasis: 'estimated',
|
||||
tools: [],
|
||||
bashCommands: [],
|
||||
timestamp: '2026-06-20T18:47:00.000Z',
|
||||
speed: 'standard',
|
||||
deduplicationKey: 'zcode:mu-2',
|
||||
turnId: 'turn-1',
|
||||
userMessage: '',
|
||||
sessionId: 'sess-1',
|
||||
},
|
||||
{
|
||||
provider: 'zcode',
|
||||
model: 'GLM-5.2',
|
||||
inputTokens: 400,
|
||||
outputTokens: 60,
|
||||
cacheCreationInputTokens: 100,
|
||||
cacheReadInputTokens: 0,
|
||||
cachedInputTokens: 0,
|
||||
reasoningTokens: 0,
|
||||
webSearchRequests: 0,
|
||||
costBasis: 'estimated',
|
||||
tools: [],
|
||||
bashCommands: [],
|
||||
timestamp: '2026-06-20T18:47:20.000Z',
|
||||
speed: 'standard',
|
||||
deduplicationKey: 'zcode:mu-3',
|
||||
userMessage: '',
|
||||
sessionId: 'sess-1',
|
||||
},
|
||||
]
|
||||
|
||||
let tmpRoot: string
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpRoot = await mkdtemp(join(tmpdir(), 'zcode-bridge-'))
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tmpRoot, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
async function collect(dbPath: string, seen = new Set<string>()): Promise<ParsedProviderCall[]> {
|
||||
const provider = createZcodeProvider(dbPath)
|
||||
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(call)
|
||||
}
|
||||
}
|
||||
return calls
|
||||
}
|
||||
|
||||
describe.skipIf(!isSqliteAvailable())('zcode bridge — fixture parity', () => {
|
||||
it('the bridged provider reproduces the pre-migration decode byte-for-byte', async () => {
|
||||
const dbPath = createZcodeDb(tmpRoot)
|
||||
seed(dbPath)
|
||||
expect(await collect(dbPath)).toEqual(GOLDEN)
|
||||
})
|
||||
|
||||
it('the priced output survives the pricing pass with only costUSD added', async () => {
|
||||
const dbPath = createZcodeDb(tmpRoot)
|
||||
seed(dbPath)
|
||||
const raw = await collect(dbPath)
|
||||
const priced = raw.map(priceProviderCall)
|
||||
priced.forEach((call, i) => {
|
||||
expect(typeof call.costUSD).toBe('number')
|
||||
expect(Number.isFinite(call.costUSD)).toBe(true)
|
||||
expect(call.costUSD).toBeGreaterThan(0)
|
||||
const { costUSD, ...rest } = call
|
||||
expect(rest).toEqual(raw[i])
|
||||
})
|
||||
})
|
||||
|
||||
it('discovery, I/O, and dedup stay CLI-side; the shared seenKeys set dedups', async () => {
|
||||
const dbPath = createZcodeDb(tmpRoot)
|
||||
seed(dbPath)
|
||||
const seen = new Set<string>()
|
||||
const first = await collect(dbPath, seen)
|
||||
const second = await collect(dbPath, seen)
|
||||
expect(first.length).toBe(3)
|
||||
expect(second).toEqual([])
|
||||
})
|
||||
})
|
||||
231
packages/cli/tests/providers/zed-bridge.test.ts
Normal file
231
packages/cli/tests/providers/zed-bridge.test.ts
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||
import { mkdtemp, rm } from 'fs/promises'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import { createRequire } from 'node:module'
|
||||
import zlib from 'zlib'
|
||||
|
||||
import { createZedProvider } from '../../src/providers/zed.js'
|
||||
import { isSqliteAvailable } from '../../src/sqlite.js'
|
||||
import { priceProviderCall } from '../../src/pricing-pass.js'
|
||||
import type { ParsedProviderCall } from '../../src/providers/types.js'
|
||||
|
||||
// Byte-identical parity gate for the zed bridge migration (phase 8, Category B
|
||||
// / sqlite). Zed is not present in the frozen corpus, so a committed fixture
|
||||
// golden is THE parity gate. The GOLDEN below was captured from the legacy
|
||||
// provider before the migration.
|
||||
|
||||
const requireForTest = createRequire(import.meta.url)
|
||||
const zstd = (zlib as { zstdCompressSync?: (buf: Buffer) => Buffer }).zstdCompressSync
|
||||
|
||||
const skipReason = !isSqliteAvailable()
|
||||
? 'node:sqlite not available — needs Node 22+; skipping'
|
||||
: !zstd
|
||||
? 'zlib zstd not available — needs Node 22.15+; skipping'
|
||||
: null
|
||||
|
||||
let tmpDir: string
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await mkdtemp(join(tmpdir(), 'zed-bridge-'))
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function buildDb(fn: (db: {
|
||||
exec(sql: string): void
|
||||
prepare(sql: string): { run(...params: unknown[]): void }
|
||||
close(): void
|
||||
}) => void): string {
|
||||
const dbPath = join(tmpDir, 'threads.db')
|
||||
const { DatabaseSync: Database } = requireForTest('node:sqlite')
|
||||
const db = new Database(dbPath)
|
||||
db.exec(`CREATE TABLE threads (
|
||||
id TEXT PRIMARY KEY,
|
||||
summary TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
data_type TEXT NOT NULL,
|
||||
data BLOB NOT NULL,
|
||||
parent_id TEXT, folder_paths TEXT, folder_paths_order TEXT, created_at TEXT
|
||||
)`)
|
||||
fn(db)
|
||||
db.close()
|
||||
return dbPath
|
||||
}
|
||||
|
||||
function insertThread(db: {
|
||||
prepare(sql: string): { run(...params: unknown[]): void }
|
||||
}, opts: {
|
||||
id: string
|
||||
summary?: string
|
||||
updatedAt?: string
|
||||
dataType?: string
|
||||
thread?: unknown
|
||||
rawData?: Buffer
|
||||
}): void {
|
||||
const data = opts.rawData ?? zstd!(Buffer.from(JSON.stringify(opts.thread ?? {})))
|
||||
db.prepare('INSERT INTO threads (id, summary, updated_at, data_type, data) VALUES (?, ?, ?, ?, ?)').run(
|
||||
opts.id,
|
||||
opts.summary ?? 'a thread',
|
||||
opts.updatedAt ?? '2026-06-20T10:00:00Z',
|
||||
opts.dataType ?? 'zstd',
|
||||
data,
|
||||
)
|
||||
}
|
||||
|
||||
function seedDb(): string {
|
||||
return buildDb((db) => {
|
||||
insertThread(db, {
|
||||
id: 'thread-1',
|
||||
summary: 'refactor the parser',
|
||||
updatedAt: '2026-06-21T09:30:00Z',
|
||||
thread: {
|
||||
model: { provider: 'anthropic', model: 'claude-opus-4-8' },
|
||||
request_token_usage: {
|
||||
'req-1': { input_tokens: 1200, output_tokens: 300, cache_creation_input_tokens: 5000, cache_read_input_tokens: 90000 },
|
||||
'req-2': { input_tokens: 800, output_tokens: 150, cache_creation_input_tokens: 0, cache_read_input_tokens: 95000 },
|
||||
},
|
||||
cumulative_token_usage: { input_tokens: 2000, output_tokens: 450 },
|
||||
},
|
||||
})
|
||||
insertThread(db, { id: 'bad-type', dataType: 'protobuf', rawData: Buffer.from('{}') })
|
||||
insertThread(db, { id: 'bad-blob', rawData: Buffer.from('not zstd at all') })
|
||||
insertThread(db, {
|
||||
id: 'legacy',
|
||||
dataType: 'json',
|
||||
updatedAt: '2026-06-22T11:00:00Z',
|
||||
rawData: Buffer.from(JSON.stringify({
|
||||
model: { model: 'claude-sonnet-4-6' },
|
||||
request_token_usage: { 'req-1': { input_tokens: 40, output_tokens: 8 } },
|
||||
})),
|
||||
})
|
||||
insertThread(db, {
|
||||
id: 'zero-usage',
|
||||
thread: {
|
||||
model: { model: 'claude-opus-4-8' },
|
||||
request_token_usage: { 'req-1': { input_tokens: 0, output_tokens: 0 } },
|
||||
cumulative_token_usage: { input_tokens: 0, output_tokens: 0 },
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Captured from the pre-migration zed decode: two per-request calls for
|
||||
// thread-1 (cumulative exactly covered by the map, no remainder), one call for
|
||||
// the legacy uncompressed row, and the bad-type/bad-blob/zero-usage rows
|
||||
// skipped without dropping the healthy threads.
|
||||
const GOLDEN: ParsedProviderCall[] = [
|
||||
{
|
||||
provider: 'zed',
|
||||
model: 'claude-opus-4-8',
|
||||
inputTokens: 1200,
|
||||
outputTokens: 300,
|
||||
cacheCreationInputTokens: 5000,
|
||||
cacheReadInputTokens: 90000,
|
||||
cachedInputTokens: 90000,
|
||||
reasoningTokens: 0,
|
||||
webSearchRequests: 0,
|
||||
costBasis: 'estimated',
|
||||
tools: [],
|
||||
bashCommands: [],
|
||||
timestamp: '2026-06-21T09:30:00.000Z',
|
||||
speed: 'standard',
|
||||
deduplicationKey: 'zed:thread-1:req-1',
|
||||
userMessage: 'refactor the parser',
|
||||
sessionId: 'thread-1',
|
||||
},
|
||||
{
|
||||
provider: 'zed',
|
||||
model: 'claude-opus-4-8',
|
||||
inputTokens: 800,
|
||||
outputTokens: 150,
|
||||
cacheCreationInputTokens: 0,
|
||||
cacheReadInputTokens: 95000,
|
||||
cachedInputTokens: 95000,
|
||||
reasoningTokens: 0,
|
||||
webSearchRequests: 0,
|
||||
costBasis: 'estimated',
|
||||
tools: [],
|
||||
bashCommands: [],
|
||||
timestamp: '2026-06-21T09:30:00.000Z',
|
||||
speed: 'standard',
|
||||
deduplicationKey: 'zed:thread-1:req-2',
|
||||
userMessage: 'refactor the parser',
|
||||
sessionId: 'thread-1',
|
||||
},
|
||||
{
|
||||
provider: 'zed',
|
||||
model: 'claude-sonnet-4-6',
|
||||
inputTokens: 40,
|
||||
outputTokens: 8,
|
||||
cacheCreationInputTokens: 0,
|
||||
cacheReadInputTokens: 0,
|
||||
cachedInputTokens: 0,
|
||||
reasoningTokens: 0,
|
||||
webSearchRequests: 0,
|
||||
costBasis: 'estimated',
|
||||
tools: [],
|
||||
bashCommands: [],
|
||||
timestamp: '2026-06-22T11:00:00.000Z',
|
||||
speed: 'standard',
|
||||
deduplicationKey: 'zed:legacy:req-1',
|
||||
userMessage: 'a thread',
|
||||
sessionId: 'legacy',
|
||||
},
|
||||
]
|
||||
|
||||
async function collectCalls(dbPath: string, seenKeys = new Set<string>()): Promise<ParsedProviderCall[]> {
|
||||
const provider = createZedProvider(dbPath)
|
||||
const sources = await provider.discoverSessions()
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for (const source of sources) {
|
||||
for await (const call of provider.createSessionParser(source, seenKeys).parse()) {
|
||||
calls.push(call)
|
||||
}
|
||||
}
|
||||
return calls
|
||||
}
|
||||
|
||||
describe.skipIf(skipReason !== null)('zed bridge — fixture parity', () => {
|
||||
it('the bridged provider reproduces the pre-migration decode byte-for-byte', async () => {
|
||||
const dbPath = seedDb()
|
||||
expect(await collectCalls(dbPath)).toEqual(GOLDEN)
|
||||
})
|
||||
|
||||
it('the priced output survives the pricing pass with only costUSD added', async () => {
|
||||
const dbPath = seedDb()
|
||||
const raw = await collectCalls(dbPath)
|
||||
const priced = raw.map(priceProviderCall)
|
||||
priced.forEach((call, i) => {
|
||||
expect(typeof call.costUSD).toBe('number')
|
||||
expect(Number.isFinite(call.costUSD)).toBe(true)
|
||||
const { costUSD, ...rest } = call
|
||||
expect(rest).toEqual(raw[i])
|
||||
})
|
||||
})
|
||||
|
||||
it('discovery, I/O, and dedup stay CLI-side; the shared seenKeys set dedups', async () => {
|
||||
const dbPath = seedDb()
|
||||
const seen = new Set<string>()
|
||||
const first = await collectCalls(dbPath, seen)
|
||||
const second = await collectCalls(dbPath, seen)
|
||||
expect(first.length).toBe(3)
|
||||
expect(second).toEqual([])
|
||||
})
|
||||
|
||||
it('still prints the pre-migration aggregate stderr line for unreadable threads', async () => {
|
||||
const dbPath = seedDb()
|
||||
const written: string[] = []
|
||||
const original = process.stderr.write.bind(process.stderr)
|
||||
;(process.stderr as { write: unknown }).write = (chunk: unknown) => { written.push(String(chunk)); return true }
|
||||
try {
|
||||
await collectCalls(dbPath)
|
||||
} finally {
|
||||
;(process.stderr as { write: unknown }).write = original
|
||||
}
|
||||
// bad-type (unknown data_type) + bad-blob (zstd decompress throws) = 2.
|
||||
expect(written).toEqual(['codeburn: skipped 2 unreadable Zed threads\n'])
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue