codeburn/packages/cli/tests/providers/forge-bridge.test.ts
iamtoruk afaf9ffc8a 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.
2026-07-26 18:27:44 -07:00

219 lines
7.5 KiB
TypeScript

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')
}
})
})