chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,608 @@
|
||||
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'fs/promises'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import { createRequire } from 'node:module'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { isSqliteAvailable } from '../../src/sqlite.js'
|
||||
import {
|
||||
antigravityAppDataDirFromSourcePath,
|
||||
antigravityCascadeIdFromPath,
|
||||
createAntigravityProvider,
|
||||
discoverAntigravitySessionSources,
|
||||
extractAntigravityAppDataDirFromLine,
|
||||
extractAntigravityGeneratorMetadata,
|
||||
extractAntigravityModelMap,
|
||||
getAntigravityStatusLineEventsPath,
|
||||
parseAntigravityServerInfo,
|
||||
parseAntigravityServerInfoFromLine,
|
||||
recordAntigravityStatusLinePayload,
|
||||
shouldReparseAntigravitySource,
|
||||
} from '../../src/providers/antigravity.js'
|
||||
import type { ParsedProviderCall } from '../../src/providers/types.js'
|
||||
|
||||
const requireForTest = createRequire(import.meta.url)
|
||||
|
||||
type CurrentCliFixture = {
|
||||
conversationId: string
|
||||
rows: Array<{ idx: number; hex: string }>
|
||||
}
|
||||
|
||||
type TestDb = {
|
||||
exec(sql: string): void
|
||||
prepare(sql: string): { run(...params: unknown[]): void }
|
||||
close(): void
|
||||
}
|
||||
|
||||
function createCurrentAntigravityCliDb(dbPath: string, fixture: CurrentCliFixture): void {
|
||||
const { DatabaseSync: Database } = requireForTest('node:sqlite')
|
||||
const db = new Database(dbPath) as TestDb
|
||||
try {
|
||||
db.exec('CREATE TABLE gen_metadata (idx integer, data blob, size integer NOT NULL DEFAULT 0, PRIMARY KEY (idx))')
|
||||
db.exec('CREATE TABLE trajectory_metadata_blob (id text DEFAULT "main", data blob, PRIMARY KEY (id))')
|
||||
db.prepare('INSERT INTO trajectory_metadata_blob (id, data) VALUES (?, ?)').run(
|
||||
'main',
|
||||
Buffer.from('file:///Users/example/private-project'),
|
||||
)
|
||||
for (const row of fixture.rows) {
|
||||
const data = Buffer.from(row.hex, 'hex')
|
||||
db.prepare('INSERT INTO gen_metadata (idx, data, size) VALUES (?, ?, ?)').run(row.idx, data, data.length)
|
||||
}
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
}
|
||||
|
||||
async function collectAntigravityCalls(source: { path: string; project: string; provider: string }): Promise<ParsedProviderCall[]> {
|
||||
const parser = createAntigravityProvider().createSessionParser(source, new Set())
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of parser.parse()) calls.push(call)
|
||||
return calls
|
||||
}
|
||||
|
||||
describe('antigravity provider helpers', () => {
|
||||
it('parses legacy https server flags from POSIX process args', () => {
|
||||
const server = parseAntigravityServerInfoFromLine(
|
||||
'/Applications/Antigravity.app/language_server_macos_arm --app_data_dir antigravity --https_server_port 57101 --csrf_token 01234567-89ab-cdef-0123-456789abcdef',
|
||||
)
|
||||
|
||||
expect(server).toEqual({
|
||||
port: 57101,
|
||||
csrfToken: '01234567-89ab-cdef-0123-456789abcdef',
|
||||
})
|
||||
})
|
||||
|
||||
it('parses Windows extension server flags and equals syntax', () => {
|
||||
const server = parseAntigravityServerInfoFromLine(
|
||||
'C:\\Users\\Admin\\AppData\\Local\\Programs\\Antigravity\\resources\\app\\extensions\\antigravity\\bin\\language_server_windows_x64.exe --extension_server_port=62225 --extension_server_csrf_token=abcdef01-2345-6789-abcd-ef0123456789',
|
||||
)
|
||||
|
||||
expect(server).toEqual({
|
||||
port: 62225,
|
||||
csrfToken: 'abcdef01-2345-6789-abcd-ef0123456789',
|
||||
})
|
||||
})
|
||||
|
||||
it('parses Windows extension server flags and space syntax', () => {
|
||||
const server = parseAntigravityServerInfo([
|
||||
'node something-unrelated',
|
||||
'language_server_windows_x64.exe --app_data_dir C:\\Users\\Admin\\.gemini\\antigravity --extension_server_port 62300 --extension_server_csrf_token fedcba98-7654-3210-fedc-ba9876543210',
|
||||
])
|
||||
|
||||
expect(server).toEqual({
|
||||
port: 62300,
|
||||
csrfToken: 'fedcba98-7654-3210-fedc-ba9876543210',
|
||||
})
|
||||
})
|
||||
|
||||
it('parses quoted flag values', () => {
|
||||
const server = parseAntigravityServerInfoFromLine(
|
||||
'Antigravity language_server_windows_x64.exe --extension_server_port "62301" --extension_server_csrf_token "fedcba98-7654-3210-fedc-ba9876543211"',
|
||||
)
|
||||
|
||||
expect(server).toEqual({
|
||||
port: 62301,
|
||||
csrfToken: 'fedcba98-7654-3210-fedc-ba9876543211',
|
||||
})
|
||||
})
|
||||
|
||||
it('normalizes app_data_dir from app and CLI process args', () => {
|
||||
expect(extractAntigravityAppDataDirFromLine(
|
||||
'language_server --app_data_dir antigravity --https_server_port 0 --csrf_token 01234567-89ab-cdef-0123-456789abcdef',
|
||||
)).toBe('antigravity')
|
||||
|
||||
expect(extractAntigravityAppDataDirFromLine(
|
||||
'language_server --app_data_dir /Users/dev/.gemini/antigravity-cli --https_server_port 0 --csrf_token 01234567-89ab-cdef-0123-456789abcdef',
|
||||
)).toBe('antigravity-cli')
|
||||
|
||||
expect(extractAntigravityAppDataDirFromLine(
|
||||
'language_server.exe --app_data_dir "C:\\Users\\Admin\\.gemini\\antigravity-cli" --extension_server_port 62225 --extension_server_csrf_token abcdef01-2345-6789-abcd-ef0123456789',
|
||||
)).toBe('antigravity-cli')
|
||||
|
||||
expect(extractAntigravityAppDataDirFromLine(
|
||||
'language_server_windows_x64.exe --app_data_dir antigravity-ide --extension_server_port 8720 --extension_server_csrf_token 39800f1b-343a-40b0-8eb5-850702450346',
|
||||
)).toBe('antigravity-ide')
|
||||
})
|
||||
|
||||
it('accepts Antigravity 2 ephemeral port zero', () => {
|
||||
const server = parseAntigravityServerInfoFromLine(
|
||||
'antigravity language_server_macos_arm --https_server_port 0 --csrf_token 01234567-89ab-cdef-0123-456789abcdef',
|
||||
)
|
||||
|
||||
expect(server).toEqual({
|
||||
port: 0,
|
||||
csrfToken: '01234567-89ab-cdef-0123-456789abcdef',
|
||||
})
|
||||
})
|
||||
|
||||
it('matches language-server and antigravity markers case-insensitively', () => {
|
||||
const server = parseAntigravityServerInfoFromLine(
|
||||
'ANTIGRAVITY LANGUAGE_SERVER_WINDOWS_X64.EXE --extension_server_port 62302 --extension_server_csrf_token fedcba98-7654-3210-fedc-ba9876543212',
|
||||
)
|
||||
|
||||
expect(server).toEqual({
|
||||
port: 62302,
|
||||
csrfToken: 'fedcba98-7654-3210-fedc-ba9876543212',
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores process args without an antigravity marker', () => {
|
||||
expect(parseAntigravityServerInfoFromLine(
|
||||
'language_server --extension_server_port 62300 --extension_server_csrf_token fedcba98-7654-3210-fedc-ba9876543210',
|
||||
)).toBeNull()
|
||||
})
|
||||
|
||||
it('ignores invalid ports', () => {
|
||||
expect(parseAntigravityServerInfoFromLine(
|
||||
'antigravity language_server --extension_server_port 99999 --extension_server_csrf_token fedcba98-7654-3210-fedc-ba9876543210',
|
||||
)).toBeNull()
|
||||
})
|
||||
|
||||
it('ignores chained flag names as values', () => {
|
||||
expect(parseAntigravityServerInfoFromLine(
|
||||
'antigravity language_server --extension_server_port=--extension_server_csrf_token --extension_server_csrf_token fedcba98-7654-3210-fedc-ba9876543210',
|
||||
)).toBeNull()
|
||||
})
|
||||
|
||||
it('ignores implausibly short CSRF tokens', () => {
|
||||
expect(parseAntigravityServerInfoFromLine(
|
||||
'antigravity language_server --extension_server_port 62300 --extension_server_csrf_token short',
|
||||
)).toBeNull()
|
||||
})
|
||||
|
||||
it('extracts model maps from wrapped and unwrapped RPC responses', () => {
|
||||
expect(extractAntigravityModelMap({
|
||||
response: { models: { high: { model: 'MODEL_PLACEHOLDER_M7' } } },
|
||||
})).toEqual({ MODEL_PLACEHOLDER_M7: 'high' })
|
||||
|
||||
expect(extractAntigravityModelMap({
|
||||
models: { low: { model: 'MODEL_PLACEHOLDER_M8' } },
|
||||
})).toEqual({ MODEL_PLACEHOLDER_M8: 'low' })
|
||||
expect(extractAntigravityModelMap({
|
||||
models: { bad: null, good: { model: 'MODEL_PLACEHOLDER_M9' } },
|
||||
})).toEqual({ MODEL_PLACEHOLDER_M9: 'good' })
|
||||
expect(extractAntigravityModelMap({
|
||||
models: { 'gemini-3-flash-agent': { model: 'MODEL_PLACEHOLDER_M133', displayName: 'Gemini 3.5 Flash (High)' } },
|
||||
})).toEqual({ MODEL_PLACEHOLDER_M133: 'gemini-3.5-flash-high' })
|
||||
expect(extractAntigravityModelMap(null)).toEqual({})
|
||||
})
|
||||
|
||||
it('extracts generator metadata from wrapped and unwrapped RPC responses', () => {
|
||||
const metadata = [{
|
||||
chatModel: {
|
||||
model: 'gemini-3-pro',
|
||||
usage: {
|
||||
model: 'gemini-3-pro',
|
||||
inputTokens: '10',
|
||||
outputTokens: '4',
|
||||
apiProvider: 'google',
|
||||
},
|
||||
},
|
||||
}]
|
||||
|
||||
expect(extractAntigravityGeneratorMetadata({ response: { generatorMetadata: metadata } })).toEqual(metadata)
|
||||
expect(extractAntigravityGeneratorMetadata({ generatorMetadata: metadata })).toEqual(metadata)
|
||||
expect(extractAntigravityGeneratorMetadata({ response: { generatorMetadata: null } })).toEqual([])
|
||||
expect(extractAntigravityGeneratorMetadata(null)).toEqual([])
|
||||
})
|
||||
|
||||
it('derives cascade ids from legacy .pb and Antigravity 2 .db files', () => {
|
||||
expect(antigravityCascadeIdFromPath('/tmp/123.pb')).toBe('123')
|
||||
expect(antigravityCascadeIdFromPath('/tmp/456.db')).toBe('456')
|
||||
expect(antigravityCascadeIdFromPath('/tmp/789.db-wal')).toBe('789.db-wal')
|
||||
})
|
||||
|
||||
it('routes app and CLI source paths to matching Antigravity app data dirs', () => {
|
||||
expect(antigravityAppDataDirFromSourcePath(
|
||||
'/Users/dev/.gemini/antigravity/conversations/session.db',
|
||||
)).toBe('antigravity')
|
||||
|
||||
expect(antigravityAppDataDirFromSourcePath(
|
||||
'/Users/dev/.gemini/antigravity-cli/conversations/session.pb',
|
||||
)).toBe('antigravity-cli')
|
||||
|
||||
expect(antigravityAppDataDirFromSourcePath(
|
||||
'C:\\Users\\Admin\\.gemini\\antigravity-cli\\implicit\\session.pb',
|
||||
)).toBe('antigravity-cli')
|
||||
|
||||
expect(antigravityAppDataDirFromSourcePath(
|
||||
'/Users/dev/.gemini/antigravity-ide/conversations/session.db',
|
||||
)).toBe('antigravity-ide')
|
||||
|
||||
expect(antigravityAppDataDirFromSourcePath(
|
||||
'C:\\Users\\Admin\\.gemini\\antigravity-ide\\implicit\\session.pb',
|
||||
)).toBe('antigravity-ide')
|
||||
})
|
||||
|
||||
it('discovers legacy .pb files and Antigravity 2 .db files only', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'codeburn-antigravity-'))
|
||||
|
||||
try {
|
||||
await writeFile(join(dir, 'legacy.pb'), '')
|
||||
await writeFile(join(dir, 'antigravity-2.db'), '')
|
||||
await writeFile(join(dir, 'uppercase.DB'), '')
|
||||
await writeFile(join(dir, 'antigravity-2.db-wal'), '')
|
||||
await mkdir(join(dir, 'directory.pb'))
|
||||
|
||||
const sources = await discoverAntigravitySessionSources([{
|
||||
dir,
|
||||
project: 'test-project',
|
||||
extensions: ['.pb', '.db'],
|
||||
}])
|
||||
|
||||
expect(sources).toEqual([
|
||||
{ path: join(dir, 'antigravity-2.db'), project: 'test-project', provider: 'antigravity' },
|
||||
{ path: join(dir, 'legacy.pb'), project: 'test-project', provider: 'antigravity' },
|
||||
{ path: join(dir, 'uppercase.DB'), project: 'test-project', provider: 'antigravity' },
|
||||
])
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('discovers antigravity-ide conversation and implicit files', async () => {
|
||||
const tempHome = await mkdtemp(join(tmpdir(), 'codeburn-home-'))
|
||||
const conversationsDir = join(tempHome, '.gemini', 'antigravity-ide', 'conversations')
|
||||
const implicitDir = join(tempHome, '.gemini', 'antigravity-ide', 'implicit')
|
||||
|
||||
await mkdir(conversationsDir, { recursive: true })
|
||||
await mkdir(implicitDir, { recursive: true })
|
||||
|
||||
await writeFile(join(conversationsDir, 'session1.db'), '')
|
||||
await writeFile(join(implicitDir, 'session2.pb'), '')
|
||||
|
||||
const roots = [
|
||||
{
|
||||
dir: conversationsDir,
|
||||
project: 'antigravity-ide',
|
||||
extensions: ['.pb', '.db'] as const,
|
||||
},
|
||||
{
|
||||
dir: implicitDir,
|
||||
project: 'antigravity-ide',
|
||||
extensions: ['.pb'] as const,
|
||||
},
|
||||
]
|
||||
|
||||
const sources = await discoverAntigravitySessionSources(roots)
|
||||
expect(sources).toEqual([
|
||||
{ path: join(conversationsDir, 'session1.db'), project: 'antigravity-ide', provider: 'antigravity' },
|
||||
{ path: join(implicitDir, 'session2.pb'), project: 'antigravity-ide', provider: 'antigravity' },
|
||||
])
|
||||
|
||||
await rm(tempHome, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('displays Gemini 3.5 Flash thinking variants as the base model', () => {
|
||||
const provider = createAntigravityProvider()
|
||||
|
||||
expect(provider.modelDisplayName('gemini-3.5-flash')).toBe('Gemini 3.5 Flash')
|
||||
expect(provider.modelDisplayName('gemini-3.5-flash-high')).toBe('Gemini 3.5 Flash')
|
||||
expect(provider.modelDisplayName('gemini-3.5-flash-medium')).toBe('Gemini 3.5 Flash')
|
||||
expect(provider.modelDisplayName('gemini-3.5-flash-low')).toBe('Gemini 3.5 Flash')
|
||||
expect(provider.modelDisplayName('Gemini 3.5 Flash (High)')).toBe('Gemini 3.5 Flash')
|
||||
})
|
||||
|
||||
it('captures exact Antigravity CLI statusLine usage as fallback calls', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'codeburn-antigravity-statusline-'))
|
||||
process.env['CODEBURN_CACHE_DIR'] = dir
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
conversation_id: 'ce061468-2e2b-4c6f-bf4f-e072bd5fa986',
|
||||
session_id: 'session-1',
|
||||
cwd: '/workspace/project',
|
||||
model: {
|
||||
id: 'Gemini 3.5 Flash (High)',
|
||||
display_name: 'Gemini 3.5 Flash (High)',
|
||||
},
|
||||
context_window: {
|
||||
current_usage: {
|
||||
input_tokens: 28407,
|
||||
output_tokens: 137,
|
||||
cache_creation_input_tokens: 0,
|
||||
cache_read_input_tokens: 0,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
expect(await recordAntigravityStatusLinePayload(payload)).toBe(true)
|
||||
expect(await recordAntigravityStatusLinePayload(payload)).toBe(true)
|
||||
|
||||
const recorded = await readFile(getAntigravityStatusLineEventsPath(), 'utf-8')
|
||||
expect(recorded).not.toContain('/workspace/project')
|
||||
expect(JSON.parse(recorded.split(/\r?\n/)[0]!)).not.toHaveProperty('cwd')
|
||||
|
||||
const source = {
|
||||
path: getAntigravityStatusLineEventsPath(),
|
||||
project: 'antigravity-cli',
|
||||
provider: 'antigravity',
|
||||
}
|
||||
|
||||
const parser = createAntigravityProvider().createSessionParser(source, new Set())
|
||||
const calls = []
|
||||
for await (const call of parser.parse()) calls.push(call)
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]).toMatchObject({
|
||||
provider: 'antigravity',
|
||||
model: 'Gemini 3.5 Flash (High)',
|
||||
inputTokens: 28407,
|
||||
outputTokens: 137,
|
||||
cacheCreationInputTokens: 0,
|
||||
cacheReadInputTokens: 0,
|
||||
cachedInputTokens: 0,
|
||||
sessionId: 'ce061468-2e2b-4c6f-bf4f-e072bd5fa986',
|
||||
project: 'antigravity-cli',
|
||||
})
|
||||
expect(calls[0]!.projectPath).toBeUndefined()
|
||||
expect(calls[0]!.costUSD).toBeGreaterThan(0)
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('skips statusLine fallback calls when RPC cache already covered the conversation', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'codeburn-antigravity-statusline-rpc-dedup-'))
|
||||
process.env['CODEBURN_CACHE_DIR'] = dir
|
||||
|
||||
try {
|
||||
expect(await recordAntigravityStatusLinePayload({
|
||||
conversation_id: 'rpc-covered-conversation',
|
||||
session_id: 'session-1',
|
||||
model: 'Gemini 3.5 Flash (High)',
|
||||
context_window: {
|
||||
current_usage: {
|
||||
input_tokens: 1000,
|
||||
output_tokens: 100,
|
||||
cache_creation_input_tokens: 0,
|
||||
cache_read_input_tokens: 0,
|
||||
},
|
||||
},
|
||||
})).toBe(true)
|
||||
|
||||
const parser = createAntigravityProvider().createSessionParser({
|
||||
path: getAntigravityStatusLineEventsPath(),
|
||||
project: 'antigravity-cli',
|
||||
provider: 'antigravity',
|
||||
}, new Set(['antigravity:rpc-covered-conversation:0']))
|
||||
|
||||
const calls = []
|
||||
for await (const call of parser.parse()) calls.push(call)
|
||||
|
||||
expect(calls).toEqual([])
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('skips singleton statusLine snapshots and deltas monotonic usage', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'codeburn-antigravity-statusline-runs-'))
|
||||
process.env['CODEBURN_CACHE_DIR'] = dir
|
||||
|
||||
const basePayload = {
|
||||
conversation_id: 'statusline-runs',
|
||||
session_id: 'session-1',
|
||||
model: 'Gemini 3.5 Flash (High)',
|
||||
}
|
||||
|
||||
const withUsage = (
|
||||
input_tokens: number,
|
||||
output_tokens: number,
|
||||
cache_read_input_tokens = 0,
|
||||
) => ({
|
||||
...basePayload,
|
||||
context_window: {
|
||||
current_usage: {
|
||||
input_tokens,
|
||||
output_tokens,
|
||||
cache_creation_input_tokens: 0,
|
||||
cache_read_input_tokens,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
expect(await recordAntigravityStatusLinePayload(withUsage(100, 10))).toBe(true)
|
||||
expect(await recordAntigravityStatusLinePayload(withUsage(200, 20))).toBe(true)
|
||||
expect(await recordAntigravityStatusLinePayload(withUsage(200, 20))).toBe(true)
|
||||
expect(await recordAntigravityStatusLinePayload(withUsage(300, 30, 50))).toBe(true)
|
||||
|
||||
const parser = createAntigravityProvider().createSessionParser({
|
||||
path: getAntigravityStatusLineEventsPath(),
|
||||
project: 'antigravity-cli',
|
||||
provider: 'antigravity',
|
||||
}, new Set())
|
||||
|
||||
const calls = []
|
||||
for await (const call of parser.parse()) calls.push(call)
|
||||
|
||||
expect(calls).toHaveLength(2)
|
||||
expect(calls.map(call => [call.inputTokens, call.outputTokens, call.cacheReadInputTokens])).toEqual([
|
||||
[200, 20, 0],
|
||||
[100, 10, 50],
|
||||
])
|
||||
expect(calls.map(call => call.cachedInputTokens)).toEqual([0, 0])
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('treats non-monotonic statusLine usage as a new request snapshot', async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'codeburn-antigravity-statusline-reset-'))
|
||||
process.env['CODEBURN_CACHE_DIR'] = dir
|
||||
|
||||
const payload = (
|
||||
input_tokens: number,
|
||||
output_tokens: number,
|
||||
cache_read_input_tokens = 0,
|
||||
) => ({
|
||||
conversation_id: 'statusline-reset',
|
||||
session_id: 'session-1',
|
||||
model: 'Gemini 3.5 Flash (High)',
|
||||
context_window: {
|
||||
current_usage: {
|
||||
input_tokens,
|
||||
output_tokens,
|
||||
cache_creation_input_tokens: 0,
|
||||
cache_read_input_tokens,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
expect(await recordAntigravityStatusLinePayload(payload(1000, 100))).toBe(true)
|
||||
expect(await recordAntigravityStatusLinePayload(payload(1000, 100))).toBe(true)
|
||||
expect(await recordAntigravityStatusLinePayload(payload(200, 30, 500))).toBe(true)
|
||||
|
||||
const parser = createAntigravityProvider().createSessionParser({
|
||||
path: getAntigravityStatusLineEventsPath(),
|
||||
project: 'antigravity-cli',
|
||||
provider: 'antigravity',
|
||||
}, new Set())
|
||||
|
||||
const calls = []
|
||||
for await (const call of parser.parse()) calls.push(call)
|
||||
|
||||
expect(calls).toHaveLength(2)
|
||||
expect(calls.map(call => [call.inputTokens, call.outputTokens, call.cacheReadInputTokens])).toEqual([
|
||||
[1000, 100, 0],
|
||||
[200, 30, 500],
|
||||
])
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('always reparses append-only statusLine sources but not unchanged cached cascades', () => {
|
||||
const statusLinePath = getAntigravityStatusLineEventsPath()
|
||||
|
||||
expect(shouldReparseAntigravitySource(statusLinePath, 1)).toBe(true)
|
||||
expect(shouldReparseAntigravitySource('/tmp/antigravity/conversation.pb', 0)).toBe(true)
|
||||
expect(shouldReparseAntigravitySource('/tmp/antigravity/conversation.pb', 1)).toBe(false)
|
||||
})
|
||||
|
||||
it('parses current Antigravity CLI SQLite conversations with non-zero token usage', async () => {
|
||||
if (!isSqliteAvailable()) return
|
||||
|
||||
const tempHome = await mkdtemp(join(tmpdir(), 'codeburn-antigravity-current-cli-'))
|
||||
const cacheDir = join(tempHome, 'cache')
|
||||
const previousCacheDir = process.env['CODEBURN_CACHE_DIR']
|
||||
process.env['CODEBURN_CACHE_DIR'] = cacheDir
|
||||
|
||||
try {
|
||||
const fixture = JSON.parse(await readFile(
|
||||
new URL('../fixtures/antigravity-cli-current/gen-metadata.json', import.meta.url),
|
||||
'utf-8',
|
||||
)) as CurrentCliFixture
|
||||
const conversationsDir = join(tempHome, '.gemini', 'antigravity-cli', 'conversations')
|
||||
const logsDir = join(
|
||||
tempHome,
|
||||
'.gemini',
|
||||
'antigravity-cli',
|
||||
'brain',
|
||||
fixture.conversationId,
|
||||
'.system_generated',
|
||||
'logs',
|
||||
)
|
||||
|
||||
await mkdir(conversationsDir, { recursive: true })
|
||||
await mkdir(logsDir, { recursive: true })
|
||||
await writeFile(
|
||||
join(logsDir, 'transcript.jsonl'),
|
||||
await readFile(
|
||||
new URL(
|
||||
'../fixtures/antigravity-cli-current/brain/fixture-current-cli/.system_generated/logs/transcript.jsonl',
|
||||
import.meta.url,
|
||||
),
|
||||
'utf-8',
|
||||
),
|
||||
)
|
||||
|
||||
const dbPath = join(conversationsDir, `${fixture.conversationId}.db`)
|
||||
createCurrentAntigravityCliDb(dbPath, fixture)
|
||||
|
||||
const sources = await discoverAntigravitySessionSources([{
|
||||
dir: conversationsDir,
|
||||
project: 'antigravity-cli',
|
||||
extensions: ['.pb', '.db'],
|
||||
}])
|
||||
expect(sources).toEqual([{ path: dbPath, project: 'antigravity-cli', provider: 'antigravity' }])
|
||||
|
||||
const calls = await collectAntigravityCalls(sources[0]!)
|
||||
|
||||
expect(calls.length).toBeGreaterThanOrEqual(1)
|
||||
expect(calls[0]).toMatchObject({
|
||||
provider: 'antigravity',
|
||||
model: 'gemini-3.1-pro-high',
|
||||
inputTokens: 30265,
|
||||
outputTokens: 659,
|
||||
reasoningTokens: 71,
|
||||
sessionId: fixture.conversationId,
|
||||
project: 'antigravity-cli',
|
||||
})
|
||||
expect(calls[0]!.projectPath).toBeUndefined()
|
||||
expect(calls[0]!.costUSD).toBeGreaterThan(0)
|
||||
} finally {
|
||||
if (previousCacheDir === undefined) delete process.env['CODEBURN_CACHE_DIR']
|
||||
else process.env['CODEBURN_CACHE_DIR'] = previousCacheDir
|
||||
await rm(tempHome, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('deduplicates current SQLite rows against RPC response ids with hyphens', async () => {
|
||||
if (!isSqliteAvailable()) return
|
||||
|
||||
const tempHome = await mkdtemp(join(tmpdir(), 'codeburn-antigravity-current-cli-dedup-'))
|
||||
const cacheDir = join(tempHome, 'cache')
|
||||
const previousCacheDir = process.env['CODEBURN_CACHE_DIR']
|
||||
process.env['CODEBURN_CACHE_DIR'] = cacheDir
|
||||
|
||||
try {
|
||||
const fixture = JSON.parse(await readFile(
|
||||
new URL('../fixtures/antigravity-cli-current/gen-metadata.json', import.meta.url),
|
||||
'utf-8',
|
||||
)) as CurrentCliFixture
|
||||
const conversationsDir = join(tempHome, '.gemini', 'antigravity-cli', 'conversations')
|
||||
|
||||
await mkdir(conversationsDir, { recursive: true })
|
||||
|
||||
const dbPath = join(conversationsDir, `${fixture.conversationId}.db`)
|
||||
createCurrentAntigravityCliDb(dbPath, fixture)
|
||||
|
||||
const parser = createAntigravityProvider().createSessionParser({
|
||||
path: dbPath,
|
||||
project: 'antigravity-cli',
|
||||
provider: 'antigravity',
|
||||
}, new Set([`antigravity:${fixture.conversationId}:fixture-response-1`]))
|
||||
const calls = []
|
||||
for await (const call of parser.parse()) calls.push(call)
|
||||
|
||||
expect(calls).toEqual([])
|
||||
} finally {
|
||||
if (previousCacheDir === undefined) delete process.env['CODEBURN_CACHE_DIR']
|
||||
else process.env['CODEBURN_CACHE_DIR'] = previousCacheDir
|
||||
await rm(tempHome, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,377 @@
|
||||
import { mkdtemp, mkdir, rm, writeFile } from 'fs/promises'
|
||||
import { delimiter as pathDelimiter, join } from 'path'
|
||||
import { tmpdir, homedir } from 'os'
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||
|
||||
import { claude } from '../../src/providers/claude.js'
|
||||
import { clearSessionCache, filterProjectsByClaudeConfigSource, parseAllSessions } from '../../src/parser.js'
|
||||
|
||||
let tmpRoot: string
|
||||
const savedEnv = {
|
||||
CLAUDE_CONFIG_DIR: process.env['CLAUDE_CONFIG_DIR'],
|
||||
CLAUDE_CONFIG_DIRS: process.env['CLAUDE_CONFIG_DIRS'],
|
||||
HOME: process.env['HOME'],
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
clearSessionCache()
|
||||
tmpRoot = await mkdtemp(join(tmpdir(), 'codeburn-claude-multi-'))
|
||||
// Point HOME at a scratch dir so the default `~/.claude` fallback resolves
|
||||
// somewhere we control. Without this, a stray `~/.claude` on the test
|
||||
// machine could leak into discovery.
|
||||
process.env['HOME'] = join(tmpRoot, 'home')
|
||||
await mkdir(process.env['HOME'], { recursive: true })
|
||||
delete process.env['CLAUDE_CONFIG_DIR']
|
||||
delete process.env['CLAUDE_CONFIG_DIRS']
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
clearSessionCache()
|
||||
for (const [k, v] of Object.entries(savedEnv)) {
|
||||
if (v === undefined) delete process.env[k]
|
||||
else process.env[k] = v
|
||||
}
|
||||
await rm(tmpRoot, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
async function makeConfigDir(name: string, projectSlugs: string[]): Promise<string> {
|
||||
const dir = join(tmpRoot, name)
|
||||
for (const slug of projectSlugs) {
|
||||
const projectDir = join(dir, 'projects', slug)
|
||||
await mkdir(projectDir, { recursive: true })
|
||||
// Discovery only checks for the project subdirectory. A real session
|
||||
// file is not required; the parser is exercised separately below.
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
async function writeSession(configDir: string, slug: string, sessionId: string, lines: string[]): Promise<void> {
|
||||
const dir = join(configDir, 'projects', slug)
|
||||
await mkdir(dir, { recursive: true })
|
||||
await writeFile(join(dir, `${sessionId}.jsonl`), lines.join('\n'))
|
||||
}
|
||||
|
||||
function summaryLine(sessionId: string, cwd: string): string {
|
||||
return JSON.stringify({
|
||||
type: 'summary',
|
||||
summary: 'test',
|
||||
leafUuid: 'l',
|
||||
sessionId,
|
||||
cwd,
|
||||
timestamp: '2026-05-09T00:00:00.000Z',
|
||||
})
|
||||
}
|
||||
|
||||
function userLine(uuid: string, sessionId: string, cwd: string, text: string): string {
|
||||
return JSON.stringify({
|
||||
type: 'user',
|
||||
uuid,
|
||||
sessionId,
|
||||
cwd,
|
||||
timestamp: '2026-05-09T00:00:01.000Z',
|
||||
message: { role: 'user', content: text },
|
||||
})
|
||||
}
|
||||
|
||||
function assistantLine(uuid: string, parentUuid: string, sessionId: string, cwd: string): string {
|
||||
return JSON.stringify({
|
||||
type: 'assistant',
|
||||
uuid,
|
||||
parentUuid,
|
||||
sessionId,
|
||||
cwd,
|
||||
timestamp: '2026-05-09T00:00:02.000Z',
|
||||
message: {
|
||||
id: `msg_${uuid}`,
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
model: 'claude-sonnet-4-6',
|
||||
content: [{ type: 'text', text: 'reply' }],
|
||||
usage: { input_tokens: 100, output_tokens: 50 },
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('claude provider — CLAUDE_CONFIG_DIRS discovery', () => {
|
||||
it('falls back to ~/.claude when no env var is set', async () => {
|
||||
const homeDir = process.env['HOME']!
|
||||
await mkdir(join(homeDir, '.claude', 'projects', '-Users-you-app'), { recursive: true })
|
||||
|
||||
const sources = await claude.discoverSessions()
|
||||
const projectDirs = sources.map(s => s.path)
|
||||
expect(projectDirs).toContain(join(homeDir, '.claude', 'projects', '-Users-you-app'))
|
||||
})
|
||||
|
||||
it('honors CLAUDE_CONFIG_DIR for a single override', async () => {
|
||||
const dir = await makeConfigDir('claude-work', ['-Users-you-app'])
|
||||
process.env['CLAUDE_CONFIG_DIR'] = dir
|
||||
|
||||
const sources = await claude.discoverSessions()
|
||||
expect(sources.some(s => s.path === join(dir, 'projects', '-Users-you-app'))).toBe(true)
|
||||
// The default `~/.claude` should NOT also be scanned when the override is set.
|
||||
expect(sources.every(s => !s.path.startsWith(join(process.env['HOME']!, '.claude')))).toBe(true)
|
||||
})
|
||||
|
||||
it('CLAUDE_CONFIG_DIRS overrides CLAUDE_CONFIG_DIR and walks every dir in the list', async () => {
|
||||
const work = await makeConfigDir('claude-work', ['-Users-you-app'])
|
||||
const personal = await makeConfigDir('claude-personal', ['-Users-you-app'])
|
||||
const single = await makeConfigDir('claude-other', ['-Users-you-other'])
|
||||
|
||||
process.env['CLAUDE_CONFIG_DIR'] = single
|
||||
process.env['CLAUDE_CONFIG_DIRS'] = [work, personal].join(pathDelimiter)
|
||||
|
||||
const sources = await claude.discoverSessions()
|
||||
const paths = sources.map(s => s.path)
|
||||
expect(paths).toContain(join(work, 'projects', '-Users-you-app'))
|
||||
expect(paths).toContain(join(personal, 'projects', '-Users-you-app'))
|
||||
// CLAUDE_CONFIG_DIR should be ignored once CLAUDE_CONFIG_DIRS is non-empty.
|
||||
expect(paths.some(p => p.startsWith(single))).toBe(false)
|
||||
})
|
||||
|
||||
it('emits the same project name for the same slug across dirs (so parser merges)', async () => {
|
||||
const work = await makeConfigDir('claude-work', ['-Users-you-app'])
|
||||
const personal = await makeConfigDir('claude-personal', ['-Users-you-app'])
|
||||
process.env['CLAUDE_CONFIG_DIRS'] = [work, personal].join(pathDelimiter)
|
||||
|
||||
const sources = await claude.discoverSessions()
|
||||
const ourSources = sources.filter(s =>
|
||||
s.path === join(work, 'projects', '-Users-you-app') ||
|
||||
s.path === join(personal, 'projects', '-Users-you-app'),
|
||||
)
|
||||
expect(ourSources).toHaveLength(2)
|
||||
expect(new Set(ourSources.map(s => s.project))).toEqual(new Set(['-Users-you-app']))
|
||||
expect(new Set(ourSources.map(s => s.sourceKind))).toEqual(new Set(['claude-config']))
|
||||
expect(new Set(ourSources.map(s => s.sourceLabel))).toEqual(new Set(['claude-work', 'claude-personal']))
|
||||
expect(ourSources.every(s => typeof s.sourceId === 'string' && s.sourceId.startsWith('claude-config:'))).toBe(true)
|
||||
})
|
||||
|
||||
it('tolerates a non-existent dir in the list without dropping the real ones', async () => {
|
||||
const real = await makeConfigDir('claude-real', ['-Users-you-app'])
|
||||
const fake = join(tmpRoot, 'does-not-exist')
|
||||
process.env['CLAUDE_CONFIG_DIRS'] = [real, fake].join(pathDelimiter)
|
||||
|
||||
const sources = await claude.discoverSessions()
|
||||
expect(sources.some(s => s.path === join(real, 'projects', '-Users-you-app'))).toBe(true)
|
||||
})
|
||||
|
||||
it('dedupes when the same dir appears twice in CLAUDE_CONFIG_DIRS', async () => {
|
||||
const dir = await makeConfigDir('claude-once', ['-Users-you-app'])
|
||||
process.env['CLAUDE_CONFIG_DIRS'] = [dir, dir].join(pathDelimiter)
|
||||
|
||||
const sources = await claude.discoverSessions()
|
||||
const ourSources = sources.filter(s => s.path === join(dir, 'projects', '-Users-you-app'))
|
||||
expect(ourSources).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('skips empty entries (leading, trailing, doubled delimiters)', async () => {
|
||||
const dir = await makeConfigDir('claude-only', ['-Users-you-app'])
|
||||
process.env['CLAUDE_CONFIG_DIRS'] = `${pathDelimiter}${dir}${pathDelimiter}${pathDelimiter}`
|
||||
|
||||
const sources = await claude.discoverSessions()
|
||||
expect(sources.some(s => s.path === join(dir, 'projects', '-Users-you-app'))).toBe(true)
|
||||
})
|
||||
|
||||
it('expands ~ in CLAUDE_CONFIG_DIR', async () => {
|
||||
const homeDir = process.env['HOME']!
|
||||
await mkdir(join(homeDir, 'custom-claude', 'projects', '-Users-you-app'), { recursive: true })
|
||||
process.env['CLAUDE_CONFIG_DIR'] = '~/custom-claude'
|
||||
|
||||
const sources = await claude.discoverSessions()
|
||||
expect(sources.some(s => s.path === join(homeDir, 'custom-claude', 'projects', '-Users-you-app'))).toBe(true)
|
||||
})
|
||||
|
||||
it('falls back to CLAUDE_CONFIG_DIR when CLAUDE_CONFIG_DIRS is set but empty', async () => {
|
||||
const single = await makeConfigDir('claude-fallback', ['-Users-you-app'])
|
||||
process.env['CLAUDE_CONFIG_DIR'] = single
|
||||
process.env['CLAUDE_CONFIG_DIRS'] = ''
|
||||
|
||||
const sources = await claude.discoverSessions()
|
||||
expect(sources.some(s => s.path === join(single, 'projects', '-Users-you-app'))).toBe(true)
|
||||
})
|
||||
|
||||
it('skips entries that point at a file rather than a directory', async () => {
|
||||
const real = await makeConfigDir('claude-real', ['-Users-you-app'])
|
||||
const filePath = join(tmpRoot, 'not-a-dir.txt')
|
||||
await writeFile(filePath, 'this is not a config dir')
|
||||
process.env['CLAUDE_CONFIG_DIRS'] = [real, filePath].join(pathDelimiter)
|
||||
|
||||
const sources = await claude.discoverSessions()
|
||||
expect(sources.some(s => s.path === join(real, 'projects', '-Users-you-app'))).toBe(true)
|
||||
expect(sources.every(s => !s.path.startsWith(filePath))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('claude provider — config.json claudeConfigDirs (menubar-driven)', () => {
|
||||
async function writeConfigJson(value: unknown): Promise<void> {
|
||||
const dir = join(process.env['HOME']!, '.config', 'codeburn')
|
||||
await mkdir(dir, { recursive: true })
|
||||
await writeFile(join(dir, 'config.json'), JSON.stringify({ claudeConfigDirs: value }))
|
||||
}
|
||||
|
||||
it('honors claudeConfigDirs from config.json when no env var is set', async () => {
|
||||
const work = await makeConfigDir('claude-work', ['-Users-you-app'])
|
||||
const personal = await makeConfigDir('claude-personal', ['-Users-you-app'])
|
||||
await writeConfigJson([work, personal])
|
||||
|
||||
const sources = await claude.discoverSessions()
|
||||
const paths = sources.map(s => s.path)
|
||||
expect(paths).toContain(join(work, 'projects', '-Users-you-app'))
|
||||
expect(paths).toContain(join(personal, 'projects', '-Users-you-app'))
|
||||
})
|
||||
|
||||
it('lets env CLAUDE_CONFIG_DIRS override config.json', async () => {
|
||||
const fromEnv = await makeConfigDir('claude-env', ['-Users-you-app'])
|
||||
const fromFile = await makeConfigDir('claude-file', ['-Users-you-app'])
|
||||
await writeConfigJson([fromFile])
|
||||
process.env['CLAUDE_CONFIG_DIRS'] = fromEnv
|
||||
|
||||
const sources = await claude.discoverSessions()
|
||||
expect(sources.some(s => s.path === join(fromEnv, 'projects', '-Users-you-app'))).toBe(true)
|
||||
expect(sources.every(s => !s.path.startsWith(fromFile))).toBe(true)
|
||||
})
|
||||
|
||||
it('lets env CLAUDE_CONFIG_DIR override config.json', async () => {
|
||||
const fromEnv = await makeConfigDir('claude-env', ['-Users-you-app'])
|
||||
const fromFile = await makeConfigDir('claude-file', ['-Users-you-app'])
|
||||
await writeConfigJson([fromFile])
|
||||
process.env['CLAUDE_CONFIG_DIR'] = fromEnv
|
||||
|
||||
const sources = await claude.discoverSessions()
|
||||
expect(sources.some(s => s.path === join(fromEnv, 'projects', '-Users-you-app'))).toBe(true)
|
||||
expect(sources.every(s => !s.path.startsWith(fromFile))).toBe(true)
|
||||
})
|
||||
|
||||
it('falls back to ~/.claude when config.json claudeConfigDirs is empty', async () => {
|
||||
const homeDir = process.env['HOME']!
|
||||
await mkdir(join(homeDir, '.claude', 'projects', '-Users-you-app'), { recursive: true })
|
||||
await writeConfigJson([])
|
||||
|
||||
const sources = await claude.discoverSessions()
|
||||
expect(sources.some(s => s.path === join(homeDir, '.claude', 'projects', '-Users-you-app'))).toBe(true)
|
||||
})
|
||||
|
||||
it('expands ~ in config.json entries', async () => {
|
||||
const homeDir = process.env['HOME']!
|
||||
await mkdir(join(homeDir, 'cfg-claude', 'projects', '-Users-you-app'), { recursive: true })
|
||||
await writeConfigJson(['~/cfg-claude'])
|
||||
|
||||
const sources = await claude.discoverSessions()
|
||||
expect(sources.some(s => s.path === join(homeDir, 'cfg-claude', 'projects', '-Users-you-app'))).toBe(true)
|
||||
})
|
||||
|
||||
it('ignores non-string and blank entries in config.json', async () => {
|
||||
const real = await makeConfigDir('claude-real', ['-Users-you-app'])
|
||||
await writeConfigJson([real, 42, '', ' ', null])
|
||||
|
||||
const sources = await claude.discoverSessions()
|
||||
expect(sources.some(s => s.path === join(real, 'projects', '-Users-you-app'))).toBe(true)
|
||||
})
|
||||
|
||||
it('falls back to ~/.claude when claudeConfigDirs is not an array', async () => {
|
||||
const homeDir = process.env['HOME']!
|
||||
await mkdir(join(homeDir, '.claude', 'projects', '-Users-you-app'), { recursive: true })
|
||||
await writeConfigJson('not-an-array')
|
||||
|
||||
const sources = await claude.discoverSessions()
|
||||
expect(sources.some(s => s.path === join(homeDir, '.claude', 'projects', '-Users-you-app'))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('claude parser — multi-dir aggregation (issue #208 option 1)', () => {
|
||||
it('merges sessions from two config dirs into a single ProjectSummary when the canonical cwd matches', async () => {
|
||||
const work = await makeConfigDir('claude-work', [])
|
||||
const personal = await makeConfigDir('claude-personal', [])
|
||||
process.env['CLAUDE_CONFIG_DIRS'] = [work, personal].join(pathDelimiter)
|
||||
|
||||
// Both accounts touch the same real project path. Same cwd -> same merge key.
|
||||
const slug = '-Users-you-shared-app'
|
||||
const cwd = '/Users/you/shared-app'
|
||||
await writeSession(work, slug, 'sess-work', [
|
||||
summaryLine('sess-work', cwd),
|
||||
userLine('u1', 'sess-work', cwd, 'hi from work'),
|
||||
assistantLine('a1', 'u1', 'sess-work', cwd),
|
||||
])
|
||||
await writeSession(personal, slug, 'sess-personal', [
|
||||
summaryLine('sess-personal', cwd),
|
||||
userLine('u2', 'sess-personal', cwd, 'hi from personal'),
|
||||
assistantLine('a2', 'u2', 'sess-personal', cwd),
|
||||
])
|
||||
|
||||
const projects = await parseAllSessions(undefined, 'claude')
|
||||
const matches = projects.filter(p => p.project === slug)
|
||||
expect(matches).toHaveLength(1)
|
||||
expect(matches[0]!.totalApiCalls).toBe(2)
|
||||
// Two sessions, one from each dir, both rolled up.
|
||||
expect(matches[0]!.sessions.map(s => s.sessionId).sort()).toEqual(['sess-personal', 'sess-work'])
|
||||
// No `account` or `accountPath` field should appear on the ProjectSummary
|
||||
// — option 1 explicitly avoids attribution.
|
||||
expect((matches[0]! as Record<string, unknown>)['account']).toBeUndefined()
|
||||
expect((matches[0]! as Record<string, unknown>)['accountPath']).toBeUndefined()
|
||||
})
|
||||
|
||||
it('keeps source metadata on merged sessions so one Claude config can be selected', async () => {
|
||||
const work = await makeConfigDir('claude-work', [])
|
||||
const personal = await makeConfigDir('claude-personal', [])
|
||||
process.env['CLAUDE_CONFIG_DIRS'] = [work, personal].join(pathDelimiter)
|
||||
|
||||
const slug = '-Users-you-shared-app'
|
||||
const cwd = '/Users/you/shared-app'
|
||||
await writeSession(work, slug, 'sess-work', [
|
||||
summaryLine('sess-work', cwd),
|
||||
userLine('u1', 'sess-work', cwd, 'hi from work'),
|
||||
assistantLine('a1', 'u1', 'sess-work', cwd),
|
||||
])
|
||||
await writeSession(personal, slug, 'sess-personal', [
|
||||
summaryLine('sess-personal', cwd),
|
||||
userLine('u2', 'sess-personal', cwd, 'hi from personal'),
|
||||
assistantLine('a2', 'u2', 'sess-personal', cwd),
|
||||
])
|
||||
|
||||
const projects = await parseAllSessions(undefined, 'claude')
|
||||
const merged = projects.find(p => p.project === slug)
|
||||
expect(merged).toBeDefined()
|
||||
expect(merged!.sessions).toHaveLength(2)
|
||||
|
||||
const sourceIds = new Map(merged!.sessions.map(s => [s.source?.label, s.source?.id]))
|
||||
const workSourceId = sourceIds.get('claude-work')
|
||||
expect(workSourceId).toBeDefined()
|
||||
|
||||
const workOnly = filterProjectsByClaudeConfigSource(projects, workSourceId!)
|
||||
expect(workOnly).toHaveLength(1)
|
||||
expect(workOnly[0]!.project).toBe(slug)
|
||||
expect(workOnly[0]!.totalApiCalls).toBe(1)
|
||||
expect(workOnly[0]!.sessions.map(s => s.sessionId)).toEqual(['sess-work'])
|
||||
expect(workOnly[0]!.sessions[0]!.source?.label).toBe('claude-work')
|
||||
})
|
||||
|
||||
// Documents the path-aware merge behavior: the mergedMap in parseAllSessions
|
||||
// now keys by normalized cwd path (crossProviderKey), not by slug. Two dirs
|
||||
// can share the same slug but have different underlying cwds — those stay
|
||||
// separate because they represent genuinely different repositories. In real
|
||||
// Claude usage different cwds always produce different slugs anyway, so this
|
||||
// scenario is contrived, but the test pins the new behavior explicitly.
|
||||
it('keeps sessions with the same slug but different cwds as separate projects', async () => {
|
||||
const work = await makeConfigDir('claude-work', [])
|
||||
const personal = await makeConfigDir('claude-personal', [])
|
||||
process.env['CLAUDE_CONFIG_DIRS'] = [work, personal].join(pathDelimiter)
|
||||
|
||||
const slug = '-Users-you-app'
|
||||
await writeSession(work, slug, 'sess-work', [
|
||||
summaryLine('sess-work', '/Users/you/work-app'),
|
||||
userLine('u1', 'sess-work', '/Users/you/work-app', 'work'),
|
||||
assistantLine('a1', 'u1', 'sess-work', '/Users/you/work-app'),
|
||||
])
|
||||
await writeSession(personal, slug, 'sess-personal', [
|
||||
summaryLine('sess-personal', '/Users/you/personal-app'),
|
||||
userLine('u2', 'sess-personal', '/Users/you/personal-app', 'personal'),
|
||||
assistantLine('a2', 'u2', 'sess-personal', '/Users/you/personal-app'),
|
||||
])
|
||||
|
||||
const projects = await parseAllSessions(undefined, 'claude')
|
||||
const matches = projects.filter(p => p.project === slug)
|
||||
// Different cwds → different crossProviderKey → two separate project rows.
|
||||
expect(matches).toHaveLength(2)
|
||||
expect(matches.every(m => m.totalApiCalls === 1)).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,139 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||
import { mkdtemp, mkdir, writeFile, rm, utimes } from 'fs/promises'
|
||||
import { join } from 'path'
|
||||
import { tmpdir } from 'os'
|
||||
|
||||
import { cline, createClineProvider } from '../../src/providers/cline.js'
|
||||
import type { ParsedProviderCall } from '../../src/providers/types.js'
|
||||
|
||||
let tmpDir: string
|
||||
|
||||
async function writeTask(baseDir: string, taskId: string, opts?: {
|
||||
tokensIn?: number
|
||||
tokensOut?: number
|
||||
model?: string
|
||||
userMessage?: string
|
||||
cost?: number
|
||||
}): Promise<string> {
|
||||
const taskDir = join(baseDir, 'tasks', taskId)
|
||||
await mkdir(taskDir, { recursive: true })
|
||||
|
||||
const messages: unknown[] = []
|
||||
if (opts?.userMessage) {
|
||||
messages.push({ type: 'say', say: 'user_feedback', text: opts.userMessage, ts: 1700000000000 })
|
||||
}
|
||||
const usage: Record<string, unknown> = {
|
||||
tokensIn: opts?.tokensIn ?? 100,
|
||||
tokensOut: opts?.tokensOut ?? 50,
|
||||
}
|
||||
if (opts?.cost !== undefined) usage.cost = opts.cost
|
||||
messages.push({ type: 'say', say: 'api_req_started', text: JSON.stringify(usage), ts: 1700000001000 })
|
||||
|
||||
const modelTag = opts?.model ? `<model>${opts.model}</model>` : ''
|
||||
const history = [
|
||||
{ role: 'user', content: [{ type: 'text', text: `hello\n<environment_details>\n${modelTag}\n</environment_details>` }] },
|
||||
]
|
||||
|
||||
await writeFile(join(taskDir, 'ui_messages.json'), JSON.stringify(messages))
|
||||
await writeFile(join(taskDir, 'api_conversation_history.json'), JSON.stringify(history))
|
||||
|
||||
return taskDir
|
||||
}
|
||||
|
||||
describe('cline provider - discovery', () => {
|
||||
beforeEach(async () => {
|
||||
tmpDir = await mkdtemp(join(tmpdir(), 'cline-test-'))
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('discovers Cline tasks from VS Code globalStorage and home data roots', async () => {
|
||||
const vscodeDir = join(tmpDir, 'globalStorage')
|
||||
const homeDataDir = join(tmpDir, 'cline-data')
|
||||
await writeTask(vscodeDir, 'task-vscode')
|
||||
await writeTask(homeDataDir, 'task-home')
|
||||
|
||||
const provider = createClineProvider([vscodeDir, homeDataDir])
|
||||
const sessions = await provider.discoverSessions()
|
||||
|
||||
expect(sessions).toHaveLength(2)
|
||||
expect(sessions.map(s => s.provider)).toEqual(['cline', 'cline'])
|
||||
expect(sessions.map(s => s.project)).toEqual(['Cline', 'Cline'])
|
||||
expect(sessions.map(s => s.path).sort()).toEqual([
|
||||
join(homeDataDir, 'tasks', 'task-home'),
|
||||
join(vscodeDir, 'tasks', 'task-vscode'),
|
||||
].sort())
|
||||
})
|
||||
|
||||
it('deduplicates the same task id across roots by keeping the newest task directory', async () => {
|
||||
const vscodeDir = join(tmpDir, 'globalStorage')
|
||||
const homeDataDir = join(tmpDir, 'cline-data')
|
||||
const oldTask = await writeTask(vscodeDir, 'task-same')
|
||||
const newTask = await writeTask(homeDataDir, 'task-same')
|
||||
await utimes(join(oldTask, 'ui_messages.json'), new Date('2026-01-01T00:00:00Z'), new Date('2026-01-01T00:00:00Z'))
|
||||
await utimes(join(newTask, 'ui_messages.json'), new Date('2026-02-01T00:00:00Z'), new Date('2026-02-01T00:00:00Z'))
|
||||
|
||||
const provider = createClineProvider([vscodeDir, homeDataDir])
|
||||
const sessions = await provider.discoverSessions()
|
||||
|
||||
expect(sessions).toHaveLength(1)
|
||||
expect(sessions[0]!.path).toBe(newTask)
|
||||
})
|
||||
|
||||
it('skips task directories without ui_messages.json', async () => {
|
||||
const vscodeDir = join(tmpDir, 'globalStorage')
|
||||
await mkdir(join(vscodeDir, 'tasks', 'task-no-ui'), { recursive: true })
|
||||
|
||||
const provider = createClineProvider(vscodeDir)
|
||||
const sessions = await provider.discoverSessions()
|
||||
|
||||
expect(sessions).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('cline provider - parsing', () => {
|
||||
beforeEach(async () => {
|
||||
tmpDir = await mkdtemp(join(tmpdir(), 'cline-test-'))
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('parses Cline usage with cline provider identity', async () => {
|
||||
const taskDir = await writeTask(tmpDir, 'task-parse', {
|
||||
tokensIn: 200,
|
||||
tokensOut: 100,
|
||||
model: 'anthropic/claude-sonnet-4-5',
|
||||
userMessage: 'build the feature',
|
||||
cost: 0.07,
|
||||
})
|
||||
|
||||
const source = { path: taskDir, project: 'Cline', provider: 'cline' }
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of cline.createSessionParser(source, new Set()).parse()) calls.push(call)
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]!.provider).toBe('cline')
|
||||
expect(calls[0]!.model).toBe('claude-sonnet-4-5')
|
||||
expect(calls[0]!.inputTokens).toBe(200)
|
||||
expect(calls[0]!.outputTokens).toBe(100)
|
||||
expect(calls[0]!.costUSD).toBe(0.07)
|
||||
expect(calls[0]!.userMessage).toBe('build the feature')
|
||||
expect(calls[0]!.deduplicationKey).toMatch(/^cline:task-parse:/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('cline provider - metadata', () => {
|
||||
it('has correct name and displayName', () => {
|
||||
expect(cline.name).toBe('cline')
|
||||
expect(cline.displayName).toBe('Cline')
|
||||
})
|
||||
|
||||
it('passes through model and tool display names', () => {
|
||||
expect(cline.modelDisplayName('claude-sonnet-4-5')).toBe('claude-sonnet-4-5')
|
||||
expect(cline.toolDisplayName('read_file')).toBe('read_file')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,480 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||
import { mkdtemp, mkdir, writeFile, rm } from 'fs/promises'
|
||||
import { join } from 'path'
|
||||
import { tmpdir } from 'os'
|
||||
|
||||
import { createCodebuffProvider } from '../../src/providers/codebuff.js'
|
||||
import type { ParsedProviderCall } from '../../src/providers/types.js'
|
||||
|
||||
let tmpDir: string
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await mkdtemp(join(tmpdir(), 'codebuff-test-'))
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
type ToolBlock = {
|
||||
type: 'tool'
|
||||
toolName: string
|
||||
input?: Record<string, unknown>
|
||||
}
|
||||
|
||||
type TextBlock = { type: 'text'; content: string }
|
||||
|
||||
type Block = ToolBlock | TextBlock
|
||||
|
||||
type AiOpts = {
|
||||
id?: string
|
||||
credits?: number
|
||||
timestamp?: string
|
||||
blocks?: Block[]
|
||||
metadata?: Record<string, unknown>
|
||||
}
|
||||
|
||||
function aiMessage(opts: AiOpts = {}) {
|
||||
const m: Record<string, unknown> = {
|
||||
id: opts.id ?? 'msg-ai-1',
|
||||
variant: 'ai',
|
||||
content: '',
|
||||
timestamp: opts.timestamp ?? '2026-04-14T10:00:30.000Z',
|
||||
}
|
||||
if (opts.blocks !== undefined) m['blocks'] = opts.blocks
|
||||
if (opts.credits !== undefined) m['credits'] = opts.credits
|
||||
if (opts.metadata !== undefined) m['metadata'] = opts.metadata
|
||||
return m
|
||||
}
|
||||
|
||||
function userMessage(content: string, timestamp?: string) {
|
||||
return {
|
||||
id: 'msg-user-1',
|
||||
variant: 'user',
|
||||
content,
|
||||
timestamp: timestamp ?? '2026-04-14T10:00:10.000Z',
|
||||
}
|
||||
}
|
||||
|
||||
async function writeChat(
|
||||
baseDir: string,
|
||||
projectName: string,
|
||||
chatId: string,
|
||||
messages: unknown[],
|
||||
runState?: unknown,
|
||||
): Promise<string> {
|
||||
const chatDir = join(baseDir, 'projects', projectName, 'chats', chatId)
|
||||
await mkdir(chatDir, { recursive: true })
|
||||
await writeFile(join(chatDir, 'chat-messages.json'), JSON.stringify(messages))
|
||||
if (runState !== undefined) {
|
||||
await writeFile(join(chatDir, 'run-state.json'), JSON.stringify(runState))
|
||||
}
|
||||
return chatDir
|
||||
}
|
||||
|
||||
describe('codebuff provider - session discovery', () => {
|
||||
it('discovers sessions under projects/<name>/chats/<chatId>/', async () => {
|
||||
await writeChat(
|
||||
tmpDir,
|
||||
'myproject',
|
||||
'2026-04-14T10-00-00.000Z',
|
||||
[userMessage('hi'), aiMessage({ credits: 10 })],
|
||||
{ sessionState: { projectContext: { cwd: '/Users/test/myproject' } } },
|
||||
)
|
||||
|
||||
const provider = createCodebuffProvider(tmpDir)
|
||||
const sessions = await provider.discoverSessions()
|
||||
|
||||
expect(sessions).toHaveLength(1)
|
||||
expect(sessions[0]!.provider).toBe('codebuff')
|
||||
expect(sessions[0]!.project).toBe('myproject')
|
||||
expect(sessions[0]!.path).toContain('2026-04-14T10-00-00.000Z')
|
||||
})
|
||||
|
||||
it('uses the cwd basename from run-state.json when present', async () => {
|
||||
await writeChat(
|
||||
tmpDir,
|
||||
'sanitized-folder',
|
||||
'2026-04-14T11-00-00.000Z',
|
||||
[aiMessage({ credits: 5 })],
|
||||
{ sessionState: { projectContext: { cwd: '/Users/test/real-project' } } },
|
||||
)
|
||||
|
||||
const provider = createCodebuffProvider(tmpDir)
|
||||
const sessions = await provider.discoverSessions()
|
||||
|
||||
expect(sessions).toHaveLength(1)
|
||||
expect(sessions[0]!.project).toBe('real-project')
|
||||
})
|
||||
|
||||
it('falls back to the folder name when run-state.json is missing', async () => {
|
||||
await writeChat(tmpDir, 'fallback-project', '2026-04-14T12-00-00.000Z', [
|
||||
aiMessage({ credits: 3 }),
|
||||
])
|
||||
|
||||
const provider = createCodebuffProvider(tmpDir)
|
||||
const sessions = await provider.discoverSessions()
|
||||
|
||||
expect(sessions).toHaveLength(1)
|
||||
expect(sessions[0]!.project).toBe('fallback-project')
|
||||
})
|
||||
|
||||
it('discovers sessions across multiple projects', async () => {
|
||||
await writeChat(tmpDir, 'proj-a', '2026-04-14T10-00-00.000Z', [aiMessage({ credits: 1 })])
|
||||
await writeChat(tmpDir, 'proj-b', '2026-04-14T10-30-00.000Z', [aiMessage({ credits: 2 })])
|
||||
|
||||
const provider = createCodebuffProvider(tmpDir)
|
||||
const sessions = await provider.discoverSessions()
|
||||
|
||||
expect(sessions).toHaveLength(2)
|
||||
const projects = sessions.map(s => s.project).sort()
|
||||
expect(projects).toEqual(['proj-a', 'proj-b'])
|
||||
})
|
||||
|
||||
it('returns empty for a non-existent directory', async () => {
|
||||
const provider = createCodebuffProvider('/nonexistent/codebuff-path')
|
||||
const sessions = await provider.discoverSessions()
|
||||
expect(sessions).toEqual([])
|
||||
})
|
||||
|
||||
it('skips chat folders without chat-messages.json', async () => {
|
||||
const chatDir = join(tmpDir, 'projects', 'proj', 'chats', '2026-04-14T10-00-00.000Z')
|
||||
await mkdir(chatDir, { recursive: true })
|
||||
// No chat-messages.json created.
|
||||
|
||||
const provider = createCodebuffProvider(tmpDir)
|
||||
const sessions = await provider.discoverSessions()
|
||||
expect(sessions).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('codebuff provider - JSONL parsing', () => {
|
||||
it('yields one call per assistant message with credits, mapping codebuff tools to canonical names', async () => {
|
||||
const chatDir = await writeChat(
|
||||
tmpDir,
|
||||
'proj',
|
||||
'2026-04-14T10-00-00.000Z',
|
||||
[
|
||||
userMessage('implement the feature'),
|
||||
aiMessage({
|
||||
credits: 42,
|
||||
metadata: {
|
||||
runState: { sessionState: { mainAgentState: { agentType: 'base2' } } },
|
||||
},
|
||||
blocks: [
|
||||
{ type: 'tool', toolName: 'read_files', input: {} },
|
||||
{ type: 'tool', toolName: 'str_replace', input: {} },
|
||||
{ type: 'tool', toolName: 'run_terminal_command', input: { command: 'npm test' } },
|
||||
{ type: 'tool', toolName: 'suggest_followups', input: {} },
|
||||
],
|
||||
}),
|
||||
],
|
||||
)
|
||||
|
||||
const provider = createCodebuffProvider(tmpDir)
|
||||
const source = { path: chatDir, project: 'proj', provider: 'codebuff' }
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of provider.createSessionParser(source, new Set()).parse()) {
|
||||
calls.push(call)
|
||||
}
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
const call = calls[0]!
|
||||
expect(call.provider).toBe('codebuff')
|
||||
expect(call.model).toBe('codebuff-base2')
|
||||
expect(call.userMessage).toBe('implement the feature')
|
||||
// `suggest_followups` is intentionally dropped from the tool breakdown.
|
||||
expect(call.tools).toEqual(['Read', 'Edit', 'Bash'])
|
||||
expect(call.bashCommands).toContain('npm')
|
||||
// Credits × $0.01 = $0.42 when token counts are absent.
|
||||
expect(call.costUSD).toBeCloseTo(0.42, 6)
|
||||
expect(call.inputTokens).toBe(0)
|
||||
expect(call.outputTokens).toBe(0)
|
||||
})
|
||||
|
||||
it('prefers direct metadata.usage tokens when available and still records credits', async () => {
|
||||
const chatDir = await writeChat(tmpDir, 'proj', '2026-04-14T10-00-00.000Z', [
|
||||
aiMessage({
|
||||
credits: 10,
|
||||
metadata: {
|
||||
model: 'claude-haiku-4-5-20251001',
|
||||
usage: {
|
||||
inputTokens: 5000,
|
||||
outputTokens: 2000,
|
||||
cacheCreationInputTokens: 1000,
|
||||
cacheReadInputTokens: 500,
|
||||
},
|
||||
},
|
||||
}),
|
||||
])
|
||||
|
||||
const provider = createCodebuffProvider(tmpDir)
|
||||
const source = { path: chatDir, project: 'proj', provider: 'codebuff' }
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of provider.createSessionParser(source, new Set()).parse()) {
|
||||
calls.push(call)
|
||||
}
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
const call = calls[0]!
|
||||
expect(call.model).toBe('claude-haiku-4-5-20251001')
|
||||
expect(call.inputTokens).toBe(5000)
|
||||
expect(call.outputTokens).toBe(2000)
|
||||
expect(call.cacheCreationInputTokens).toBe(1000)
|
||||
expect(call.cacheReadInputTokens).toBe(500)
|
||||
expect(call.cachedInputTokens).toBe(500)
|
||||
// With real token counts the calculated cost takes precedence over credits.
|
||||
expect(call.costUSD).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('falls back to providerOptions.codebuff.usage in the stashed RunState history', async () => {
|
||||
const chatDir = await writeChat(tmpDir, 'proj', '2026-04-14T10-00-00.000Z', [
|
||||
aiMessage({
|
||||
credits: 7,
|
||||
metadata: {
|
||||
runState: {
|
||||
sessionState: {
|
||||
mainAgentState: {
|
||||
messageHistory: [
|
||||
{ role: 'user' },
|
||||
{
|
||||
role: 'assistant',
|
||||
providerOptions: {
|
||||
codebuff: {
|
||||
model: 'openai/gpt-4o',
|
||||
usage: {
|
||||
prompt_tokens: 2000,
|
||||
completion_tokens: 800,
|
||||
prompt_tokens_details: { cached_tokens: 400 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
])
|
||||
|
||||
const provider = createCodebuffProvider(tmpDir)
|
||||
const source = { path: chatDir, project: 'proj', provider: 'codebuff' }
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of provider.createSessionParser(source, new Set()).parse()) {
|
||||
calls.push(call)
|
||||
}
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]!.model).toBe('openai/gpt-4o')
|
||||
expect(calls[0]!.inputTokens).toBe(2000)
|
||||
expect(calls[0]!.outputTokens).toBe(800)
|
||||
expect(calls[0]!.cacheReadInputTokens).toBe(400)
|
||||
})
|
||||
|
||||
it('skips assistant messages with no credits and no tokens', async () => {
|
||||
const chatDir = await writeChat(tmpDir, 'proj', '2026-04-14T10-00-00.000Z', [
|
||||
aiMessage({ blocks: [{ type: 'text', content: 'mode-divider' }] }),
|
||||
])
|
||||
|
||||
const provider = createCodebuffProvider(tmpDir)
|
||||
const source = { path: chatDir, project: 'proj', provider: 'codebuff' }
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of provider.createSessionParser(source, new Set()).parse()) {
|
||||
calls.push(call)
|
||||
}
|
||||
|
||||
expect(calls).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('deduplicates calls seen across multiple parses', async () => {
|
||||
const chatDir = await writeChat(tmpDir, 'proj', '2026-04-14T10-00-00.000Z', [
|
||||
aiMessage({ id: 'msg-dup', credits: 3 }),
|
||||
])
|
||||
|
||||
const provider = createCodebuffProvider(tmpDir)
|
||||
const source = { path: chatDir, project: 'proj', provider: 'codebuff' }
|
||||
const seenKeys = new Set<string>()
|
||||
|
||||
const firstRun: ParsedProviderCall[] = []
|
||||
for await (const call of provider.createSessionParser(source, seenKeys).parse()) {
|
||||
firstRun.push(call)
|
||||
}
|
||||
|
||||
const secondRun: ParsedProviderCall[] = []
|
||||
for await (const call of provider.createSessionParser(source, seenKeys).parse()) {
|
||||
secondRun.push(call)
|
||||
}
|
||||
|
||||
expect(firstRun).toHaveLength(1)
|
||||
expect(secondRun).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('yields one call per assistant message in a multi-turn chat, preserving user messages', async () => {
|
||||
const chatDir = await writeChat(tmpDir, 'proj', '2026-04-14T10-00-00.000Z', [
|
||||
userMessage('first question'),
|
||||
aiMessage({ id: 'a1', credits: 5, timestamp: '2026-04-14T10:00:30.000Z' }),
|
||||
userMessage('second question', '2026-04-14T10:01:00.000Z'),
|
||||
aiMessage({ id: 'a2', credits: 8, timestamp: '2026-04-14T10:01:30.000Z' }),
|
||||
])
|
||||
|
||||
const provider = createCodebuffProvider(tmpDir)
|
||||
const source = { path: chatDir, project: 'proj', provider: 'codebuff' }
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of provider.createSessionParser(source, new Set()).parse()) {
|
||||
calls.push(call)
|
||||
}
|
||||
|
||||
expect(calls).toHaveLength(2)
|
||||
expect(calls[0]!.userMessage).toBe('first question')
|
||||
expect(calls[0]!.costUSD).toBeCloseTo(0.05, 6)
|
||||
expect(calls[1]!.userMessage).toBe('second question')
|
||||
expect(calls[1]!.costUSD).toBeCloseTo(0.08, 6)
|
||||
})
|
||||
|
||||
it('handles a missing chat-messages.json gracefully', async () => {
|
||||
const provider = createCodebuffProvider(tmpDir)
|
||||
const source = {
|
||||
path: join(tmpDir, 'projects', 'missing', 'chats', 'nope'),
|
||||
project: 'missing',
|
||||
provider: 'codebuff',
|
||||
}
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of provider.createSessionParser(source, new Set()).parse()) {
|
||||
calls.push(call)
|
||||
}
|
||||
expect(calls).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('skips a malformed chat-messages.json without throwing', async () => {
|
||||
const chatDir = join(tmpDir, 'projects', 'proj', 'chats', '2026-04-14T10-00-00.000Z')
|
||||
await mkdir(chatDir, { recursive: true })
|
||||
await writeFile(join(chatDir, 'chat-messages.json'), 'not-valid-json')
|
||||
|
||||
const provider = createCodebuffProvider(tmpDir)
|
||||
const source = { path: chatDir, project: 'proj', provider: 'codebuff' }
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of provider.createSessionParser(source, new Set()).parse()) {
|
||||
calls.push(call)
|
||||
}
|
||||
expect(calls).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('codebuff provider - sessionId channel scoping', () => {
|
||||
it('produces distinct sessionIds for the same chatId across different channel roots', async () => {
|
||||
const chatId = '2026-04-14T10-00-00.000Z'
|
||||
const channelA = join(tmpDir, 'manicode')
|
||||
const channelB = join(tmpDir, 'manicode-dev')
|
||||
const cwd = '/Users/test/shared-project'
|
||||
const runState = { sessionState: { projectContext: { cwd } } }
|
||||
|
||||
const chatDirA = await writeChat(
|
||||
channelA,
|
||||
'shared-project',
|
||||
chatId,
|
||||
[userMessage('hi'), aiMessage({ credits: 5 })],
|
||||
runState,
|
||||
)
|
||||
const chatDirB = await writeChat(
|
||||
channelB,
|
||||
'shared-project',
|
||||
chatId,
|
||||
[userMessage('hi'), aiMessage({ credits: 5 })],
|
||||
runState,
|
||||
)
|
||||
|
||||
const providerA = createCodebuffProvider(channelA)
|
||||
const providerB = createCodebuffProvider(channelB)
|
||||
|
||||
const sourceA = { path: chatDirA, project: 'shared-project', provider: 'codebuff' }
|
||||
const sourceB = { path: chatDirB, project: 'shared-project', provider: 'codebuff' }
|
||||
|
||||
const callsA: ParsedProviderCall[] = []
|
||||
for await (const call of providerA.createSessionParser(sourceA, new Set()).parse()) {
|
||||
callsA.push(call)
|
||||
}
|
||||
const callsB: ParsedProviderCall[] = []
|
||||
for await (const call of providerB.createSessionParser(sourceB, new Set()).parse()) {
|
||||
callsB.push(call)
|
||||
}
|
||||
|
||||
expect(callsA).toHaveLength(1)
|
||||
expect(callsB).toHaveLength(1)
|
||||
// The whole point of the fix: same chatId + same project should NOT
|
||||
// collapse into a single session when the chats live under different
|
||||
// channel roots.
|
||||
expect(callsA[0]!.sessionId).not.toBe(callsB[0]!.sessionId)
|
||||
expect(callsA[0]!.sessionId).toBe(`manicode/${chatId}`)
|
||||
expect(callsB[0]!.sessionId).toBe(`manicode-dev/${chatId}`)
|
||||
// The sessionId must not contain ':' -- src/parser.ts keys sessions as
|
||||
// `${provider}:${sessionId}:${project}` and reconstructs the session via
|
||||
// `key.split(':')[1]`, so a colon would truncate the id downstream.
|
||||
expect(callsA[0]!.sessionId).not.toContain(':')
|
||||
expect(callsB[0]!.sessionId).not.toContain(':')
|
||||
})
|
||||
|
||||
it('includes the channel name in the sessionId', async () => {
|
||||
const chatId = '2026-04-14T10-00-00.000Z'
|
||||
const channelRoot = join(tmpDir, 'manicode-staging')
|
||||
const chatDir = await writeChat(channelRoot, 'proj', chatId, [aiMessage({ credits: 3 })])
|
||||
|
||||
const provider = createCodebuffProvider(channelRoot)
|
||||
const source = { path: chatDir, project: 'proj', provider: 'codebuff' }
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of provider.createSessionParser(source, new Set()).parse()) {
|
||||
calls.push(call)
|
||||
}
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]!.sessionId).toBe(`manicode-staging/${chatId}`)
|
||||
expect(calls[0]!.sessionId).not.toContain(':')
|
||||
})
|
||||
|
||||
it('falls back to the chatId when the path does not match the expected structure', async () => {
|
||||
const chatId = '2026-04-14T10-00-00.000Z'
|
||||
// Not the canonical <channel>/projects/<proj>/chats/<chatId> layout.
|
||||
const chatDir = join(tmpDir, 'oddly-shaped', chatId)
|
||||
await mkdir(chatDir, { recursive: true })
|
||||
await writeFile(
|
||||
join(chatDir, 'chat-messages.json'),
|
||||
JSON.stringify([aiMessage({ credits: 2 })]),
|
||||
)
|
||||
|
||||
const provider = createCodebuffProvider(tmpDir)
|
||||
const source = { path: chatDir, project: 'proj', provider: 'codebuff' }
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of provider.createSessionParser(source, new Set()).parse()) {
|
||||
calls.push(call)
|
||||
}
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]!.sessionId).toBe(chatId)
|
||||
})
|
||||
})
|
||||
|
||||
describe('codebuff provider - display names', () => {
|
||||
const provider = createCodebuffProvider('/tmp')
|
||||
|
||||
it('has the correct identifiers', () => {
|
||||
expect(provider.name).toBe('codebuff')
|
||||
expect(provider.displayName).toBe('Codebuff')
|
||||
})
|
||||
|
||||
it('maps known Codebuff tiers to readable names', () => {
|
||||
expect(provider.modelDisplayName('codebuff')).toBe('Codebuff')
|
||||
expect(provider.modelDisplayName('codebuff-base2')).toBe('Codebuff Base 2')
|
||||
expect(provider.modelDisplayName('codebuff-lite')).toBe('Codebuff Lite')
|
||||
})
|
||||
|
||||
it('returns the raw name for unknown models', () => {
|
||||
expect(provider.modelDisplayName('claude-sonnet-4-6')).toBe('claude-sonnet-4-6')
|
||||
})
|
||||
|
||||
it('normalizes tool names to the canonical set', () => {
|
||||
expect(provider.toolDisplayName('read_files')).toBe('Read')
|
||||
expect(provider.toolDisplayName('str_replace')).toBe('Edit')
|
||||
expect(provider.toolDisplayName('run_terminal_command')).toBe('Bash')
|
||||
expect(provider.toolDisplayName('unknown_tool')).toBe('unknown_tool')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,593 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||
import { mkdtemp, mkdir, writeFile, rm } from 'fs/promises'
|
||||
import { join } from 'path'
|
||||
import { tmpdir } from 'os'
|
||||
|
||||
import { createCodexProvider } from '../../src/providers/codex.js'
|
||||
import type { ParsedProviderCall } from '../../src/providers/types.js'
|
||||
|
||||
let tmpDir: string
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await mkdtemp(join(tmpdir(), 'codex-test-'))
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function sessionMeta(opts: { cwd?: string; originator?: string; session_id?: string; model?: string; forked_from_id?: string; timestamp?: string } = {}) {
|
||||
return JSON.stringify({
|
||||
type: 'session_meta',
|
||||
timestamp: opts.timestamp ?? '2026-04-14T10:00:00Z',
|
||||
payload: {
|
||||
cwd: opts.cwd ?? '/Users/test/myproject',
|
||||
originator: opts.originator ?? 'codex-cli',
|
||||
session_id: opts.session_id ?? 'sess-001',
|
||||
model: opts.model ?? 'gpt-5.3-codex',
|
||||
...(opts.forked_from_id ? { forked_from_id: opts.forked_from_id } : {}),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function tokenCount(opts: {
|
||||
timestamp?: string
|
||||
last?: { input?: number; cached?: number; output?: number; reasoning?: number }
|
||||
total?: { input?: number; cached?: number; output?: number; reasoning?: number; total?: number }
|
||||
model?: string
|
||||
}) {
|
||||
return JSON.stringify({
|
||||
type: 'event_msg',
|
||||
timestamp: opts.timestamp ?? '2026-04-14T10:01:00Z',
|
||||
payload: {
|
||||
type: 'token_count',
|
||||
info: {
|
||||
model: opts.model,
|
||||
last_token_usage: opts.last ? {
|
||||
input_tokens: opts.last.input ?? 0,
|
||||
cached_input_tokens: opts.last.cached ?? 0,
|
||||
output_tokens: opts.last.output ?? 0,
|
||||
reasoning_output_tokens: opts.last.reasoning ?? 0,
|
||||
total_tokens: (opts.last.input ?? 0) + (opts.last.cached ?? 0) + (opts.last.output ?? 0) + (opts.last.reasoning ?? 0),
|
||||
} : undefined,
|
||||
total_token_usage: opts.total ? {
|
||||
input_tokens: opts.total.input ?? 0,
|
||||
cached_input_tokens: opts.total.cached ?? 0,
|
||||
output_tokens: opts.total.output ?? 0,
|
||||
reasoning_output_tokens: opts.total.reasoning ?? 0,
|
||||
total_tokens: opts.total.total ?? ((opts.total.input ?? 0) + (opts.total.cached ?? 0) + (opts.total.output ?? 0) + (opts.total.reasoning ?? 0)),
|
||||
} : undefined,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function functionCall(name: string, timestamp?: string) {
|
||||
return JSON.stringify({
|
||||
type: 'response_item',
|
||||
timestamp: timestamp ?? '2026-04-14T10:00:30Z',
|
||||
payload: { type: 'function_call', name },
|
||||
})
|
||||
}
|
||||
|
||||
function mcpToolCallEnd(server: string, tool: string, timestamp?: string) {
|
||||
return JSON.stringify({
|
||||
type: 'event_msg',
|
||||
timestamp: timestamp ?? '2026-04-14T10:00:30Z',
|
||||
payload: {
|
||||
type: 'mcp_tool_call_end',
|
||||
call_id: 'call-1',
|
||||
invocation: { server, tool, arguments: {} },
|
||||
duration: '1.2s',
|
||||
result: { Ok: { content: [] } },
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function userMessage(text: string, timestamp?: string) {
|
||||
return JSON.stringify({
|
||||
type: 'response_item',
|
||||
timestamp: timestamp ?? '2026-04-14T10:00:00Z',
|
||||
payload: {
|
||||
type: 'message',
|
||||
role: 'user',
|
||||
content: [{ type: 'input_text', text }],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function writeSession(dir: string, date: string, filename: string, lines: string[]) {
|
||||
const [year, month, day] = date.split('-')
|
||||
const sessionDir = join(dir, 'sessions', year!, month!, day!)
|
||||
await mkdir(sessionDir, { recursive: true })
|
||||
const filePath = join(sessionDir, filename)
|
||||
await writeFile(filePath, lines.join('\n') + '\n')
|
||||
return filePath
|
||||
}
|
||||
|
||||
describe('codex provider - model display names', () => {
|
||||
it('maps gpt-5.3-codex-spark to its own label', () => {
|
||||
const provider = createCodexProvider(tmpDir)
|
||||
const name = provider.modelDisplayName('gpt-5.3-codex-spark')
|
||||
expect(name).not.toBe('GPT-5.3 Codex')
|
||||
expect(name).toBe('GPT-5.3 Codex Spark')
|
||||
})
|
||||
|
||||
it('maps gpt-5.3-codex reasoning suffixes to the base label', () => {
|
||||
const provider = createCodexProvider(tmpDir)
|
||||
expect(provider.modelDisplayName('gpt-5.3-codex-high')).toBe('GPT-5.3 Codex')
|
||||
expect(provider.modelDisplayName('gpt-5.3-codex-low')).toBe('GPT-5.3 Codex')
|
||||
})
|
||||
})
|
||||
|
||||
describe('codex provider - session discovery', () => {
|
||||
it('discovers sessions in YYYY/MM/DD structure', async () => {
|
||||
await writeSession(tmpDir, '2026-04-14', 'rollout-abc123.jsonl', [
|
||||
sessionMeta({ cwd: '/Users/test/myproject' }),
|
||||
tokenCount({ last: { input: 100, output: 50 }, total: { total: 150 } }),
|
||||
])
|
||||
|
||||
const provider = createCodexProvider(tmpDir)
|
||||
const sessions = await provider.discoverSessions()
|
||||
|
||||
expect(sessions).toHaveLength(1)
|
||||
expect(sessions[0]!.provider).toBe('codex')
|
||||
expect(sessions[0]!.project).toBe('Users-test-myproject')
|
||||
expect(sessions[0]!.path).toContain('rollout-abc123.jsonl')
|
||||
})
|
||||
|
||||
it('returns empty for non-existent directory', async () => {
|
||||
const provider = createCodexProvider('/nonexistent/path/that/does/not/exist')
|
||||
const sessions = await provider.discoverSessions()
|
||||
expect(sessions).toEqual([])
|
||||
})
|
||||
|
||||
it('accepts case-insensitive originator (Codex Desktop)', async () => {
|
||||
await writeSession(tmpDir, '2026-04-14', 'rollout-desktop.jsonl', [
|
||||
sessionMeta({ originator: 'Codex Desktop' }),
|
||||
tokenCount({ last: { input: 100, output: 50 }, total: { total: 150 } }),
|
||||
])
|
||||
|
||||
const provider = createCodexProvider(tmpDir)
|
||||
const sessions = await provider.discoverSessions()
|
||||
expect(sessions).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('accepts session_meta lines larger than 16 KB (Codex CLI 0.128+)', async () => {
|
||||
// Codex CLI 0.128+ embeds the full base_instructions / system prompt in the
|
||||
// first session_meta line, often pushing it past 20 KB. Regression guard
|
||||
// against a fixed-size buffer in readFirstLine.
|
||||
const bigPayload = JSON.stringify({
|
||||
type: 'session_meta',
|
||||
timestamp: '2026-05-02T00:00:00Z',
|
||||
payload: {
|
||||
cwd: '/Users/test/big',
|
||||
originator: 'codex-tui',
|
||||
session_id: 'sess-big',
|
||||
model: 'gpt-5.5',
|
||||
base_instructions: { text: 'x'.repeat(40_000) },
|
||||
},
|
||||
})
|
||||
await writeSession(tmpDir, '2026-05-02', 'rollout-big.jsonl', [
|
||||
bigPayload,
|
||||
tokenCount({ last: { input: 100, output: 50 }, total: { total: 150 } }),
|
||||
])
|
||||
|
||||
const provider = createCodexProvider(tmpDir)
|
||||
const sessions = await provider.discoverSessions()
|
||||
expect(sessions).toHaveLength(1)
|
||||
expect(sessions[0]!.path).toContain('rollout-big.jsonl')
|
||||
// Confirm the large meta line was actually parsed (cwd extracted),
|
||||
// not just that some path was registered.
|
||||
expect(sessions[0]!.project).toBe('Users-test-big')
|
||||
})
|
||||
|
||||
it('handles a session_meta line without trailing newline', async () => {
|
||||
const [year, month, day] = '2026-05-02'.split('-')
|
||||
const sessionDir = join(tmpDir, 'sessions', year!, month!, day!)
|
||||
await mkdir(sessionDir, { recursive: true })
|
||||
// Write a single session_meta line, deliberately without a trailing \n.
|
||||
await writeFile(
|
||||
join(sessionDir, 'rollout-no-nl.jsonl'),
|
||||
JSON.stringify({
|
||||
type: 'session_meta',
|
||||
timestamp: '2026-05-02T00:00:00Z',
|
||||
payload: {
|
||||
cwd: '/Users/test/nonl',
|
||||
originator: 'codex-tui',
|
||||
session_id: 'sess-nonl',
|
||||
model: 'gpt-5.5',
|
||||
},
|
||||
}),
|
||||
)
|
||||
const provider = createCodexProvider(tmpDir)
|
||||
const sessions = await provider.discoverSessions()
|
||||
expect(sessions).toHaveLength(1)
|
||||
expect(sessions[0]!.project).toBe('Users-test-nonl')
|
||||
})
|
||||
|
||||
it('handles a session_meta line that spans multiple stream chunks', async () => {
|
||||
// createReadStream defaults to a 64 KiB highWaterMark, so a >64 KiB first
|
||||
// line forces readline to assemble the line across chunk boundaries.
|
||||
const bigPayload = JSON.stringify({
|
||||
type: 'session_meta',
|
||||
timestamp: '2026-05-02T00:00:00Z',
|
||||
payload: {
|
||||
cwd: '/Users/test/multichunk',
|
||||
originator: 'codex-tui',
|
||||
session_id: 'sess-multichunk',
|
||||
model: 'gpt-5.5',
|
||||
base_instructions: { text: 'y'.repeat(120_000) },
|
||||
},
|
||||
})
|
||||
await writeSession(tmpDir, '2026-05-02', 'rollout-multichunk.jsonl', [
|
||||
bigPayload,
|
||||
tokenCount({ last: { input: 100, output: 50 }, total: { total: 150 } }),
|
||||
])
|
||||
const provider = createCodexProvider(tmpDir)
|
||||
const sessions = await provider.discoverSessions()
|
||||
expect(sessions).toHaveLength(1)
|
||||
expect(sessions[0]!.project).toBe('Users-test-multichunk')
|
||||
})
|
||||
|
||||
it('rejects truncated/torn first-line writes without throwing', async () => {
|
||||
// Simulate a partial write where Codex started the session_meta object
|
||||
// but hasn't flushed the rest yet (no closing brace, no newline).
|
||||
const [year, month, day] = '2026-05-02'.split('-')
|
||||
const sessionDir = join(tmpDir, 'sessions', year!, month!, day!)
|
||||
await mkdir(sessionDir, { recursive: true })
|
||||
await writeFile(
|
||||
join(sessionDir, 'rollout-torn.jsonl'),
|
||||
'{"type":"session_meta","timestamp":"2026-05-02T00:00:00Z","payload":{"cwd":"/x","originator":"codex-tui","session_id":"s","model":"gpt',
|
||||
)
|
||||
const provider = createCodexProvider(tmpDir)
|
||||
const sessions = await provider.discoverSessions()
|
||||
expect(sessions).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('returns no sessions for an empty rollout file', async () => {
|
||||
const [year, month, day] = '2026-05-02'.split('-')
|
||||
const sessionDir = join(tmpDir, 'sessions', year!, month!, day!)
|
||||
await mkdir(sessionDir, { recursive: true })
|
||||
await writeFile(join(sessionDir, 'rollout-empty.jsonl'), '')
|
||||
const provider = createCodexProvider(tmpDir)
|
||||
const sessions = await provider.discoverSessions()
|
||||
expect(sessions).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('skips files without codex session_meta', async () => {
|
||||
const [year, month, day] = '2026-04-14'.split('-')
|
||||
const sessionDir = join(tmpDir, 'sessions', year!, month!, day!)
|
||||
await mkdir(sessionDir, { recursive: true })
|
||||
await writeFile(
|
||||
join(sessionDir, 'rollout-bad.jsonl'),
|
||||
JSON.stringify({ type: 'other', payload: {} }) + '\n',
|
||||
)
|
||||
|
||||
const provider = createCodexProvider(tmpDir)
|
||||
const sessions = await provider.discoverSessions()
|
||||
expect(sessions).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('codex provider - JSONL parsing', () => {
|
||||
it('extracts token usage from last_token_usage', async () => {
|
||||
const filePath = await writeSession(tmpDir, '2026-04-14', 'rollout-parse.jsonl', [
|
||||
sessionMeta({ session_id: 'sess-parse', model: 'gpt-5.3-codex' }),
|
||||
userMessage('fix the bug'),
|
||||
functionCall('exec_command'),
|
||||
functionCall('read_file'),
|
||||
tokenCount({
|
||||
timestamp: '2026-04-14T10:01:00Z',
|
||||
last: { input: 500, cached: 100, output: 200, reasoning: 50 },
|
||||
total: { total: 850 },
|
||||
}),
|
||||
])
|
||||
|
||||
const provider = createCodexProvider(tmpDir)
|
||||
const source = { path: filePath, project: 'test', provider: 'codex' }
|
||||
const parser = provider.createSessionParser(source, new Set())
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of parser.parse()) {
|
||||
calls.push(call)
|
||||
}
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
const call = calls[0]!
|
||||
expect(call.provider).toBe('codex')
|
||||
expect(call.model).toBe('gpt-5.3-codex')
|
||||
expect(call.inputTokens).toBe(400)
|
||||
expect(call.cachedInputTokens).toBe(100)
|
||||
expect(call.cacheReadInputTokens).toBe(100)
|
||||
expect(call.outputTokens).toBe(200)
|
||||
expect(call.reasoningTokens).toBe(50)
|
||||
expect(call.tools).toEqual(['Bash', 'Read'])
|
||||
expect(call.userMessage).toBe('fix the bug')
|
||||
expect(call.sessionId).toBe('sess-parse')
|
||||
expect(call.costUSD).toBeGreaterThan(0)
|
||||
expect(call.deduplicationKey).toContain('codex:')
|
||||
})
|
||||
|
||||
it('attributes MCP calls emitted as event_msg/mcp_tool_call_end', async () => {
|
||||
const filePath = await writeSession(tmpDir, '2026-04-14', 'rollout-mcp.jsonl', [
|
||||
sessionMeta({ session_id: 'sess-mcp', model: 'gpt-5.5' }),
|
||||
userMessage('look up the issue'),
|
||||
mcpToolCallEnd('github', 'get_issue'),
|
||||
tokenCount({
|
||||
timestamp: '2026-04-14T10:01:00Z',
|
||||
last: { input: 300, output: 100 },
|
||||
total: { total: 400 },
|
||||
}),
|
||||
])
|
||||
|
||||
const provider = createCodexProvider(tmpDir)
|
||||
const source = { path: filePath, project: 'test', provider: 'codex' }
|
||||
const parser = provider.createSessionParser(source, new Set())
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of parser.parse()) {
|
||||
calls.push(call)
|
||||
}
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]!.tools).toEqual(['mcp__github__get_issue'])
|
||||
})
|
||||
|
||||
it('attributes CLI-wrapped MCP calls (mcp-cli call server tool) to MCP + Bash', async () => {
|
||||
const execStr = (command: string) => JSON.stringify({
|
||||
type: 'response_item',
|
||||
timestamp: '2026-04-14T10:00:30Z',
|
||||
payload: { type: 'function_call', name: 'exec_command', arguments: JSON.stringify({ command }) },
|
||||
})
|
||||
// command as an array (Codex sometimes logs argv form).
|
||||
const execArr = (command: string[]) => JSON.stringify({
|
||||
type: 'response_item',
|
||||
timestamp: '2026-04-14T10:00:30Z',
|
||||
payload: { type: 'function_call', name: 'exec_command', arguments: JSON.stringify({ command }) },
|
||||
})
|
||||
const filePath = await writeSession(tmpDir, '2026-04-14', 'rollout-mcpcli.jsonl', [
|
||||
sessionMeta({ session_id: 'sess-mcpcli', model: 'gpt-5.5' }),
|
||||
userMessage('look up an issue via the MCP CLI'),
|
||||
// Real invocation forms that MUST attribute to MCP:
|
||||
execStr("bash -lc \"mcp-cli call github get_issue '{\\\"id\\\": 5}'\""), // bash -lc wrapper
|
||||
execStr('mcp-cli -c ./mcp.json call linear list_issues'), // flags before subcommand
|
||||
execArr(['mcp-cli', 'call', 'slack', 'post_message', '{}']), // argv array form
|
||||
// Lookups and unrelated commands that must NOT attribute:
|
||||
execStr('mcp-cli info github'),
|
||||
execStr('mcp-cli grep "*issue*"'),
|
||||
execStr('my-mcp-cli-wrapper call github get_issue'), // not the mcp-cli binary
|
||||
execStr('ls -la'),
|
||||
tokenCount({ timestamp: '2026-04-14T10:01:00Z', last: { input: 300, output: 100 }, total: { total: 400 } }),
|
||||
])
|
||||
|
||||
const provider = createCodexProvider(tmpDir)
|
||||
const source = { path: filePath, project: 'test', provider: 'codex' }
|
||||
const parser = provider.createSessionParser(source, new Set())
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of parser.parse()) calls.push(call)
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
const tools = calls[0]!.tools
|
||||
// Every exec still counts as Bash (7 exec_commands total).
|
||||
expect(tools.filter(t => t === 'Bash')).toHaveLength(7)
|
||||
// Exactly the three `call` invocations attribute to MCP; info/grep/wrapper/ls do not.
|
||||
expect(tools.filter(t => t.startsWith('mcp__')).sort()).toEqual([
|
||||
'mcp__github__get_issue',
|
||||
'mcp__linear__list_issues',
|
||||
'mcp__slack__post_message',
|
||||
])
|
||||
})
|
||||
|
||||
it('normalizes Codex subagent tool calls to Agent', async () => {
|
||||
const filePath = await writeSession(tmpDir, '2026-04-14', 'rollout-agent.jsonl', [
|
||||
sessionMeta({ session_id: 'sess-agent', model: 'gpt-5.5' }),
|
||||
userMessage('delegate the review'),
|
||||
functionCall('spawn_agent'),
|
||||
functionCall('wait_agent'),
|
||||
functionCall('close_agent'),
|
||||
tokenCount({
|
||||
timestamp: '2026-04-14T10:01:00Z',
|
||||
last: { input: 300, output: 100 },
|
||||
total: { total: 400 },
|
||||
}),
|
||||
])
|
||||
|
||||
const provider = createCodexProvider(tmpDir)
|
||||
const source = { path: filePath, project: 'test', provider: 'codex' }
|
||||
const parser = provider.createSessionParser(source, new Set())
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of parser.parse()) {
|
||||
calls.push(call)
|
||||
}
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]!.tools).toEqual(['Agent', 'Agent', 'Agent'])
|
||||
})
|
||||
|
||||
it('skips duplicate token_count events', async () => {
|
||||
const filePath = await writeSession(tmpDir, '2026-04-14', 'rollout-dedup.jsonl', [
|
||||
sessionMeta(),
|
||||
tokenCount({
|
||||
timestamp: '2026-04-14T10:01:00Z',
|
||||
last: { input: 500, output: 200 },
|
||||
total: { total: 700 },
|
||||
}),
|
||||
tokenCount({
|
||||
timestamp: '2026-04-14T10:01:01Z',
|
||||
last: { input: 500, output: 200 },
|
||||
total: { total: 700 },
|
||||
}),
|
||||
tokenCount({
|
||||
timestamp: '2026-04-14T10:02:00Z',
|
||||
last: { input: 300, output: 100 },
|
||||
total: { total: 1100 },
|
||||
}),
|
||||
])
|
||||
|
||||
const provider = createCodexProvider(tmpDir)
|
||||
const source = { path: filePath, project: 'test', provider: 'codex' }
|
||||
const parser = provider.createSessionParser(source, new Set())
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of parser.parse()) {
|
||||
calls.push(call)
|
||||
}
|
||||
|
||||
expect(calls).toHaveLength(2)
|
||||
expect(calls[0]!.inputTokens).toBe(500)
|
||||
expect(calls[1]!.inputTokens).toBe(300)
|
||||
})
|
||||
|
||||
it('does not drop the first event when total_token_usage is omitted (cumulativeTotal=0)', async () => {
|
||||
// Regression for the prevCumulativeTotal-initialized-to-0 bug. Sessions
|
||||
// that emit only last_token_usage (no total_token_usage) report
|
||||
// cumulativeTotal=0 on every event. With a 0-initialized prev, the first
|
||||
// event matched the dedup guard and was silently dropped, losing the
|
||||
// session's opening turn. The null sentinel fixes this.
|
||||
const filePath = await writeSession(tmpDir, '2026-04-14', 'rollout-zero-total.jsonl', [
|
||||
sessionMeta(),
|
||||
tokenCount({
|
||||
timestamp: '2026-04-14T10:01:00Z',
|
||||
last: { input: 500, output: 200 },
|
||||
// No `total` — info.total_token_usage will be undefined.
|
||||
}),
|
||||
tokenCount({
|
||||
timestamp: '2026-04-14T10:01:01Z',
|
||||
last: { input: 100, output: 50 },
|
||||
}),
|
||||
])
|
||||
|
||||
const provider = createCodexProvider(tmpDir)
|
||||
const source = { path: filePath, project: 'test', provider: 'codex' }
|
||||
const parser = provider.createSessionParser(source, new Set())
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of parser.parse()) {
|
||||
calls.push(call)
|
||||
}
|
||||
|
||||
// Both events should produce calls — the first with input=500, second
|
||||
// with input=100. With the buggy 0-init, only the second would survive
|
||||
// (or neither, depending on equality timing).
|
||||
expect(calls.length).toBeGreaterThanOrEqual(1)
|
||||
expect(calls[0]!.inputTokens).toBe(500)
|
||||
})
|
||||
|
||||
it('still dedups consecutive zero-cumulative duplicates', async () => {
|
||||
// The other half of the regression: two consecutive events with the
|
||||
// same cumulativeTotal (here both 0 because total_token_usage is
|
||||
// omitted) and identical last_token_usage must NOT both ingest. The
|
||||
// second is a duplicate.
|
||||
const filePath = await writeSession(tmpDir, '2026-04-14', 'rollout-zero-dup.jsonl', [
|
||||
sessionMeta(),
|
||||
tokenCount({
|
||||
timestamp: '2026-04-14T10:01:00Z',
|
||||
last: { input: 500, output: 200 },
|
||||
}),
|
||||
tokenCount({
|
||||
timestamp: '2026-04-14T10:01:01Z',
|
||||
last: { input: 500, output: 200 },
|
||||
}),
|
||||
])
|
||||
|
||||
const provider = createCodexProvider(tmpDir)
|
||||
const source = { path: filePath, project: 'test', provider: 'codex' }
|
||||
const parser = provider.createSessionParser(source, new Set())
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of parser.parse()) {
|
||||
calls.push(call)
|
||||
}
|
||||
expect(calls).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('codex provider - forked session dedupe', () => {
|
||||
// Aggregate every discovered session through ONE shared seenKeys, exactly as
|
||||
// the real provider report does, then sum the global token total.
|
||||
async function aggregateTokens(dir: string): Promise<{ tokens: number; calls: number }> {
|
||||
const provider = createCodexProvider(dir)
|
||||
const sessions = (await provider.discoverSessions()).sort((a, b) => (a.path < b.path ? -1 : 1))
|
||||
const seenKeys = new Set<string>()
|
||||
let tokens = 0
|
||||
let calls = 0
|
||||
for (const s of sessions) {
|
||||
for await (const c of provider.createSessionParser(s, seenKeys).parse()) {
|
||||
calls++
|
||||
tokens += c.inputTokens + c.outputTokens + c.cachedInputTokens + c.reasoningTokens
|
||||
}
|
||||
}
|
||||
return { tokens, calls }
|
||||
}
|
||||
|
||||
it('does not double-count a fork that replays the parent past the 5s cutoff', async () => {
|
||||
// Parent does 1100 tokens of real work. The fork replays both events with
|
||||
// timestamps well beyond the 5s fork cutoff, then adds one genuine event
|
||||
// (+400). The replays must collide with the parent and drop, so the global
|
||||
// total is 1500 -- not 2600 (which keying on the fork's own session id would
|
||||
// produce by double-counting the replayed history).
|
||||
await writeSession(tmpDir, '2026-04-14', 'rollout-1-parent.jsonl', [
|
||||
sessionMeta({ session_id: 'sess-parent' }),
|
||||
tokenCount({ timestamp: '2026-04-14T10:00:01Z', last: { input: 700 }, total: { total: 700 } }),
|
||||
tokenCount({ timestamp: '2026-04-14T10:00:02Z', last: { input: 400 }, total: { total: 1100 } }),
|
||||
])
|
||||
await writeSession(tmpDir, '2026-04-14', 'rollout-2-fork.jsonl', [
|
||||
sessionMeta({ session_id: 'sess-fork', forked_from_id: 'sess-parent' }),
|
||||
tokenCount({ timestamp: '2026-04-14T10:00:10Z', last: { input: 700 }, total: { total: 700 } }),
|
||||
tokenCount({ timestamp: '2026-04-14T10:00:11Z', last: { input: 400 }, total: { total: 1100 } }),
|
||||
tokenCount({ timestamp: '2026-04-14T10:00:12Z', last: { input: 400 }, total: { total: 1500 } }),
|
||||
])
|
||||
|
||||
const { tokens } = await aggregateTokens(tmpDir)
|
||||
expect(tokens).toBe(1500)
|
||||
})
|
||||
|
||||
it('keeps a genuine divergent fork event that shares a cumulative total with the parent', async () => {
|
||||
// Parent reaches cumulative 1600 via input (last input 500). The fork replays
|
||||
// 700 and 1100, then does genuinely different work that also reaches
|
||||
// cumulative 1600 but via OUTPUT (last output 500). Keying on cumulativeTotal
|
||||
// alone would collide the fork's 1600 with the parent's 1600 and drop it
|
||||
// (undercount, losing 500). The content-addressed key keeps both.
|
||||
await writeSession(tmpDir, '2026-04-14', 'rollout-1-parent.jsonl', [
|
||||
sessionMeta({ session_id: 'sess-parent' }),
|
||||
tokenCount({ timestamp: '2026-04-14T10:00:01Z', last: { input: 700 }, total: { total: 700 } }),
|
||||
tokenCount({ timestamp: '2026-04-14T10:00:02Z', last: { input: 400 }, total: { total: 1100 } }),
|
||||
tokenCount({ timestamp: '2026-04-14T10:00:03Z', last: { input: 500 }, total: { input: 1600, total: 1600 } }),
|
||||
])
|
||||
await writeSession(tmpDir, '2026-04-14', 'rollout-2-fork.jsonl', [
|
||||
sessionMeta({ session_id: 'sess-fork', forked_from_id: 'sess-parent' }),
|
||||
tokenCount({ timestamp: '2026-04-14T10:00:10Z', last: { input: 700 }, total: { total: 700 } }),
|
||||
tokenCount({ timestamp: '2026-04-14T10:00:11Z', last: { input: 400 }, total: { total: 1100 } }),
|
||||
tokenCount({ timestamp: '2026-04-14T10:00:12Z', last: { output: 500 }, total: { input: 1100, output: 500, total: 1600 } }),
|
||||
])
|
||||
|
||||
const { tokens } = await aggregateTokens(tmpDir)
|
||||
// parent 1600 + fork's genuine +500 = 2100; replays (700, 1100) dropped.
|
||||
expect(tokens).toBe(2100)
|
||||
})
|
||||
|
||||
it('does not overcount a total-only fork whose replay straddles the 5s cutoff', async () => {
|
||||
// The dedupe key must be derived from the cumulative token breakdown, not
|
||||
// per-event deltas. In the fallback branch (events with total_token_usage
|
||||
// but no last_token_usage), the delta is computed against a running `prev`.
|
||||
// A fork skips replays within 5s of the fork (prev NOT advanced), so a
|
||||
// replay kept just past the cutoff would compute a different delta than the
|
||||
// parent did and, with a delta-based key, fail to dedupe -> double-count.
|
||||
// The cumulative totals are copied verbatim, so a cumulative-based key
|
||||
// collides regardless of the cutoff. Parent does 300 tokens; the fork is a
|
||||
// pure replay (no new work), so the global total must stay 300.
|
||||
await writeSession(tmpDir, '2026-04-14', 'rollout-1-parent.jsonl', [
|
||||
sessionMeta({ session_id: 'sess-parent' }),
|
||||
tokenCount({ timestamp: '2026-04-14T10:00:01Z', total: { input: 100, total: 100 } }),
|
||||
tokenCount({ timestamp: '2026-04-14T10:00:02Z', total: { input: 200, total: 200 } }),
|
||||
tokenCount({ timestamp: '2026-04-14T10:00:03Z', total: { input: 300, total: 300 } }),
|
||||
])
|
||||
await writeSession(tmpDir, '2026-04-14', 'rollout-2-fork.jsonl', [
|
||||
sessionMeta({ session_id: 'sess-fork', forked_from_id: 'sess-parent' }),
|
||||
// 10:00:01 is within the 5s cutoff -> skipped (prev not advanced).
|
||||
tokenCount({ timestamp: '2026-04-14T10:00:01Z', total: { input: 100, total: 100 } }),
|
||||
// These land past the cutoff and replay the parent's cumulative totals.
|
||||
tokenCount({ timestamp: '2026-04-14T10:00:08Z', total: { input: 200, total: 200 } }),
|
||||
tokenCount({ timestamp: '2026-04-14T10:00:09Z', total: { input: 300, total: 300 } }),
|
||||
])
|
||||
|
||||
const { tokens } = await aggregateTokens(tmpDir)
|
||||
expect(tokens).toBe(300)
|
||||
})
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,317 @@
|
||||
import { mkdtemp, rm, mkdir, writeFile } 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 { isSqliteAvailable } from '../../src/sqlite.js'
|
||||
import { createCrushProvider } from '../../src/providers/crush.js'
|
||||
import type { ParsedProviderCall } from '../../src/providers/types.js'
|
||||
|
||||
const requireForTest = createRequire(import.meta.url)
|
||||
|
||||
type TestDb = {
|
||||
exec(sql: string): void
|
||||
prepare(sql: string): { run(...params: unknown[]): void }
|
||||
close(): void
|
||||
}
|
||||
|
||||
let tmpRoot: string
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpRoot = await mkdtemp(join(tmpdir(), 'crush-test-'))
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tmpRoot, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
// CREATE TABLE statements taken verbatim from charmbracelet/crush@v0.66.1
|
||||
// internal/db/migrations/20250424200609_initial.sql, with subsequent ALTERs
|
||||
// folded in (summary_message_id, provider on messages, is_summary_message,
|
||||
// todos on sessions). Keeping the literal upstream column ordering and
|
||||
// constraints makes drift easy to spot.
|
||||
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 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()
|
||||
}
|
||||
}
|
||||
|
||||
type SessionFixture = {
|
||||
id: string
|
||||
parentId?: string | null
|
||||
promptTokens?: number
|
||||
completionTokens?: number
|
||||
cost?: number
|
||||
createdAt?: number
|
||||
updatedAt?: number
|
||||
messageCount?: number
|
||||
}
|
||||
|
||||
function insertSession(db: TestDb, s: SessionFixture): void {
|
||||
db.prepare(`
|
||||
INSERT INTO sessions (id, parent_session_id, title, message_count, prompt_tokens, completion_tokens, cost, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
s.id,
|
||||
s.parentId ?? null,
|
||||
'test session',
|
||||
s.messageCount ?? 0,
|
||||
s.promptTokens ?? 0,
|
||||
s.completionTokens ?? 0,
|
||||
s.cost ?? 0,
|
||||
s.createdAt ?? 1_700_000_000,
|
||||
s.updatedAt ?? s.createdAt ?? 1_700_000_000,
|
||||
)
|
||||
}
|
||||
|
||||
function insertMessage(db: TestDb, sessionId: string, role: string, model: string | null, id: string): void {
|
||||
db.prepare(`
|
||||
INSERT INTO messages (id, session_id, role, parts, model, created_at, updated_at)
|
||||
VALUES (?, ?, ?, '[]', ?, ?, ?)
|
||||
`).run(id, sessionId, role, model, 1_700_000_000, 1_700_000_000)
|
||||
}
|
||||
|
||||
async function writeRegistry(globalDataDir: string, entries: Record<string, { path: string; data_dir: string }>): Promise<void> {
|
||||
await mkdir(globalDataDir, { recursive: true })
|
||||
await writeFile(join(globalDataDir, 'projects.json'), JSON.stringify(entries))
|
||||
}
|
||||
|
||||
async function collect(parser: { parse(): AsyncGenerator<ParsedProviderCall> }): Promise<ParsedProviderCall[]> {
|
||||
const out: ParsedProviderCall[] = []
|
||||
for await (const call of parser.parse()) out.push(call)
|
||||
return out
|
||||
}
|
||||
|
||||
describe('crush provider', () => {
|
||||
it('reports correct identity', () => {
|
||||
const p = createCrushProvider()
|
||||
expect(p.name).toBe('crush')
|
||||
expect(p.displayName).toBe('Crush')
|
||||
expect(p.modelDisplayName('gpt-5')).toBe('gpt-5')
|
||||
})
|
||||
|
||||
it('returns no sessions when registry is missing', async () => {
|
||||
const globalData = join(tmpRoot, 'crush-global')
|
||||
process.env['CRUSH_GLOBAL_DATA'] = globalData
|
||||
const p = createCrushProvider()
|
||||
const sessions = await p.discoverSessions()
|
||||
expect(sessions).toEqual([])
|
||||
})
|
||||
|
||||
it('returns no sessions when registry is malformed JSON', async () => {
|
||||
const globalData = join(tmpRoot, 'crush-global')
|
||||
await mkdir(globalData, { recursive: true })
|
||||
await writeFile(join(globalData, 'projects.json'), '{ not json')
|
||||
process.env['CRUSH_GLOBAL_DATA'] = globalData
|
||||
const p = createCrushProvider()
|
||||
const sessions = await p.discoverSessions()
|
||||
expect(sessions).toEqual([])
|
||||
})
|
||||
|
||||
it('discovers root sessions with cost or tokens, skipping zero rows and child sessions', async () => {
|
||||
if (!isSqliteAvailable()) return
|
||||
|
||||
const projectDir = join(tmpRoot, 'project-a')
|
||||
const dbPath = createCrushDb(join(projectDir, '.crush'))
|
||||
withTestDb(dbPath, db => {
|
||||
insertSession(db, { id: 'root-with-cost', cost: 0.42, promptTokens: 100, completionTokens: 50, createdAt: 1_700_000_001 })
|
||||
insertSession(db, { id: 'root-no-spend', cost: 0, promptTokens: 0, completionTokens: 0, createdAt: 1_700_000_002 })
|
||||
insertSession(db, { id: 'child', parentId: 'root-with-cost', cost: 0.01, createdAt: 1_700_000_003 })
|
||||
insertSession(db, { id: 'root-tokens-only', cost: 0, promptTokens: 5, completionTokens: 5, createdAt: 1_700_000_004 })
|
||||
})
|
||||
|
||||
const globalData = join(tmpRoot, 'crush-global')
|
||||
await writeRegistry(globalData, {
|
||||
'proj-a': { path: projectDir, data_dir: '.crush' },
|
||||
})
|
||||
process.env['CRUSH_GLOBAL_DATA'] = globalData
|
||||
|
||||
const p = createCrushProvider()
|
||||
const sessions = await p.discoverSessions()
|
||||
const ids = sessions.map(s => s.path.split(':').pop()).sort()
|
||||
expect(ids).toEqual(['root-tokens-only', 'root-with-cost'])
|
||||
expect(sessions.every(s => s.provider === 'crush')).toBe(true)
|
||||
})
|
||||
|
||||
it('parses a session into a ParsedProviderCall with real tokens, cost, and dominant model', async () => {
|
||||
if (!isSqliteAvailable()) return
|
||||
|
||||
const projectDir = join(tmpRoot, 'project-b')
|
||||
const dbPath = createCrushDb(join(projectDir, '.crush'))
|
||||
withTestDb(dbPath, db => {
|
||||
insertSession(db, {
|
||||
id: 'sess-1',
|
||||
promptTokens: 1234,
|
||||
completionTokens: 567,
|
||||
cost: 0.0789,
|
||||
createdAt: 1_700_000_010,
|
||||
updatedAt: 1_700_000_999,
|
||||
})
|
||||
// Most-used model wins.
|
||||
insertMessage(db, 'sess-1', 'assistant', 'claude-sonnet-4-6', 'm1')
|
||||
insertMessage(db, 'sess-1', 'assistant', 'claude-sonnet-4-6', 'm2')
|
||||
insertMessage(db, 'sess-1', 'assistant', 'gpt-5', 'm3')
|
||||
})
|
||||
|
||||
const globalData = join(tmpRoot, 'crush-global')
|
||||
await writeRegistry(globalData, {
|
||||
'proj-b': { path: projectDir, data_dir: '.crush' },
|
||||
})
|
||||
process.env['CRUSH_GLOBAL_DATA'] = globalData
|
||||
|
||||
const p = createCrushProvider()
|
||||
const sources = await p.discoverSessions()
|
||||
expect(sources).toHaveLength(1)
|
||||
|
||||
const calls = await collect(p.createSessionParser(sources[0]!, new Set()))
|
||||
expect(calls).toHaveLength(1)
|
||||
const call = calls[0]!
|
||||
expect(call.provider).toBe('crush')
|
||||
expect(call.model).toBe('claude-sonnet-4-6')
|
||||
expect(call.inputTokens).toBe(1234)
|
||||
expect(call.outputTokens).toBe(567)
|
||||
expect(call.costUSD).toBeCloseTo(0.0789, 6)
|
||||
expect(call.sessionId).toBe('sess-1')
|
||||
expect(call.deduplicationKey).toBe('crush:sess-1')
|
||||
// Crush stores epoch seconds; 1_700_000_999 sec → 2023-11-14T22:29:59.000Z.
|
||||
expect(call.timestamp).toBe(new Date(1_700_000_999 * 1000).toISOString())
|
||||
})
|
||||
|
||||
it('falls back to "unknown" when no message has a model', async () => {
|
||||
if (!isSqliteAvailable()) return
|
||||
|
||||
const projectDir = join(tmpRoot, 'project-c')
|
||||
const dbPath = createCrushDb(join(projectDir, '.crush'))
|
||||
withTestDb(dbPath, db => {
|
||||
insertSession(db, { id: 'sess-no-model', cost: 0.05, promptTokens: 10, completionTokens: 5, createdAt: 1_700_000_500 })
|
||||
insertMessage(db, 'sess-no-model', 'user', null, 'm1')
|
||||
insertMessage(db, 'sess-no-model', 'assistant', null, 'm2')
|
||||
})
|
||||
|
||||
const globalData = join(tmpRoot, 'crush-global')
|
||||
await writeRegistry(globalData, {
|
||||
'proj-c': { path: projectDir, data_dir: '.crush' },
|
||||
})
|
||||
process.env['CRUSH_GLOBAL_DATA'] = globalData
|
||||
|
||||
const p = createCrushProvider()
|
||||
const sources = await p.discoverSessions()
|
||||
const calls = await collect(p.createSessionParser(sources[0]!, new Set()))
|
||||
expect(calls[0]!.model).toBe('unknown')
|
||||
})
|
||||
|
||||
it('respects seenKeys for deduplication', async () => {
|
||||
if (!isSqliteAvailable()) return
|
||||
|
||||
const projectDir = join(tmpRoot, 'project-d')
|
||||
const dbPath = createCrushDb(join(projectDir, '.crush'))
|
||||
withTestDb(dbPath, db => {
|
||||
insertSession(db, { id: 'sess-dup', cost: 0.10, promptTokens: 100, completionTokens: 50, createdAt: 1_700_000_700 })
|
||||
})
|
||||
|
||||
const globalData = join(tmpRoot, 'crush-global')
|
||||
await writeRegistry(globalData, {
|
||||
'proj-d': { path: projectDir, data_dir: '.crush' },
|
||||
})
|
||||
process.env['CRUSH_GLOBAL_DATA'] = globalData
|
||||
|
||||
const p = createCrushProvider()
|
||||
const sources = await p.discoverSessions()
|
||||
const seen = new Set<string>()
|
||||
const first = await collect(p.createSessionParser(sources[0]!, seen))
|
||||
expect(first).toHaveLength(1)
|
||||
|
||||
const second = await collect(p.createSessionParser(sources[0]!, seen))
|
||||
expect(second).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('accepts an array-shaped projects.json (legacy format)', async () => {
|
||||
if (!isSqliteAvailable()) return
|
||||
|
||||
const projectDir = join(tmpRoot, 'project-e')
|
||||
const dbPath = createCrushDb(join(projectDir, '.crush'))
|
||||
withTestDb(dbPath, db => {
|
||||
insertSession(db, { id: 'sess-arr', cost: 0.01, promptTokens: 1, completionTokens: 1, createdAt: 1_700_000_800 })
|
||||
})
|
||||
|
||||
const globalData = join(tmpRoot, 'crush-global')
|
||||
await mkdir(globalData, { recursive: true })
|
||||
await writeFile(
|
||||
join(globalData, 'projects.json'),
|
||||
JSON.stringify([{ path: projectDir, data_dir: '.crush' }]),
|
||||
)
|
||||
process.env['CRUSH_GLOBAL_DATA'] = globalData
|
||||
|
||||
const p = createCrushProvider()
|
||||
const sources = await p.discoverSessions()
|
||||
expect(sources).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('ignores registry entries whose db is missing', async () => {
|
||||
if (!isSqliteAvailable()) return
|
||||
|
||||
const globalData = join(tmpRoot, 'crush-global')
|
||||
await writeRegistry(globalData, {
|
||||
'ghost': { path: join(tmpRoot, 'does-not-exist'), data_dir: '.crush' },
|
||||
})
|
||||
process.env['CRUSH_GLOBAL_DATA'] = globalData
|
||||
|
||||
const p = createCrushProvider()
|
||||
const sources = await p.discoverSessions()
|
||||
expect(sources).toEqual([])
|
||||
})
|
||||
|
||||
it('is registered via getAllProviders', async () => {
|
||||
if (!isSqliteAvailable()) return
|
||||
const { getAllProviders } = await import('../../src/providers/index.js')
|
||||
const providers = await getAllProviders()
|
||||
const found = providers.find(p => p.name === 'crush')
|
||||
expect(found).toBeDefined()
|
||||
expect(found!.displayName).toBe('Crush')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,329 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'fs/promises'
|
||||
import { existsSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
import { tmpdir } from 'os'
|
||||
|
||||
import { getAllProviders } from '../../src/providers/index.js'
|
||||
import { createCursorAgentProvider } from '../../src/providers/cursor-agent.js'
|
||||
import type { ParsedProviderCall, Provider, SessionSource } from '../../src/providers/types.js'
|
||||
import { isSqliteAvailable } from '../../src/sqlite.js'
|
||||
|
||||
const CHARS_PER_TOKEN = 4
|
||||
const CURSOR_AGENT_DEFAULT_MODEL = 'cursor-agent-auto'
|
||||
const FIXED_UUID = '123e4567-e89b-12d3-a456-426614174000'
|
||||
|
||||
const skipUnlessSqlite = isSqliteAvailable() ? describe : describe.skip
|
||||
|
||||
type TestDb = {
|
||||
exec(sql: string): void
|
||||
prepare(sql: string): { run(...params: unknown[]): void }
|
||||
close(): void
|
||||
}
|
||||
|
||||
let tempRoots: string[] = []
|
||||
|
||||
beforeEach(() => {
|
||||
tempRoots = []
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(tempRoots.filter(existsSync).map((dir) => rm(dir, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
async function makeBaseDir(): Promise<string> {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'cursor-agent-test-'))
|
||||
tempRoots.push(dir)
|
||||
return dir
|
||||
}
|
||||
|
||||
async function collectCalls(provider: Provider, source: SessionSource): Promise<ParsedProviderCall[]> {
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of provider.createSessionParser(source, new Set()).parse()) {
|
||||
calls.push(call)
|
||||
}
|
||||
return calls
|
||||
}
|
||||
|
||||
function withTestDb(dbPath: string, fn: (db: TestDb) => void): void {
|
||||
const { DatabaseSync: Database } = require('node:sqlite')
|
||||
const db = new Database(dbPath)
|
||||
fn(db)
|
||||
db.close()
|
||||
}
|
||||
|
||||
describe('cursor-agent provider', () => {
|
||||
it('is registered', async () => {
|
||||
const all = await getAllProviders()
|
||||
const provider = all.find((p) => p.name === 'cursor-agent')
|
||||
|
||||
expect(provider).toBeDefined()
|
||||
expect(provider?.displayName).toBe('Cursor Agent')
|
||||
})
|
||||
|
||||
it('maps default model to Cursor (auto) label', () => {
|
||||
const provider = createCursorAgentProvider('/tmp/nonexistent-cursor-agent-fixture')
|
||||
expect(provider.modelDisplayName('cursor-agent-auto')).toBe('Cursor (auto)')
|
||||
})
|
||||
|
||||
it('maps known models and appends estimation label', () => {
|
||||
const provider = createCursorAgentProvider('/tmp/nonexistent-cursor-agent-fixture')
|
||||
|
||||
expect(provider.modelDisplayName('claude-4.5-opus-high-thinking')).toBe('Opus 4.5 (Thinking) (est.)')
|
||||
expect(provider.modelDisplayName('claude-4.6-sonnet')).toBe('Sonnet 4.6 (est.)')
|
||||
expect(provider.modelDisplayName('composer-1')).toBe('Composer 1 (est.)')
|
||||
})
|
||||
|
||||
it('falls through to raw model name for unknown models with single est. suffix', () => {
|
||||
const provider = createCursorAgentProvider('/tmp/nonexistent-cursor-agent-fixture')
|
||||
|
||||
expect(provider.modelDisplayName('claude-5-future-model')).toBe('claude-5-future-model (est.)')
|
||||
expect(provider.modelDisplayName('gpt-9')).toBe('gpt-9 (est.)')
|
||||
})
|
||||
|
||||
it('returns identity for tool display name', () => {
|
||||
const provider = createCursorAgentProvider('/tmp/nonexistent-cursor-agent-fixture')
|
||||
expect(provider.toolDisplayName('cursor:edit')).toBe('cursor:edit')
|
||||
})
|
||||
|
||||
it('returns empty discovery when projects dir is missing', async () => {
|
||||
const baseDir = await makeBaseDir()
|
||||
const provider = createCursorAgentProvider(baseDir)
|
||||
const sources = await provider.discoverSessions()
|
||||
|
||||
expect(sources).toEqual([])
|
||||
})
|
||||
|
||||
it('discovers a single transcript', async () => {
|
||||
const baseDir = await makeBaseDir()
|
||||
const transcriptDir = join(baseDir, 'projects', 'test-proj', 'agent-transcripts')
|
||||
await mkdir(transcriptDir, { recursive: true })
|
||||
const transcriptPath = join(transcriptDir, `${FIXED_UUID}.txt`)
|
||||
await writeFile(transcriptPath, 'user:\n<user_query>hello</user_query>\nA:\nworld\n')
|
||||
|
||||
const provider = createCursorAgentProvider(baseDir)
|
||||
const sources = await provider.discoverSessions()
|
||||
|
||||
expect(sources).toHaveLength(1)
|
||||
expect(sources[0]!.provider).toBe('cursor-agent')
|
||||
expect(sources[0]!.path).toBe(transcriptPath)
|
||||
})
|
||||
|
||||
it('discovers transcripts across multiple projects', async () => {
|
||||
const baseDir = await makeBaseDir()
|
||||
const transcriptA = join(baseDir, 'projects', 'proj-one', 'agent-transcripts')
|
||||
const transcriptB = join(baseDir, 'projects', 'proj-two', 'agent-transcripts')
|
||||
await mkdir(transcriptA, { recursive: true })
|
||||
await mkdir(transcriptB, { recursive: true })
|
||||
await writeFile(join(transcriptA, `${FIXED_UUID}.txt`), 'user:\n<user_query>a</user_query>\nA:\na\n')
|
||||
await writeFile(join(transcriptB, `${FIXED_UUID}.txt`), 'user:\n<user_query>b</user_query>\nA:\nb\n')
|
||||
|
||||
const provider = createCursorAgentProvider(baseDir)
|
||||
const sources = await provider.discoverSessions()
|
||||
|
||||
expect(sources).toHaveLength(2)
|
||||
expect(sources.every((s) => s.provider === 'cursor-agent')).toBe(true)
|
||||
})
|
||||
|
||||
it('does not scan a workspace root when agent-transcripts is missing', async () => {
|
||||
const baseDir = await makeBaseDir()
|
||||
const workspaceRoot = join(baseDir, 'projects', 'workspace-without-transcripts')
|
||||
await mkdir(workspaceRoot, { recursive: true })
|
||||
await writeFile(
|
||||
join(workspaceRoot, 'extension-state.txt'),
|
||||
'user:\n<user_query>not a transcript</user_query>\nA:\nnot a cursor-agent answer\n',
|
||||
)
|
||||
|
||||
const provider = createCursorAgentProvider(baseDir)
|
||||
const sources = await provider.discoverSessions()
|
||||
|
||||
expect(sources).toEqual([])
|
||||
})
|
||||
|
||||
it('prefers jsonl over same-session txt inside UUID transcript dirs', async () => {
|
||||
const baseDir = await makeBaseDir()
|
||||
const sessionDir = join(baseDir, 'projects', 'proj-with-duplicates', 'agent-transcripts', FIXED_UUID)
|
||||
const jsonlPath = join(sessionDir, `${FIXED_UUID}.jsonl`)
|
||||
const txtPath = join(sessionDir, `${FIXED_UUID}.txt`)
|
||||
await mkdir(sessionDir, { recursive: true })
|
||||
await writeFile(
|
||||
jsonlPath,
|
||||
'{"role":"user","message":{"content":[{"type":"text","text":"<user_query>jsonl wins</user_query>"}]}}\n{"role":"assistant","message":{"content":[{"type":"text","text":"jsonl answer"}]}}\n',
|
||||
)
|
||||
await writeFile(txtPath, 'user:\n<user_query>txt duplicate</user_query>\nA:\ntxt answer\n')
|
||||
|
||||
const provider = createCursorAgentProvider(baseDir)
|
||||
const sources = await provider.discoverSessions()
|
||||
|
||||
expect(sources).toHaveLength(1)
|
||||
expect(sources[0]!.path).toBe(jsonlPath)
|
||||
})
|
||||
|
||||
it('parses one user/assistant pair with estimated token counts', async () => {
|
||||
const baseDir = await makeBaseDir()
|
||||
const transcriptDir = join(baseDir, 'projects', 'my-proj', 'agent-transcripts')
|
||||
await mkdir(transcriptDir, { recursive: true })
|
||||
|
||||
const userText = 'explain parser output'
|
||||
const assistantText = 'first line\nsecond line'
|
||||
const transcriptPath = join(transcriptDir, `${FIXED_UUID}.txt`)
|
||||
|
||||
await writeFile(
|
||||
transcriptPath,
|
||||
`user:\n<user_query>${userText}</user_query>\nA:\n${assistantText}\n`
|
||||
)
|
||||
|
||||
const provider = createCursorAgentProvider(baseDir)
|
||||
const source = (await provider.discoverSessions())[0]!
|
||||
const calls = await collectCalls(provider, source)
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]!.provider).toBe('cursor-agent')
|
||||
expect(calls[0]!.model).toBe(CURSOR_AGENT_DEFAULT_MODEL)
|
||||
expect(calls[0]!.inputTokens).toBe(Math.ceil(userText.length / CHARS_PER_TOKEN))
|
||||
expect(calls[0]!.outputTokens).toBe(Math.ceil(assistantText.length / CHARS_PER_TOKEN))
|
||||
expect(calls[0]!.reasoningTokens).toBe(0)
|
||||
expect(calls[0]!.deduplicationKey).toBe(`cursor-agent:${FIXED_UUID}:0`)
|
||||
})
|
||||
|
||||
it('parses without sqlite db and defaults model', async () => {
|
||||
const baseDir = await makeBaseDir()
|
||||
const transcriptDir = join(baseDir, 'projects', 'fallback-proj', 'agent-transcripts')
|
||||
await mkdir(transcriptDir, { recursive: true })
|
||||
const transcriptPath = join(transcriptDir, `${FIXED_UUID}.txt`)
|
||||
|
||||
await writeFile(transcriptPath, 'user:\n<user_query>hello world</user_query>\nA:\n[Thinking]private\nvisible\n')
|
||||
|
||||
const provider = createCursorAgentProvider(baseDir)
|
||||
const source = (await provider.discoverSessions())[0]!
|
||||
const calls = await collectCalls(provider, source)
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]!.model).toBe(CURSOR_AGENT_DEFAULT_MODEL)
|
||||
expect(calls[0]!.reasoningTokens).toBe(2)
|
||||
expect(calls[0]!.outputTokens).toBe(2)
|
||||
})
|
||||
|
||||
it('skips unrecognized transcript format and writes stderr message', async () => {
|
||||
const baseDir = await makeBaseDir()
|
||||
const transcriptDir = join(baseDir, 'projects', 'bad-proj', 'agent-transcripts')
|
||||
await mkdir(transcriptDir, { recursive: true })
|
||||
const transcriptPath = join(transcriptDir, `${FIXED_UUID}.txt`)
|
||||
await writeFile(transcriptPath, 'no markers in this transcript')
|
||||
|
||||
const provider = createCursorAgentProvider(baseDir)
|
||||
const source = (await provider.discoverSessions())[0]!
|
||||
const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true)
|
||||
|
||||
const calls = await collectCalls(provider, source)
|
||||
|
||||
expect(calls).toHaveLength(0)
|
||||
expect(stderrSpy).toHaveBeenCalled()
|
||||
expect(String(stderrSpy.mock.calls[0]?.[0] ?? '')).toContain('unrecognized cursor-agent transcript format')
|
||||
|
||||
stderrSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('warns only once for the same unrecognized transcript', async () => {
|
||||
const baseDir = await makeBaseDir()
|
||||
const transcriptDir = join(baseDir, 'projects', 'bad-proj-repeat', 'agent-transcripts')
|
||||
await mkdir(transcriptDir, { recursive: true })
|
||||
const transcriptPath = join(transcriptDir, 'repeat-bad.txt')
|
||||
await writeFile(transcriptPath, 'no cursor-agent markers here')
|
||||
|
||||
const provider = createCursorAgentProvider(baseDir)
|
||||
const source = (await provider.discoverSessions())[0]!
|
||||
const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true)
|
||||
|
||||
await collectCalls(provider, source)
|
||||
await collectCalls(provider, source)
|
||||
|
||||
const warnings = stderrSpy.mock.calls
|
||||
.map(call => String(call[0] ?? ''))
|
||||
.filter(message => message.includes('unrecognized cursor-agent transcript format'))
|
||||
expect(warnings).toHaveLength(1)
|
||||
|
||||
stderrSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('discovers jsonl transcripts stored directly under project dir (workspace-less layout)', async () => {
|
||||
const baseDir = await makeBaseDir()
|
||||
const fixtureRoot = join(import.meta.dirname, '../fixtures/cursor-agent/workspace-less')
|
||||
const sessionDir = join(baseDir, 'projects', 'agent-transcripts', '1031d227-0c67-4e17-8954-0b6e2b3322f0')
|
||||
await mkdir(sessionDir, { recursive: true })
|
||||
await writeFile(
|
||||
join(sessionDir, '1031d227-0c67-4e17-8954-0b6e2b3322f0.jsonl'),
|
||||
await readFile(
|
||||
join(
|
||||
fixtureRoot,
|
||||
'projects/agent-transcripts/1031d227-0c67-4e17-8954-0b6e2b3322f0/1031d227-0c67-4e17-8954-0b6e2b3322f0.jsonl',
|
||||
),
|
||||
'utf-8',
|
||||
),
|
||||
)
|
||||
|
||||
const provider = createCursorAgentProvider(baseDir)
|
||||
const sources = await provider.discoverSessions()
|
||||
|
||||
expect(sources).toHaveLength(1)
|
||||
expect(sources[0]!.project).toBe('transcripts')
|
||||
expect(sources[0]!.path.endsWith('.jsonl')).toBe(true)
|
||||
|
||||
const calls = await collectCalls(provider, sources[0]!)
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]!.sessionId).toBe('1031d227-0c67-4e17-8954-0b6e2b3322f0')
|
||||
expect(calls[0]!.userMessage).toBe('Run a quick smoke test')
|
||||
expect(calls[0]!.costUSD).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('falls back to stable sha1 conversation id for non-uuid filenames', async () => {
|
||||
const baseDir = await makeBaseDir()
|
||||
const transcriptDir = join(baseDir, 'projects', 'sha-proj', 'agent-transcripts')
|
||||
await mkdir(transcriptDir, { recursive: true })
|
||||
const transcriptPath = join(transcriptDir, 'not-a-uuid.txt')
|
||||
await writeFile(transcriptPath, 'user:\n<user_query>test</user_query>\nA:\nresult\n')
|
||||
|
||||
const provider = createCursorAgentProvider(baseDir)
|
||||
const source = (await provider.discoverSessions())[0]!
|
||||
|
||||
const callsFirst = await collectCalls(provider, source)
|
||||
const callsSecond = await collectCalls(provider, source)
|
||||
|
||||
expect(callsFirst).toHaveLength(1)
|
||||
expect(callsSecond).toHaveLength(1)
|
||||
expect(callsFirst[0]!.sessionId).toHaveLength(16)
|
||||
expect(callsFirst[0]!.deduplicationKey.startsWith('cursor-agent:')).toBe(true)
|
||||
expect(callsFirst[0]!.sessionId).toBe(callsSecond[0]!.sessionId)
|
||||
expect(callsFirst[0]!.deduplicationKey).toBe(callsSecond[0]!.deduplicationKey)
|
||||
})
|
||||
})
|
||||
|
||||
skipUnlessSqlite('cursor-agent sqlite metadata', () => {
|
||||
it('uses model metadata from ai-code-tracking db when present', async () => {
|
||||
const baseDir = await makeBaseDir()
|
||||
const transcriptDir = join(baseDir, 'projects', 'proj-with-db', 'agent-transcripts')
|
||||
const aiTrackingDir = join(baseDir, 'ai-tracking')
|
||||
await mkdir(transcriptDir, { recursive: true })
|
||||
await mkdir(aiTrackingDir, { recursive: true })
|
||||
|
||||
await writeFile(
|
||||
join(transcriptDir, `${FIXED_UUID}.txt`),
|
||||
'user:\n<user_query>estimate cost</user_query>\nA:\nanswer\n'
|
||||
)
|
||||
|
||||
const dbPath = join(aiTrackingDir, 'ai-code-tracking.db')
|
||||
withTestDb(dbPath, (db) => {
|
||||
db.exec('CREATE TABLE conversation_summaries (conversationId TEXT, title TEXT, tldr TEXT, model TEXT, mode TEXT, updatedAt INTEGER)')
|
||||
db.prepare('INSERT INTO conversation_summaries (conversationId, title, tldr, model, mode, updatedAt) VALUES (?, ?, ?, ?, ?, ?)')
|
||||
.run(FIXED_UUID, 'Demo title', '', 'claude-4.6-sonnet', 'agent', 1735689600000)
|
||||
})
|
||||
|
||||
const provider = createCursorAgentProvider(baseDir)
|
||||
const source = (await provider.discoverSessions())[0]!
|
||||
const calls = await collectCalls(provider, source)
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]!.model).toBe('claude-4.6-sonnet')
|
||||
expect(calls[0]!.timestamp).toBe('2025-01-01T00:00:00.000Z')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,176 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||
import { mkdtemp, rm, writeFile } from 'fs/promises'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
|
||||
import { isSqliteAvailable, openDatabase } from '../../src/sqlite.js'
|
||||
import { getAllProviders } from '../../src/providers/index.js'
|
||||
import type { Provider, ParsedProviderCall } from '../../src/providers/types.js'
|
||||
|
||||
/// Pinned regression for the v3 bubble-dedup fix. The previous (v2) code used
|
||||
/// the bubble row's mutable token counts as part of the deduplication key, so
|
||||
/// the same bubble was counted twice once Cursor wrote the streaming-complete
|
||||
/// final token totals on top of the streaming-in-progress row. v3 switched to
|
||||
/// the SQLite primary `key` column (which is the stable bubbleId:<id>:<id>
|
||||
/// path) so re-parsing the same DB after token updates produces zero new
|
||||
/// calls. This test:
|
||||
/// 1. Builds a tmp SQLite DB with the cursorDiskKV schema and one bubble row
|
||||
/// with low token counts (the streaming-in-progress shape).
|
||||
/// 2. Parses it through the cursor provider. Asserts one call.
|
||||
/// 3. Mutates the row in place to higher token counts (the streaming-complete
|
||||
/// shape) without changing the SQLite key.
|
||||
/// 4. Re-parses with the SAME seenKeys set. Asserts zero new calls.
|
||||
/// If a future refactor brings back token-count-based dedup, the second parse
|
||||
/// will produce a duplicate call and this test will fail.
|
||||
|
||||
const skipReason = isSqliteAvailable()
|
||||
? null
|
||||
: 'node:sqlite not available — needs Node 22+; skipping'
|
||||
|
||||
let tmpDir: string
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await mkdtemp(join(tmpdir(), 'cursor-dedup-'))
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function buildBubbleValue(opts: {
|
||||
conversationId: string
|
||||
text: string
|
||||
inputTokens: number
|
||||
outputTokens: number
|
||||
type: 1 | 2
|
||||
createdAt?: string
|
||||
}): string {
|
||||
return JSON.stringify({
|
||||
type: opts.type,
|
||||
conversationId: opts.conversationId,
|
||||
text: opts.text,
|
||||
tokenCount: {
|
||||
inputTokens: opts.inputTokens,
|
||||
outputTokens: opts.outputTokens,
|
||||
},
|
||||
createdAt: opts.createdAt ?? new Date().toISOString(),
|
||||
modelId: 'gpt-5',
|
||||
capabilityType: 'composer',
|
||||
})
|
||||
}
|
||||
|
||||
async function createCursorTestDb(): Promise<string> {
|
||||
// Cursor uses a non-extension state DB filename (state.vscdb in the real app);
|
||||
// any path works for openDatabase as long as we set up the schema and the
|
||||
// directory layout the parser expects. The parser only checks the DB
|
||||
// contents — discovery is bypassed because we hand it the path directly.
|
||||
const dbPath = join(tmpDir, 'state.vscdb')
|
||||
await writeFile(dbPath, '')
|
||||
// Use the underlying node:sqlite to create the schema.
|
||||
// We need cursorDiskKV with key + value columns.
|
||||
const Module = await import('node:module')
|
||||
const requireForSqlite = Module.createRequire(import.meta.url)
|
||||
const { DatabaseSync } = requireForSqlite('node:sqlite') as {
|
||||
DatabaseSync: new (path: string) => {
|
||||
exec(sql: string): void
|
||||
prepare(sql: string): { run(...p: unknown[]): unknown }
|
||||
close(): void
|
||||
}
|
||||
}
|
||||
const db = new DatabaseSync(dbPath)
|
||||
db.exec('CREATE TABLE cursorDiskKV (key TEXT PRIMARY KEY, value TEXT)')
|
||||
|
||||
// Single assistant bubble (type=2). The parser yields one ParsedProviderCall
|
||||
// per bubbleId:% row, so a multi-row fixture would muddy the dedup count;
|
||||
// we keep the test surface minimal — one bubble through one parse, then
|
||||
// the same bubble again after token mutation.
|
||||
const bubbleKey = 'bubbleId:abc-123:bubble-xyz'
|
||||
db.prepare('INSERT INTO cursorDiskKV (key, value) VALUES (?, ?)').run(
|
||||
bubbleKey,
|
||||
buildBubbleValue({
|
||||
conversationId: 'abc-123',
|
||||
text: 'def hello(): pass',
|
||||
inputTokens: 100,
|
||||
outputTokens: 20,
|
||||
type: 2,
|
||||
})
|
||||
)
|
||||
|
||||
db.close()
|
||||
return dbPath
|
||||
}
|
||||
|
||||
async function updateAssistantBubbleTokens(dbPath: string, inputTokens: number, outputTokens: number): Promise<void> {
|
||||
const Module = await import('node:module')
|
||||
const requireForSqlite = Module.createRequire(import.meta.url)
|
||||
const { DatabaseSync } = requireForSqlite('node:sqlite') as {
|
||||
DatabaseSync: new (path: string) => {
|
||||
prepare(sql: string): { run(...p: unknown[]): unknown }
|
||||
close(): void
|
||||
}
|
||||
}
|
||||
const db = new DatabaseSync(dbPath)
|
||||
db.prepare('UPDATE cursorDiskKV SET value = ? WHERE key = ?').run(
|
||||
buildBubbleValue({
|
||||
conversationId: 'abc-123',
|
||||
text: 'def hello(): pass',
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
type: 2,
|
||||
}),
|
||||
'bubbleId:abc-123:bubble-xyz'
|
||||
)
|
||||
db.close()
|
||||
}
|
||||
|
||||
async function getCursorProvider(): Promise<Provider> {
|
||||
const all = await getAllProviders()
|
||||
const p = all.find(p => p.name === 'cursor')
|
||||
if (!p) throw new Error('cursor provider not registered')
|
||||
return p
|
||||
}
|
||||
|
||||
describe.skipIf(skipReason !== null)('cursor bubble dedup (regression for v3 fix)', () => {
|
||||
it('does not double-count when bubble token counts mutate between parses', async () => {
|
||||
const dbPath = await createCursorTestDb()
|
||||
const provider = await getCursorProvider()
|
||||
|
||||
// First parse: streaming-in-progress shape.
|
||||
const seenKeys = new Set<string>()
|
||||
const source = { path: dbPath, project: 'test-project', provider: 'cursor' }
|
||||
const firstRunCalls: ParsedProviderCall[] = []
|
||||
for await (const call of provider.createSessionParser(source, seenKeys).parse()) {
|
||||
firstRunCalls.push(call)
|
||||
}
|
||||
expect(firstRunCalls.length).toBe(1)
|
||||
|
||||
// Cursor mutates the same bubble row to its final token totals when the
|
||||
// stream completes. Simulate by updating in place. The SQLite primary
|
||||
// key stays the same.
|
||||
await updateAssistantBubbleTokens(dbPath, 250, 80)
|
||||
|
||||
// Second parse with the SAME seenKeys: must yield zero new calls. If the
|
||||
// dedup key were derived from token counts (the v2 bug), this would
|
||||
// produce a duplicate.
|
||||
const secondRunCalls: ParsedProviderCall[] = []
|
||||
for await (const call of provider.createSessionParser(source, seenKeys).parse()) {
|
||||
secondRunCalls.push(call)
|
||||
}
|
||||
expect(secondRunCalls.length).toBe(0)
|
||||
})
|
||||
|
||||
it('does not yield the same bubble twice within a single parser run', async () => {
|
||||
const dbPath = await createCursorTestDb()
|
||||
const provider = await getCursorProvider()
|
||||
const seenKeys = new Set<string>()
|
||||
const source = { path: dbPath, project: 'test-project', provider: 'cursor' }
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of provider.createSessionParser(source, seenKeys).parse()) {
|
||||
calls.push(call)
|
||||
}
|
||||
// One bubble in the DB → one call. (The user message row at type=1 is
|
||||
// not surfaced as a separate ParsedProviderCall; it's threaded into the
|
||||
// assistant call's userMessage field.)
|
||||
expect(calls.length).toBe(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,129 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||
import { mkdtemp, rm, writeFile } from 'fs/promises'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
|
||||
import { isSqliteAvailable } from '../../src/sqlite.js'
|
||||
import { getAllProviders } from '../../src/providers/index.js'
|
||||
import type { Provider, ParsedProviderCall } from '../../src/providers/types.js'
|
||||
import type { DateRange } from '../../src/types.js'
|
||||
|
||||
/// Regression for #482: the Cursor scan must not drop in-range sessions just
|
||||
/// because the DB has more bubbles than the scan budget. The old code kept the
|
||||
/// most-recent MAX_BUBBLES rows *by ROWID* and warned unconditionally; the new
|
||||
/// code pages the requested time window and only truncates (with a warning)
|
||||
/// when the in-range scan genuinely exceeds the budget. We shrink the budget
|
||||
/// via CODEBURN_CURSOR_MAX_BUBBLES so a tiny fixture exercises the capped path.
|
||||
|
||||
const skipReason = isSqliteAvailable() ? null : 'node:sqlite not available — needs Node 22+; skipping'
|
||||
|
||||
let tmpDir: string
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await mkdtemp(join(tmpdir(), 'cursor-cap-'))
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
type Bubble = { conversationId: string; createdAt: string; model: string; tokens: number }
|
||||
|
||||
/// Inserts assistant bubbles in array order, so ROWID follows array index.
|
||||
async function createDb(bubbles: Bubble[]): Promise<string> {
|
||||
const dbPath = join(tmpDir, 'state.vscdb')
|
||||
await writeFile(dbPath, '')
|
||||
const Module = await import('node:module')
|
||||
const requireForSqlite = Module.createRequire(import.meta.url)
|
||||
const { DatabaseSync } = requireForSqlite('node:sqlite') as {
|
||||
DatabaseSync: new (path: string) => {
|
||||
exec(sql: string): void
|
||||
prepare(sql: string): { run(...p: unknown[]): unknown }
|
||||
close(): void
|
||||
}
|
||||
}
|
||||
const db = new DatabaseSync(dbPath)
|
||||
db.exec('CREATE TABLE cursorDiskKV (key TEXT PRIMARY KEY, value TEXT)')
|
||||
const stmt = db.prepare('INSERT INTO cursorDiskKV (key, value) VALUES (?, ?)')
|
||||
bubbles.forEach((b, i) => {
|
||||
stmt.run(
|
||||
`bubbleId:${b.conversationId}:bubble-${i}`,
|
||||
JSON.stringify({
|
||||
type: 2,
|
||||
conversationId: b.conversationId,
|
||||
text: 'def hello(): pass',
|
||||
tokenCount: { inputTokens: b.tokens, outputTokens: b.tokens },
|
||||
createdAt: b.createdAt,
|
||||
modelInfo: { modelName: b.model },
|
||||
}),
|
||||
)
|
||||
})
|
||||
db.close()
|
||||
return dbPath
|
||||
}
|
||||
|
||||
async function getCursorProvider(): Promise<Provider> {
|
||||
const p = (await getAllProviders()).find(p => p.name === 'cursor')
|
||||
if (!p) throw new Error('cursor provider not registered')
|
||||
return p
|
||||
}
|
||||
|
||||
async function parse(dbPath: string, range: DateRange): Promise<ParsedProviderCall[]> {
|
||||
const provider = await getCursorProvider()
|
||||
const source = { path: dbPath, project: 'test', provider: 'cursor' }
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of provider.createSessionParser(source, new Set<string>(), range).parse()) {
|
||||
calls.push(call)
|
||||
}
|
||||
return calls
|
||||
}
|
||||
|
||||
const iso = (daysAgo: number) => new Date(Date.now() - daysAgo * 24 * 60 * 60 * 1000).toISOString()
|
||||
const last30Days = (): DateRange => ({ start: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000), end: new Date() })
|
||||
const last120Days = (): DateRange => ({ start: new Date(Date.now() - 120 * 24 * 60 * 60 * 1000), end: new Date() })
|
||||
|
||||
describe.skipIf(skipReason !== null)('cursor large-DB scan cap (#482)', () => {
|
||||
it('keeps in-range sessions even when they have low ROWIDs and the DB is over budget', async () => {
|
||||
// In-range bubbles inserted FIRST (low ROWID); out-of-range bubbles inserted
|
||||
// LATER (high ROWID). The old "most-recent N by ROWID" cap would scan only
|
||||
// the high-ROWID out-of-range rows and drop the in-range ones entirely.
|
||||
const dbPath = await createDb([
|
||||
{ conversationId: 'recent-A', createdAt: iso(1), model: 'gpt-5', tokens: 100 },
|
||||
{ conversationId: 'recent-B', createdAt: iso(2), model: 'gpt-5', tokens: 100 },
|
||||
{ conversationId: 'old-C', createdAt: iso(300), model: 'gpt-5', tokens: 100 },
|
||||
{ conversationId: 'old-D', createdAt: iso(300), model: 'gpt-5', tokens: 100 },
|
||||
])
|
||||
process.env['CODEBURN_CURSOR_MAX_BUBBLES'] = '2' // total 4 > budget 2 -> capped path
|
||||
|
||||
const calls = await parse(dbPath, last30Days())
|
||||
// Both in-range sessions are present (the old ROWID cap returned 0 here).
|
||||
expect(calls.length).toBe(2)
|
||||
})
|
||||
|
||||
it('returns the whole window when in-range bubbles fit the budget (over-budget DB)', async () => {
|
||||
const dbPath = await createDb([
|
||||
{ conversationId: 'A', createdAt: iso(1), model: 'gpt-5', tokens: 100 },
|
||||
{ conversationId: 'B', createdAt: iso(2), model: 'gpt-5', tokens: 100 },
|
||||
{ conversationId: 'old', createdAt: iso(300), model: 'gpt-5', tokens: 100 },
|
||||
{ conversationId: 'older', createdAt: iso(301), model: 'gpt-5', tokens: 100 },
|
||||
])
|
||||
process.env['CODEBURN_CURSOR_MAX_BUBBLES'] = '3' // total 4 > budget 3, but in-range 2 <= 3
|
||||
const calls = await parse(dbPath, last30Days())
|
||||
expect(calls.length).toBe(2) // both in-range, none truncated
|
||||
})
|
||||
|
||||
it('truncates to the budget and keeps the newest in-range bubbles when over budget', async () => {
|
||||
// Four in-range bubbles, oldest->newest by ROWID; budget 2 keeps the two newest.
|
||||
const dbPath = await createDb([
|
||||
{ conversationId: 'd1', createdAt: iso(40), model: 'old-model', tokens: 100 },
|
||||
{ conversationId: 'd2', createdAt: iso(30), model: 'old-model', tokens: 100 },
|
||||
{ conversationId: 'd3', createdAt: iso(2), model: 'new-model', tokens: 100 },
|
||||
{ conversationId: 'd4', createdAt: iso(1), model: 'new-model', tokens: 100 },
|
||||
])
|
||||
process.env['CODEBURN_CURSOR_MAX_BUBBLES'] = '2' // total 4 > budget 2
|
||||
const calls = await parse(dbPath, last120Days())
|
||||
expect(calls.length).toBe(2)
|
||||
// Budget keeps the highest-ROWID (newest-inserted) bubbles.
|
||||
expect(calls.every(c => c.model === 'new-model')).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,412 @@
|
||||
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 {
|
||||
createCursorProvider,
|
||||
clearCursorWorkspaceMapCache,
|
||||
} from '../../src/providers/cursor.js'
|
||||
import { isSqliteAvailable } from '../../src/sqlite.js'
|
||||
import type { ParsedProviderCall } from '../../src/providers/types.js'
|
||||
|
||||
const requireForTest = createRequire(import.meta.url)
|
||||
|
||||
const skipReason = isSqliteAvailable()
|
||||
? null
|
||||
: 'node:sqlite not available — needs Node 22+; skipping'
|
||||
|
||||
let tmpDir: string
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await mkdtemp(join(tmpdir(), 'cursor-tokens-test-'))
|
||||
clearCursorWorkspaceMapCache()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
clearCursorWorkspaceMapCache()
|
||||
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, 'state.vscdb')
|
||||
const { DatabaseSync: Database } = requireForTest('node:sqlite')
|
||||
const db = new Database(dbPath)
|
||||
db.exec('CREATE TABLE cursorDiskKV (key TEXT PRIMARY KEY, value BLOB)')
|
||||
db.exec('CREATE TABLE ItemTable (key TEXT UNIQUE, value BLOB)')
|
||||
fn(db)
|
||||
db.close()
|
||||
return dbPath
|
||||
}
|
||||
|
||||
function insertBubble(db: {
|
||||
prepare(sql: string): { run(...params: unknown[]): void }
|
||||
}, opts: {
|
||||
composerId: string
|
||||
bubbleUuid: string
|
||||
type: 1 | 2
|
||||
text: string
|
||||
model?: string
|
||||
inputTokens?: number
|
||||
outputTokens?: number
|
||||
createdAt?: string
|
||||
requestId?: string
|
||||
codeBlocks?: string
|
||||
}): void {
|
||||
const key = `bubbleId:${opts.composerId}:${opts.bubbleUuid}`
|
||||
const value = JSON.stringify({
|
||||
type: opts.type,
|
||||
conversationId: '',
|
||||
createdAt: opts.createdAt ?? new Date().toISOString(),
|
||||
tokenCount: {
|
||||
inputTokens: opts.inputTokens ?? 0,
|
||||
outputTokens: opts.outputTokens ?? 0,
|
||||
},
|
||||
modelInfo: opts.model ? { modelName: opts.model } : undefined,
|
||||
text: opts.text,
|
||||
codeBlocks: opts.codeBlocks ?? '[]',
|
||||
requestId: opts.requestId,
|
||||
})
|
||||
db.prepare('INSERT INTO cursorDiskKV (key, value) VALUES (?, ?)').run(key, value)
|
||||
}
|
||||
|
||||
function insertComposerData(db: {
|
||||
prepare(sql: string): { run(...params: unknown[]): void }
|
||||
}, opts: {
|
||||
composerId: string
|
||||
totalUsedTokens?: number | null
|
||||
contextTokensUsed?: number | null
|
||||
createdAt?: number
|
||||
}): void {
|
||||
const key = `composerData:${opts.composerId}`
|
||||
const breakdown = opts.totalUsedTokens !== undefined
|
||||
? { totalUsedTokens: opts.totalUsedTokens }
|
||||
: {}
|
||||
const value = JSON.stringify({
|
||||
promptTokenBreakdown: breakdown,
|
||||
contextTokensUsed: opts.contextTokensUsed ?? undefined,
|
||||
createdAt: opts.createdAt ?? undefined,
|
||||
})
|
||||
db.prepare('INSERT INTO cursorDiskKV (key, value) VALUES (?, ?)').run(key, value)
|
||||
}
|
||||
|
||||
function insertAgentKv(db: {
|
||||
prepare(sql: string): { run(...params: unknown[]): void }
|
||||
}, opts: {
|
||||
blobId: string
|
||||
role: string
|
||||
content: unknown
|
||||
requestId?: string
|
||||
}): void {
|
||||
const key = `agentKv:blob:${opts.blobId}`
|
||||
const value = JSON.stringify({
|
||||
role: opts.role,
|
||||
content: opts.content,
|
||||
providerOptions: opts.requestId
|
||||
? { cursor: { requestId: opts.requestId } }
|
||||
: undefined,
|
||||
})
|
||||
db.prepare('INSERT INTO cursorDiskKV (key, value) VALUES (?, ?)').run(key, value)
|
||||
}
|
||||
|
||||
async function collectCalls(provider: ReturnType<typeof createCursorProvider>, dbPath: string): Promise<ParsedProviderCall[]> {
|
||||
const source = { path: dbPath, project: 'test', provider: 'cursor' as const }
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of provider.createSessionParser(source, new Set()).parse()) {
|
||||
calls.push(call)
|
||||
}
|
||||
return calls
|
||||
}
|
||||
|
||||
describe.skipIf(skipReason !== null)('cursor real context tokens (#575)', () => {
|
||||
it('credits composerData.promptTokenBreakdown.totalUsedTokens as input', async () => {
|
||||
const composerId = 'aaaa1111-2222-3333-4444-555566667777'
|
||||
const dbPath = buildDb((db) => {
|
||||
insertComposerData(db, { composerId, totalUsedTokens: 50000 })
|
||||
insertBubble(db, {
|
||||
composerId, bubbleUuid: 'b1', type: 1, text: 'user prompt',
|
||||
inputTokens: 0, outputTokens: 0,
|
||||
})
|
||||
insertBubble(db, {
|
||||
composerId, bubbleUuid: 'b2', type: 2, text: 'assistant reply',
|
||||
model: 'claude-4.6-sonnet', inputTokens: 0, outputTokens: 0,
|
||||
})
|
||||
})
|
||||
|
||||
const provider = createCursorProvider(dbPath)
|
||||
const calls = await collectCalls(provider, dbPath)
|
||||
|
||||
const credited = calls.find(c => c.inputTokens === 50000)
|
||||
expect(credited).toBeDefined()
|
||||
expect(credited!.deduplicationKey).toBe(`cursor:composer-input:${composerId}`)
|
||||
expect(credited!.costIsEstimated).toBe(true)
|
||||
})
|
||||
|
||||
it('credits real input tokens once per conversation, not per bubble', async () => {
|
||||
const composerId = 'bbbb1111-2222-3333-4444-555566667777'
|
||||
const dbPath = buildDb((db) => {
|
||||
insertComposerData(db, { composerId, totalUsedTokens: 30000 })
|
||||
insertBubble(db, { composerId, bubbleUuid: 'b1', type: 1, text: 'turn 1' })
|
||||
insertBubble(db, { composerId, bubbleUuid: 'b2', type: 2, text: 'reply 1', model: 'gpt-5' })
|
||||
insertBubble(db, { composerId, bubbleUuid: 'b3', type: 1, text: 'turn 2' })
|
||||
insertBubble(db, { composerId, bubbleUuid: 'b4', type: 2, text: 'reply 2', model: 'gpt-5' })
|
||||
})
|
||||
|
||||
const provider = createCursorProvider(dbPath)
|
||||
const calls = await collectCalls(provider, dbPath)
|
||||
|
||||
const credited = calls.filter(c => c.inputTokens === 30000)
|
||||
expect(credited.length).toBe(1)
|
||||
// The metered conversation's user-bubble text must not be counted on top.
|
||||
const inputTotal = calls.reduce((s, c) => s + c.inputTokens, 0)
|
||||
expect(inputTotal).toBe(30000)
|
||||
})
|
||||
|
||||
it('anchors the conversation record to composerData.createdAt, independent of the parse window', async () => {
|
||||
const composerId = 'ab121111-2222-3333-4444-555566667777'
|
||||
const startMs = Date.parse('2026-06-01T10:00:00.000Z')
|
||||
const dbPath = buildDb((db) => {
|
||||
insertComposerData(db, { composerId, totalUsedTokens: 20000, createdAt: startMs })
|
||||
insertBubble(db, { composerId, bubbleUuid: 'b1', type: 1, text: 'later turn' })
|
||||
})
|
||||
|
||||
const provider = createCursorProvider(dbPath)
|
||||
const calls = await collectCalls(provider, dbPath)
|
||||
|
||||
const credited = calls.find(c => c.inputTokens === 20000)
|
||||
expect(credited).toBeDefined()
|
||||
expect(credited!.timestamp).toBe(new Date(startMs).toISOString())
|
||||
})
|
||||
|
||||
it('falls back to text estimation when no composerData exists', async () => {
|
||||
const composerId = 'cccc1111-2222-3333-4444-555566667777'
|
||||
const dbPath = buildDb((db) => {
|
||||
insertBubble(db, {
|
||||
composerId, bubbleUuid: 'b1', type: 1, text: 'hello world this is a test',
|
||||
})
|
||||
})
|
||||
|
||||
const provider = createCursorProvider(dbPath)
|
||||
const calls = await collectCalls(provider, dbPath)
|
||||
|
||||
const userCall = calls.find(c => c.inputTokens > 0)
|
||||
expect(userCall).toBeDefined()
|
||||
expect(userCall!.inputTokens).toBe(Math.ceil('hello world this is a test'.length / 4))
|
||||
})
|
||||
|
||||
it('uses contextTokensUsed when totalUsedTokens is null', async () => {
|
||||
const composerId = 'dddd1111-2222-3333-4444-555566667777'
|
||||
const dbPath = buildDb((db) => {
|
||||
insertComposerData(db, { composerId, totalUsedTokens: null, contextTokensUsed: 42000 })
|
||||
insertBubble(db, { composerId, bubbleUuid: 'b1', type: 1, text: 'prompt' })
|
||||
insertBubble(db, { composerId, bubbleUuid: 'b2', type: 2, text: 'reply', model: 'gpt-5' })
|
||||
})
|
||||
|
||||
const provider = createCursorProvider(dbPath)
|
||||
const calls = await collectCalls(provider, dbPath)
|
||||
|
||||
const credited = calls.find(c => c.inputTokens === 42000)
|
||||
expect(credited).toBeDefined()
|
||||
})
|
||||
|
||||
it('uses contextTokensUsed when totalUsedTokens is present but zero', async () => {
|
||||
const composerId = 'de001111-2222-3333-4444-555566667777'
|
||||
const dbPath = buildDb((db) => {
|
||||
insertComposerData(db, { composerId, totalUsedTokens: 0, contextTokensUsed: 42000 })
|
||||
insertBubble(db, { composerId, bubbleUuid: 'b1', type: 1, text: 'prompt' })
|
||||
})
|
||||
|
||||
const provider = createCursorProvider(dbPath)
|
||||
const calls = await collectCalls(provider, dbPath)
|
||||
|
||||
const credited = calls.find(c => c.inputTokens === 42000)
|
||||
expect(credited).toBeDefined()
|
||||
})
|
||||
|
||||
it('skips the meter when any bubble carries real tokenCounts', async () => {
|
||||
const composerId = 'ef001111-2222-3333-4444-555566667777'
|
||||
const dbPath = buildDb((db) => {
|
||||
insertComposerData(db, { composerId, totalUsedTokens: 80000 })
|
||||
insertBubble(db, { composerId, bubbleUuid: 'b1', type: 1, text: 'prompt', inputTokens: 6000 })
|
||||
insertBubble(db, { composerId, bubbleUuid: 'b2', type: 2, text: 'reply', model: 'gpt-5', outputTokens: 900 })
|
||||
})
|
||||
|
||||
const provider = createCursorProvider(dbPath)
|
||||
const calls = await collectCalls(provider, dbPath)
|
||||
|
||||
// Real per-bubble counts are authoritative; the snapshot must not stack.
|
||||
expect(calls.find(c => c.inputTokens === 80000)).toBeUndefined()
|
||||
const inputTotal = calls.reduce((s, c) => s + c.inputTokens, 0)
|
||||
expect(inputTotal).toBe(6000)
|
||||
})
|
||||
|
||||
it('attributes aggregated agentKv tools once, with canonical Bash names', async () => {
|
||||
const composerId = 'eeee1111-2222-3333-4444-555566667777'
|
||||
const requestId = 'req-001'
|
||||
const dbPath = buildDb((db) => {
|
||||
insertComposerData(db, { composerId, totalUsedTokens: 10000 })
|
||||
insertBubble(db, { composerId, bubbleUuid: 'b1', type: 1, text: 'do stuff', requestId })
|
||||
insertBubble(db, { composerId, bubbleUuid: 'b2', type: 2, text: 'doing stuff', model: 'gpt-5' })
|
||||
insertBubble(db, { composerId, bubbleUuid: 'b3', type: 1, text: 'do more stuff', requestId: 'req-002' })
|
||||
insertBubble(db, { composerId, bubbleUuid: 'b4', type: 2, text: 'doing more stuff', model: 'gpt-5' })
|
||||
insertAgentKv(db, {
|
||||
blobId: 'akv-1', role: 'user',
|
||||
content: [{ type: 'text', text: 'do stuff' }],
|
||||
requestId,
|
||||
})
|
||||
insertAgentKv(db, {
|
||||
blobId: 'akv-2', role: 'assistant',
|
||||
content: [
|
||||
{ type: 'tool-call', toolName: 'Read', args: {} },
|
||||
{ type: 'tool-call', toolName: 'Shell', args: { command: 'npm test' } },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
const provider = createCursorProvider(dbPath)
|
||||
const calls = await collectCalls(provider, dbPath)
|
||||
|
||||
const callWithTools = calls.find(c => c.tools.length > 0)
|
||||
expect(callWithTools).toBeDefined()
|
||||
expect(callWithTools!.tools).toContain('Read')
|
||||
expect(callWithTools!.tools).toContain('Bash')
|
||||
expect(callWithTools!.bashCommands).toContain('npm')
|
||||
|
||||
const allTools = calls.flatMap(c => c.tools)
|
||||
const allBashCommands = calls.flatMap(c => c.bashCommands)
|
||||
expect(allTools.filter(t => t === 'Read').length).toBe(1)
|
||||
expect(allTools.filter(t => t === 'Bash').length).toBe(1)
|
||||
expect(allBashCommands.filter(cmd => cmd === 'npm').length).toBe(1)
|
||||
})
|
||||
|
||||
it('uses conversation model for pricing the conversation record', async () => {
|
||||
const composerId = 'ffff1111-2222-3333-4444-555566667777'
|
||||
const dbPath = buildDb((db) => {
|
||||
insertComposerData(db, { composerId, totalUsedTokens: 100000 })
|
||||
insertBubble(db, { composerId, bubbleUuid: 'b1', type: 1, text: 'prompt' })
|
||||
insertBubble(db, {
|
||||
composerId, bubbleUuid: 'b2', type: 2, text: 'reply',
|
||||
model: 'claude-4.5-opus-high-thinking',
|
||||
})
|
||||
})
|
||||
|
||||
const provider = createCursorProvider(dbPath)
|
||||
const calls = await collectCalls(provider, dbPath)
|
||||
|
||||
const creditedCall = calls.find(c => c.inputTokens === 100000)
|
||||
expect(creditedCall).toBeDefined()
|
||||
expect(creditedCall!.model).toBe('claude-4.5-opus-high-thinking')
|
||||
})
|
||||
|
||||
it('estimates input from the agent stream when a non-Composer turn has empty bubble text', async () => {
|
||||
const composerId = '99990000-1111-2222-3333-444455556666'
|
||||
const requestId = 'req-gpt-1'
|
||||
const prompt = '<user_info>OS: darwin</user_info> refactor the auth module and add tests'
|
||||
const dbPath = buildDb((db) => {
|
||||
insertBubble(db, { composerId, bubbleUuid: 'b1', type: 1, text: '', requestId })
|
||||
insertBubble(db, { composerId, bubbleUuid: 'b2', type: 2, text: 'done', model: 'gpt-5' })
|
||||
insertAgentKv(db, { blobId: 'akv-1', role: 'user', content: prompt, requestId })
|
||||
})
|
||||
|
||||
const provider = createCursorProvider(dbPath)
|
||||
const calls = await collectCalls(provider, dbPath)
|
||||
|
||||
const credited = calls.find(c => c.inputTokens > 0)
|
||||
expect(credited).toBeDefined()
|
||||
expect(credited!.inputTokens).toBe(Math.ceil(prompt.length / 4))
|
||||
})
|
||||
|
||||
it('counts tool and system stream rows as context for meterless sessions', async () => {
|
||||
const composerId = '77770000-1111-2222-3333-444455556666'
|
||||
const requestId = 'req-gpt-2'
|
||||
const prompt = 'summarize the repo'
|
||||
const toolResult = 'x'.repeat(4000)
|
||||
const dbPath = buildDb((db) => {
|
||||
insertBubble(db, { composerId, bubbleUuid: 'b1', type: 1, text: '', requestId })
|
||||
insertBubble(db, { composerId, bubbleUuid: 'b2', type: 2, text: 'done', model: 'gpt-5' })
|
||||
insertAgentKv(db, { blobId: 'akv-1', role: 'user', content: prompt, requestId })
|
||||
insertAgentKv(db, { blobId: 'akv-2', role: 'tool', content: [{ type: 'text', text: toolResult }] })
|
||||
})
|
||||
|
||||
const provider = createCursorProvider(dbPath)
|
||||
const calls = await collectCalls(provider, dbPath)
|
||||
|
||||
const credited = calls.find(c => c.inputTokens > 0)
|
||||
expect(credited).toBeDefined()
|
||||
expect(credited!.inputTokens).toBe(Math.ceil((prompt.length + toolResult.length) / 4))
|
||||
})
|
||||
|
||||
it('does not double count turns that also have bubble text in stream-estimated conversations', async () => {
|
||||
const composerId = '66660000-1111-2222-3333-444455556666'
|
||||
const requestId = 'req-gpt-3'
|
||||
const streamPrompt = 'the full prompt with injected context'
|
||||
const dbPath = buildDb((db) => {
|
||||
// Turn 1 has visible bubble text; turn 2's lives only in the stream.
|
||||
insertBubble(db, { composerId, bubbleUuid: 'b1', type: 1, text: 'visible text', requestId })
|
||||
insertBubble(db, { composerId, bubbleUuid: 'b2', type: 1, text: '' })
|
||||
insertAgentKv(db, { blobId: 'akv-1', role: 'user', content: streamPrompt, requestId })
|
||||
})
|
||||
|
||||
const provider = createCursorProvider(dbPath)
|
||||
const calls = await collectCalls(provider, dbPath)
|
||||
|
||||
const inputTotal = calls.reduce((s, c) => s + c.inputTokens, 0)
|
||||
expect(inputTotal).toBe(Math.ceil(streamPrompt.length / 4))
|
||||
})
|
||||
|
||||
it('emits sessions recorded only in the agent stream', async () => {
|
||||
const requestId = 'req-headless-1'
|
||||
const prompt = 'run the nightly data export'
|
||||
const reply = 'export completed with 3 warnings'
|
||||
const dbPath = buildDb((db) => {
|
||||
insertAgentKv(db, { blobId: 'akv-1', role: 'user', content: prompt, requestId })
|
||||
insertAgentKv(db, { blobId: 'akv-2', role: 'assistant', content: [{ type: 'text', text: reply }] })
|
||||
})
|
||||
|
||||
const provider = createCursorProvider(dbPath)
|
||||
const calls = await collectCalls(provider, dbPath)
|
||||
|
||||
const session = calls.find(c => c.deduplicationKey === `cursor:agentKv:${requestId}`)
|
||||
expect(session).toBeDefined()
|
||||
expect(session!.inputTokens).toBe(Math.ceil(prompt.length / 4))
|
||||
expect(session!.outputTokens).toBe(Math.ceil(reply.length / 4))
|
||||
})
|
||||
|
||||
it('pairs each assistant reply with its own turn\'s user question', async () => {
|
||||
const composerId = '55550000-1111-2222-3333-444455556666'
|
||||
const dbPath = buildDb((db) => {
|
||||
insertBubble(db, { composerId, bubbleUuid: 'b1', type: 1, text: 'first question' })
|
||||
insertBubble(db, { composerId, bubbleUuid: 'b2', type: 2, text: 'first reply', model: 'gpt-5' })
|
||||
insertBubble(db, { composerId, bubbleUuid: 'b3', type: 1, text: 'second question' })
|
||||
insertBubble(db, { composerId, bubbleUuid: 'b4', type: 2, text: 'second reply', model: 'gpt-5' })
|
||||
})
|
||||
|
||||
const provider = createCursorProvider(dbPath)
|
||||
const calls = await collectCalls(provider, dbPath)
|
||||
|
||||
const firstReply = calls.find(c => c.userMessage.includes('first reply'))
|
||||
const secondReply = calls.find(c => c.userMessage.includes('second reply'))
|
||||
expect(firstReply).toBeDefined()
|
||||
expect(secondReply).toBeDefined()
|
||||
expect(firstReply!.userMessage).toContain('first question')
|
||||
expect(secondReply!.userMessage).toContain('second question')
|
||||
})
|
||||
|
||||
it('does not fabricate input when an empty-text turn has no agent stream', async () => {
|
||||
const composerId = '88880000-1111-2222-3333-444455556666'
|
||||
const dbPath = buildDb((db) => {
|
||||
insertBubble(db, { composerId, bubbleUuid: 'b1', type: 1, text: '' })
|
||||
insertBubble(db, { composerId, bubbleUuid: 'b2', type: 2, text: 'done', model: 'gpt-5' })
|
||||
})
|
||||
|
||||
const provider = createCursorProvider(dbPath)
|
||||
const calls = await collectCalls(provider, dbPath)
|
||||
|
||||
expect(calls.find(c => c.inputTokens > 0)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,330 @@
|
||||
import { mkdtemp, mkdir, rm, writeFile } 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 {
|
||||
createCursorProvider,
|
||||
clearCursorWorkspaceMapCache,
|
||||
} from '../../src/providers/cursor.js'
|
||||
import { isSqliteAvailable } from '../../src/sqlite.js'
|
||||
import type { ParsedProviderCall } from '../../src/providers/types.js'
|
||||
|
||||
const requireForTest = createRequire(import.meta.url)
|
||||
|
||||
let userDir: string
|
||||
|
||||
beforeEach(async () => {
|
||||
userDir = await mkdtemp(join(tmpdir(), 'cursor-ws-test-'))
|
||||
// Layout matches Cursor's: <userDir>/{globalStorage,workspaceStorage}/.
|
||||
await mkdir(join(userDir, 'globalStorage'), { recursive: true })
|
||||
await mkdir(join(userDir, 'workspaceStorage'), { recursive: true })
|
||||
clearCursorWorkspaceMapCache()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
clearCursorWorkspaceMapCache()
|
||||
await rm(userDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function globalDbPath(): string {
|
||||
return join(userDir, 'globalStorage', 'state.vscdb')
|
||||
}
|
||||
|
||||
/// Builds a global state.vscdb with the cursorDiskKV table and a small set of
|
||||
/// bubbles for the requested composer ids. Each bubble carries enough fields
|
||||
/// to satisfy parseBubbles() — created_at, tokenCount, conversationId, type.
|
||||
function createGlobalDb(composerIds: string[]): string {
|
||||
const dbPath = globalDbPath()
|
||||
const { DatabaseSync: Database } = requireForTest('node:sqlite')
|
||||
const db = new Database(dbPath)
|
||||
db.exec(`CREATE TABLE cursorDiskKV (key TEXT PRIMARY KEY, value BLOB)`)
|
||||
// ItemTable is unused by the global parser but creating it mirrors the
|
||||
// real schema so a stray query against it does not error.
|
||||
db.exec(`CREATE TABLE ItemTable (key TEXT UNIQUE, value BLOB)`)
|
||||
|
||||
const insert = db.prepare(`INSERT INTO cursorDiskKV (key, value) VALUES (?, ?)`)
|
||||
const baseTime = Date.now() - 24 * 3600 * 1000
|
||||
|
||||
for (const composerId of composerIds) {
|
||||
// Exactly one assistant bubble per composer so the test math is
|
||||
// "one composer == one call". User bubbles also produce calls in the
|
||||
// real parser (text-length token estimation), but they are not
|
||||
// necessary to exercise the workspace routing logic.
|
||||
const bubbleId = `bubbleId:${composerId}:bubble-${composerId.slice(0, 6)}`
|
||||
const bubble = {
|
||||
type: 2, // assistant
|
||||
conversationId: composerId,
|
||||
createdAt: new Date(baseTime).toISOString(),
|
||||
tokenCount: { inputTokens: 100, outputTokens: 50 },
|
||||
modelInfo: { modelName: 'claude-4.6-sonnet' },
|
||||
text: 'assistant reply for ' + composerId,
|
||||
codeBlocks: '[]',
|
||||
}
|
||||
insert.run(bubbleId, JSON.stringify(bubble))
|
||||
}
|
||||
|
||||
db.close()
|
||||
return dbPath
|
||||
}
|
||||
|
||||
/// Creates one workspaceStorage/<hash>/ subdir with workspace.json (folder URI)
|
||||
/// and state.vscdb (composer.composerData listing the supplied composerIds).
|
||||
function createWorkspaceDir(hash: string, folderUri: string, composerIds: string[]): void {
|
||||
const dir = join(userDir, 'workspaceStorage', hash)
|
||||
mkdirSync(dir, { recursive: true })
|
||||
|
||||
const wsJsonPath = join(dir, 'workspace.json')
|
||||
// We cannot do a top-level await in a sync helper; the caller writes via
|
||||
// mkdirSync above and the JSON via Node's sync writeFile shim through the
|
||||
// require'd 'fs'. Using readFileSync-friendly imports to keep this test
|
||||
// helper sync.
|
||||
const fs = requireForTest('fs') as typeof import('fs')
|
||||
fs.writeFileSync(wsJsonPath, JSON.stringify({ folder: folderUri }))
|
||||
|
||||
const wsDbPath = join(dir, 'state.vscdb')
|
||||
const { DatabaseSync: Database } = requireForTest('node:sqlite')
|
||||
const db = new Database(wsDbPath)
|
||||
db.exec(`CREATE TABLE ItemTable (key TEXT UNIQUE, value BLOB)`)
|
||||
const composerData = {
|
||||
allComposers: composerIds.map(id => ({
|
||||
composerId: id,
|
||||
name: 'session-' + id.slice(0, 6),
|
||||
unifiedMode: 'agent',
|
||||
})),
|
||||
}
|
||||
db.prepare(`INSERT INTO ItemTable (key, value) VALUES (?, ?)`).run(
|
||||
'composer.composerData',
|
||||
JSON.stringify(composerData),
|
||||
)
|
||||
db.close()
|
||||
}
|
||||
|
||||
async function collect(parser: { parse(): AsyncGenerator<ParsedProviderCall> }): Promise<ParsedProviderCall[]> {
|
||||
const out: ParsedProviderCall[] = []
|
||||
for await (const call of parser.parse()) out.push(call)
|
||||
return out
|
||||
}
|
||||
|
||||
describe('cursor provider — per-project breakdown (#196)', () => {
|
||||
it('emits one source per workspace plus an orphan source', async () => {
|
||||
if (!isSqliteAvailable()) return
|
||||
|
||||
const dbPath = createGlobalDb([
|
||||
'composer-work-1',
|
||||
'composer-work-2',
|
||||
'composer-personal-1',
|
||||
'composer-orphan-1',
|
||||
])
|
||||
createWorkspaceDir('hash-work', 'file:///Users/me/work-app', ['composer-work-1', 'composer-work-2'])
|
||||
createWorkspaceDir('hash-personal', 'file:///Users/me/personal-app', ['composer-personal-1'])
|
||||
|
||||
const provider = createCursorProvider(dbPath)
|
||||
const sources = await provider.discoverSessions()
|
||||
|
||||
const projects = sources.map(s => s.project).sort()
|
||||
expect(projects).toContain('-Users-me-work-app')
|
||||
expect(projects).toContain('-Users-me-personal-app')
|
||||
// Orphan source is labeled 'cursor' so a user with no workspaces
|
||||
// sees the same project name as before the breakdown change.
|
||||
expect(projects).toContain('cursor')
|
||||
})
|
||||
|
||||
it('routes calls to the right workspace and excludes others', async () => {
|
||||
if (!isSqliteAvailable()) return
|
||||
|
||||
const dbPath = createGlobalDb([
|
||||
'composer-work-1',
|
||||
'composer-work-2',
|
||||
'composer-personal-1',
|
||||
])
|
||||
createWorkspaceDir('hash-work', 'file:///Users/me/work-app', ['composer-work-1', 'composer-work-2'])
|
||||
createWorkspaceDir('hash-personal', 'file:///Users/me/personal-app', ['composer-personal-1'])
|
||||
|
||||
const provider = createCursorProvider(dbPath)
|
||||
const sources = await provider.discoverSessions()
|
||||
const workSource = sources.find(s => s.project === '-Users-me-work-app')!
|
||||
const personalSource = sources.find(s => s.project === '-Users-me-personal-app')!
|
||||
|
||||
const workCalls = await collect(provider.createSessionParser(workSource, new Set()))
|
||||
const personalCalls = await collect(provider.createSessionParser(personalSource, new Set()))
|
||||
|
||||
const workComposerIds = new Set(workCalls.map(c => c.sessionId))
|
||||
expect(workComposerIds).toEqual(new Set(['composer-work-1', 'composer-work-2']))
|
||||
const personalComposerIds = new Set(personalCalls.map(c => c.sessionId))
|
||||
expect(personalComposerIds).toEqual(new Set(['composer-personal-1']))
|
||||
})
|
||||
|
||||
it('orphan source captures composers not registered in any workspace', async () => {
|
||||
if (!isSqliteAvailable()) return
|
||||
|
||||
const dbPath = createGlobalDb([
|
||||
'composer-mapped',
|
||||
'composer-orphan-a',
|
||||
'composer-orphan-b',
|
||||
])
|
||||
createWorkspaceDir('hash-only', 'file:///Users/me/only-app', ['composer-mapped'])
|
||||
|
||||
const provider = createCursorProvider(dbPath)
|
||||
const sources = await provider.discoverSessions()
|
||||
const orphanSource = sources.find(s => s.project === 'cursor')!
|
||||
|
||||
const orphanCalls = await collect(provider.createSessionParser(orphanSource, new Set()))
|
||||
const ids = new Set(orphanCalls.map(c => c.sessionId))
|
||||
expect(ids).toEqual(new Set(['composer-orphan-a', 'composer-orphan-b']))
|
||||
})
|
||||
|
||||
it('totals across all sources equal totals from the legacy single-source behavior', async () => {
|
||||
if (!isSqliteAvailable()) return
|
||||
|
||||
const dbPath = createGlobalDb([
|
||||
'composer-work-1',
|
||||
'composer-personal-1',
|
||||
'composer-orphan-1',
|
||||
])
|
||||
createWorkspaceDir('hash-work', 'file:///Users/me/work-app', ['composer-work-1'])
|
||||
createWorkspaceDir('hash-personal', 'file:///Users/me/personal-app', ['composer-personal-1'])
|
||||
|
||||
const provider = createCursorProvider(dbPath)
|
||||
const sources = await provider.discoverSessions()
|
||||
|
||||
const seen = new Set<string>()
|
||||
let totalCalls = 0
|
||||
let totalCost = 0
|
||||
for (const source of sources) {
|
||||
const calls = await collect(provider.createSessionParser(source, seen))
|
||||
totalCalls += calls.length
|
||||
for (const call of calls) totalCost += call.costUSD
|
||||
}
|
||||
// Three composers, one assistant call each => three calls overall.
|
||||
expect(totalCalls).toBe(3)
|
||||
expect(totalCost).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('emits a single `cursor` source (legacy-equivalent) when no workspace mapping exists', async () => {
|
||||
if (!isSqliteAvailable()) return
|
||||
|
||||
// No createWorkspaceDir calls -> workspaceStorage exists but is empty.
|
||||
const dbPath = createGlobalDb(['composer-1', 'composer-2'])
|
||||
|
||||
const provider = createCursorProvider(dbPath)
|
||||
const sources = await provider.discoverSessions()
|
||||
expect(sources).toHaveLength(1)
|
||||
expect(sources[0]!.project).toBe('cursor')
|
||||
|
||||
const calls = await collect(provider.createSessionParser(sources[0]!, new Set()))
|
||||
// All composers fall through to the orphan/catch-all source, matching
|
||||
// the pre-PR behavior where every Cursor session showed under one row.
|
||||
const ids = new Set(calls.map(c => c.sessionId))
|
||||
expect(ids).toEqual(new Set(['composer-1', 'composer-2']))
|
||||
})
|
||||
|
||||
it('handles multi-root workspaces (workspace.json without folder) by skipping them', async () => {
|
||||
if (!isSqliteAvailable()) return
|
||||
|
||||
const dbPath = createGlobalDb(['composer-multi'])
|
||||
// Multi-root workspace: workspace.json carries `configuration` not `folder`.
|
||||
const dir = join(userDir, 'workspaceStorage', 'hash-multi')
|
||||
mkdirSync(dir, { recursive: true })
|
||||
await writeFile(
|
||||
join(dir, 'workspace.json'),
|
||||
JSON.stringify({ configuration: 'file:///path/to/.code-workspace' }),
|
||||
)
|
||||
// No state.vscdb either — multi-root composer never registers.
|
||||
|
||||
const provider = createCursorProvider(dbPath)
|
||||
const sources = await provider.discoverSessions()
|
||||
// Multi-root produces no workspace mapping; only the orphan source
|
||||
// (labeled 'cursor') remains, and it captures the multi-root composer.
|
||||
const projects = sources.map(s => s.project)
|
||||
expect(projects).toEqual(['cursor'])
|
||||
const calls = await collect(provider.createSessionParser(sources[0]!, new Set()))
|
||||
expect(calls.map(c => c.sessionId)).toEqual(['composer-multi'])
|
||||
})
|
||||
|
||||
it('sanitizes vscode-remote URIs into a slug', async () => {
|
||||
if (!isSqliteAvailable()) return
|
||||
|
||||
const dbPath = createGlobalDb(['composer-remote'])
|
||||
createWorkspaceDir(
|
||||
'hash-remote',
|
||||
'vscode-remote://wsl+Ubuntu/home/me/proj',
|
||||
['composer-remote'],
|
||||
)
|
||||
|
||||
const provider = createCursorProvider(dbPath)
|
||||
const sources = await provider.discoverSessions()
|
||||
const project = sources.find(s => s.project !== 'cursor')!.project
|
||||
// file:// would yield "-Users-me-proj"; remote URIs get the scheme rewritten.
|
||||
expect(project).toMatch(/wsl-Ubuntu/)
|
||||
expect(project).toContain('home')
|
||||
expect(project).toContain('proj')
|
||||
})
|
||||
|
||||
it('drops sub-composer rows whose composer id is not a UUID', async () => {
|
||||
if (!isSqliteAvailable()) return
|
||||
|
||||
const dbPath = globalDbPath()
|
||||
const { DatabaseSync: Database } = requireForTest('node:sqlite')
|
||||
const db = new Database(dbPath)
|
||||
db.exec(`CREATE TABLE cursorDiskKV (key TEXT PRIMARY KEY, value BLOB)`)
|
||||
db.exec(`CREATE TABLE ItemTable (key TEXT UNIQUE, value BLOB)`)
|
||||
const insert = db.prepare(`INSERT INTO cursorDiskKV (key, value) VALUES (?, ?)`)
|
||||
|
||||
// One real composer with one bubble. Real composer ids are UUIDs.
|
||||
const realComposerId = 'cccc1111-2222-3333-4444-555566667777'
|
||||
insert.run(`bubbleId:${realComposerId}:bubble-real`, JSON.stringify({
|
||||
type: 2,
|
||||
conversationId: realComposerId,
|
||||
createdAt: new Date().toISOString(),
|
||||
tokenCount: { inputTokens: 100, outputTokens: 50 },
|
||||
modelInfo: { modelName: 'claude-4.6-sonnet' },
|
||||
text: 'real',
|
||||
codeBlocks: '[]',
|
||||
}))
|
||||
// A sub-composer row mirroring the real Cursor shape: the composer
|
||||
// segment has an embedded newline and is not UUID-shaped. Must be
|
||||
// dropped, not surfaced as its own session.
|
||||
insert.run(`bubbleId:task-call_xxx\nfc_yyy:bubble-sub`, JSON.stringify({
|
||||
type: 2,
|
||||
conversationId: '',
|
||||
createdAt: new Date().toISOString(),
|
||||
tokenCount: { inputTokens: 10, outputTokens: 5 },
|
||||
modelInfo: { modelName: 'claude-4.6-sonnet' },
|
||||
text: 'sub',
|
||||
codeBlocks: '[]',
|
||||
}))
|
||||
db.close()
|
||||
|
||||
createWorkspaceDir('hash-only', 'file:///Users/me/only', [realComposerId])
|
||||
|
||||
const provider = createCursorProvider(dbPath)
|
||||
const sources = await provider.discoverSessions()
|
||||
const seen = new Set<string>()
|
||||
let allCalls = 0
|
||||
for (const source of sources) {
|
||||
const calls = await collect(provider.createSessionParser(source, seen))
|
||||
allCalls += calls.length
|
||||
}
|
||||
// One real composer -> one call. Sub-composer dropped. Total: 1.
|
||||
expect(allCalls).toBe(1)
|
||||
})
|
||||
|
||||
it('remains backwards-compatible when given a legacy bare DB path', async () => {
|
||||
if (!isSqliteAvailable()) return
|
||||
|
||||
const dbPath = createGlobalDb(['composer-legacy-1', 'composer-legacy-2'])
|
||||
createWorkspaceDir('hash-legacy', 'file:///Users/me/legacy', ['composer-legacy-1'])
|
||||
|
||||
const provider = createCursorProvider(dbPath)
|
||||
// Hand-construct a legacy SessionSource (no workspace tag) and verify
|
||||
// it still yields every call regardless of workspace mapping.
|
||||
const legacySource = { path: dbPath, project: 'cursor', provider: 'cursor' }
|
||||
const calls = await collect(provider.createSessionParser(legacySource, new Set()))
|
||||
const ids = new Set(calls.map(c => c.sessionId))
|
||||
expect(ids).toEqual(new Set(['composer-legacy-1', 'composer-legacy-2']))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,167 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||
import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { createRequire } from 'node:module'
|
||||
import { getAllProviders } from '../../src/providers/index.js'
|
||||
import { getCursorTimeFloor, createCursorProvider, clearCursorWorkspaceMapCache } from '../../src/providers/cursor.js'
|
||||
import { isSqliteAvailable } from '../../src/sqlite.js'
|
||||
import type { Provider } from '../../src/providers/types.js'
|
||||
|
||||
describe('cursor provider', () => {
|
||||
let cursorProvider: Provider
|
||||
|
||||
beforeEach(async () => {
|
||||
const all = await getAllProviders()
|
||||
cursorProvider = all.find(p => p.name === 'cursor')!
|
||||
})
|
||||
it('is registered', () => {
|
||||
expect(cursorProvider).toBeDefined()
|
||||
expect(cursorProvider.name).toBe('cursor')
|
||||
expect(cursorProvider.displayName).toBe('Cursor')
|
||||
})
|
||||
|
||||
describe('model display names', () => {
|
||||
it('maps cursor-auto to Cursor (auto) label', () => {
|
||||
expect(cursorProvider.modelDisplayName('cursor-auto')).toBe('Cursor (auto)')
|
||||
})
|
||||
|
||||
it('maps known models to readable names', () => {
|
||||
expect(cursorProvider.modelDisplayName('claude-4.5-opus-high-thinking')).toBe('Opus 4.5 (Thinking)')
|
||||
expect(cursorProvider.modelDisplayName('claude-4-sonnet-thinking')).toBe('Sonnet 4 (Thinking)')
|
||||
expect(cursorProvider.modelDisplayName('grok-code-fast-1')).toBe('Grok Code Fast')
|
||||
expect(cursorProvider.modelDisplayName('gemini-3-pro')).toBe('Gemini 3 Pro')
|
||||
expect(cursorProvider.modelDisplayName('gpt-5')).toBe('GPT-5')
|
||||
expect(cursorProvider.modelDisplayName('composer-1')).toBe('Composer 1')
|
||||
})
|
||||
|
||||
it('returns raw name for unknown models', () => {
|
||||
expect(cursorProvider.modelDisplayName('some-future-model')).toBe('some-future-model')
|
||||
})
|
||||
})
|
||||
|
||||
describe('tool display names', () => {
|
||||
it('returns raw tool name as identity', () => {
|
||||
expect(cursorProvider.toolDisplayName('some_tool')).toBe('some_tool')
|
||||
})
|
||||
})
|
||||
|
||||
describe('time floor', () => {
|
||||
it('uses dateRange.start when within the six-month cap', () => {
|
||||
const start = new Date(2026, 3, 1)
|
||||
expect(getCursorTimeFloor({ start, end: new Date(2026, 5, 2) })).toBe(start.toISOString())
|
||||
})
|
||||
})
|
||||
|
||||
describe('session discovery', () => {
|
||||
it('returns empty when sqlite is not available', async () => {
|
||||
const sessions = await cursorProvider.discoverSessions()
|
||||
expect(Array.isArray(sessions)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns empty when db does not exist', async () => {
|
||||
const sessions = await cursorProvider.discoverSessions()
|
||||
expect(sessions.every(s => s.provider === 'cursor')).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('cursor sqlite adapter', () => {
|
||||
it('reports availability', async () => {
|
||||
const { isSqliteAvailable } = await import('../../src/sqlite.js')
|
||||
const available = isSqliteAvailable()
|
||||
expect(typeof available).toBe('boolean')
|
||||
})
|
||||
|
||||
it('provides error message when not available', async () => {
|
||||
const { getSqliteLoadError } = await import('../../src/sqlite.js')
|
||||
const error = getSqliteLoadError()
|
||||
expect(typeof error).toBe('string')
|
||||
expect(error.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('cursor cache', () => {
|
||||
it('returns null when no cache exists', async () => {
|
||||
const { readCachedResults } = await import('../../src/cursor-cache.js')
|
||||
const result = await readCachedResults('/nonexistent/path.db', new Date(0).toISOString())
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
// Regression: Cursor renamed the per-workspace composer list key from
|
||||
// 'composer.composerData' to 'composer.composerHeaders'. loadWorkspaceMap must
|
||||
// read both, otherwise every composer orphans into the 'cursor' catch-all and
|
||||
// per-project attribution is lost.
|
||||
describe('cursor workspace mapping (composer.composerHeaders regression)', () => {
|
||||
const requireForTest = createRequire(import.meta.url)
|
||||
type TestDb = {
|
||||
exec(sql: string): void
|
||||
prepare(sql: string): { run(...params: unknown[]): void }
|
||||
close(): void
|
||||
}
|
||||
let root: string
|
||||
|
||||
function writeItemTableDb(dbPath: string, key: string, composerIds: string[]): void {
|
||||
const { DatabaseSync } = requireForTest('node:sqlite') as { DatabaseSync: new (p: string) => TestDb }
|
||||
const db = new DatabaseSync(dbPath)
|
||||
db.exec('CREATE TABLE ItemTable (key TEXT PRIMARY KEY, value TEXT)')
|
||||
if (key) {
|
||||
const value = JSON.stringify({ allComposers: composerIds.map(composerId => ({ composerId })) })
|
||||
db.prepare('INSERT INTO ItemTable (key, value) VALUES (?, ?)').run(key, value)
|
||||
}
|
||||
db.close()
|
||||
}
|
||||
|
||||
async function makeWorkspace(hash: string, folderUri: string, key: string, composerIds: string[]): Promise<void> {
|
||||
const wsDir = join(root, 'User', 'workspaceStorage', hash)
|
||||
await mkdir(wsDir, { recursive: true })
|
||||
await writeFile(join(wsDir, 'workspace.json'), JSON.stringify({ folder: folderUri }))
|
||||
writeItemTableDb(join(wsDir, 'state.vscdb'), key, composerIds)
|
||||
}
|
||||
|
||||
async function makeGlobalDb(): Promise<string> {
|
||||
const gsDir = join(root, 'User', 'globalStorage')
|
||||
await mkdir(gsDir, { recursive: true })
|
||||
const dbPath = join(gsDir, 'state.vscdb')
|
||||
// discoverSessions only needs the global DB to exist; the workspace map is
|
||||
// built from the sibling workspaceStorage dir.
|
||||
writeItemTableDb(dbPath, '', [])
|
||||
return dbPath
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await mkdtemp(join(tmpdir(), 'cursor-ws-test-'))
|
||||
clearCursorWorkspaceMapCache()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
clearCursorWorkspaceMapCache()
|
||||
await rm(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it.skipIf(!isSqliteAvailable())(
|
||||
'maps composers to their workspace via composer.composerHeaders (new Cursor key)',
|
||||
async () => {
|
||||
await makeWorkspace('ws-headers', 'file:///home/user/myapp', 'composer.composerHeaders', ['comp-1', 'comp-2'])
|
||||
const dbPath = await makeGlobalDb()
|
||||
|
||||
const sources = await createCursorProvider(dbPath).discoverSessions()
|
||||
const projects = sources.map(s => s.project)
|
||||
|
||||
// Before the fix these composers orphaned to the 'cursor' catch-all.
|
||||
expect(projects).toContain('-home-user-myapp')
|
||||
},
|
||||
)
|
||||
|
||||
it.skipIf(!isSqliteAvailable())(
|
||||
'still maps composers via the legacy composer.composerData key',
|
||||
async () => {
|
||||
await makeWorkspace('ws-legacy', 'file:///home/user/legacy', 'composer.composerData', ['old-1'])
|
||||
const dbPath = await makeGlobalDb()
|
||||
|
||||
const sources = await createCursorProvider(dbPath).discoverSessions()
|
||||
expect(sources.map(s => s.project)).toContain('-home-user-legacy')
|
||||
},
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,816 @@
|
||||
import { mkdir, mkdtemp, rm, writeFile } from 'fs/promises'
|
||||
import { join } from 'path'
|
||||
import { tmpdir } from 'os'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { isSqliteAvailable } from '../../src/sqlite.js'
|
||||
import { createDevinProvider } from '../../src/providers/devin.js'
|
||||
import type { ParsedProviderCall } from '../../src/providers/types.js'
|
||||
|
||||
let tmpDir: string
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await mkdtemp(join(tmpdir(), 'devin-provider-'))
|
||||
process.env['HOME'] = tmpDir
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
async function configureDevinRate(rate = 1): Promise<void> {
|
||||
await mkdir(join(tmpDir, '.config', 'codeburn'), { recursive: true })
|
||||
await writeFile(join(tmpDir, '.config', 'codeburn', 'config.json'), JSON.stringify({
|
||||
devin: { acuUsdRate: rate },
|
||||
}))
|
||||
}
|
||||
|
||||
async function writeTranscript(name: string, transcript: unknown): Promise<string> {
|
||||
const transcriptsDir = join(tmpDir, 'transcripts')
|
||||
await mkdir(transcriptsDir, { recursive: true })
|
||||
const filePath = join(transcriptsDir, name)
|
||||
await writeFile(filePath, JSON.stringify(transcript))
|
||||
return filePath
|
||||
}
|
||||
|
||||
async function parseTranscript(filePath: string, project = 'devin'): Promise<ParsedProviderCall[]> {
|
||||
const provider = createDevinProvider(tmpDir)
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of provider.createSessionParser({ path: filePath, project, provider: 'devin' }, new Set()).parse()) {
|
||||
calls.push(call)
|
||||
}
|
||||
return calls
|
||||
}
|
||||
|
||||
function createSessionsDb(): void {
|
||||
const { DatabaseSync: Database } = require('node:sqlite')
|
||||
const db = new Database(join(tmpDir, 'sessions.db'))
|
||||
db.exec(`
|
||||
CREATE TABLE sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
working_directory TEXT,
|
||||
backend_type TEXT,
|
||||
model TEXT,
|
||||
agent_mode TEXT,
|
||||
created_at INTEGER,
|
||||
last_activity_at INTEGER,
|
||||
title TEXT,
|
||||
hidden INTEGER NOT NULL DEFAULT 0
|
||||
)
|
||||
`)
|
||||
db.prepare(`
|
||||
INSERT INTO sessions (id, working_directory, model, created_at, last_activity_at, title, hidden)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`).run('db-session', '/Users/example/work/codeburn', 'claude-sonnet-4-6', 1_800_000_000, 1_800_000_010, 'CodeBurn', 0)
|
||||
db.prepare(`
|
||||
INSERT INTO sessions (id, working_directory, model, created_at, last_activity_at, title, hidden)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`).run('hidden-session', '/Users/example/work/hidden', 'claude-opus-4-6', 1_800_000_000, 1_800_000_010, 'Hidden', 1)
|
||||
db.close()
|
||||
}
|
||||
|
||||
describe('devin provider', () => {
|
||||
it('discovers Devin CLI transcript json files', async () => {
|
||||
await configureDevinRate()
|
||||
const filePath = await writeTranscript('glimmer-platinum.json', { steps: [] })
|
||||
await writeFile(join(tmpDir, 'transcripts', 'ignore.txt'), '{}')
|
||||
|
||||
const provider = createDevinProvider(tmpDir)
|
||||
const sources = await provider.discoverSessions()
|
||||
|
||||
expect(sources).toEqual([
|
||||
{ path: filePath, project: 'devin', provider: 'devin' },
|
||||
])
|
||||
})
|
||||
|
||||
it('stays disabled until the Devin ACU rate is configured', async () => {
|
||||
await writeTranscript('glimmer-platinum.json', {
|
||||
session_id: 'session-123',
|
||||
steps: [{ step_id: 's1', metadata: { committed_acu_cost: 0.5 } }],
|
||||
})
|
||||
|
||||
const provider = createDevinProvider(tmpDir)
|
||||
expect(await provider.discoverSessions()).toEqual([])
|
||||
expect(await parseTranscript(join(tmpDir, 'transcripts', 'glimmer-platinum.json'))).toEqual([])
|
||||
})
|
||||
|
||||
it('parses per-step ACUs, tokens, tools, and model resolution', async () => {
|
||||
await configureDevinRate()
|
||||
const filePath = await writeTranscript('glimmer-platinum.json', {
|
||||
schema_version: '1',
|
||||
session_id: 'session-123',
|
||||
agent: { model_name: 'agent-model' },
|
||||
steps: [
|
||||
{
|
||||
step_id: 1,
|
||||
message: 'please inspect the repo',
|
||||
metadata: { is_user_input: true, created_at: '2027-01-15T08:00:00.000Z' },
|
||||
},
|
||||
{
|
||||
step_id: 2,
|
||||
model_name: 'step-model',
|
||||
metadata: {
|
||||
created_at: '2027-01-15T08:00:01.000Z',
|
||||
committed_acu_cost: 0.02076149918138981,
|
||||
generation_model: 'claude-opus-4-6',
|
||||
metrics: {
|
||||
input_tokens: 100,
|
||||
output_tokens: 20,
|
||||
cache_creation_tokens: 10,
|
||||
cache_read_tokens: 5,
|
||||
},
|
||||
},
|
||||
tool_calls: [{ function_name: 'read_file' }],
|
||||
},
|
||||
{
|
||||
step_id: 3,
|
||||
model_name: 'claude-sonnet-4-6',
|
||||
metadata: {
|
||||
created_at: '2027-01-15T08:00:02.000Z',
|
||||
committed_acu_cost: 0.005421000067144632,
|
||||
metrics: { input_tokens: 1 },
|
||||
},
|
||||
tool_calls: [{ function_name: 'str_replace' }],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const calls = await parseTranscript(filePath)
|
||||
|
||||
expect(calls).toHaveLength(2)
|
||||
expect(calls.reduce((sum, call) => sum + call.costUSD, 0)).toBeCloseTo(0.026182499248534442, 15)
|
||||
expect(calls[0]).toMatchObject({
|
||||
provider: 'devin',
|
||||
model: 'Opus 4.6',
|
||||
inputTokens: 100,
|
||||
outputTokens: 20,
|
||||
cacheCreationInputTokens: 10,
|
||||
cacheReadInputTokens: 5,
|
||||
cachedInputTokens: 5,
|
||||
costUSD: 0.02076149918138981,
|
||||
tools: ['read_file'],
|
||||
timestamp: '2027-01-15T08:00:01.000Z',
|
||||
deduplicationKey: 'devin:session-123:2',
|
||||
userMessage: 'please inspect the repo',
|
||||
sessionId: 'session-123',
|
||||
})
|
||||
expect(calls[1]).toMatchObject({
|
||||
model: 'Sonnet 4.6',
|
||||
timestamp: '2027-01-15T08:00:02.000Z',
|
||||
tools: ['str_replace'],
|
||||
deduplicationKey: 'devin:session-123:3',
|
||||
})
|
||||
})
|
||||
|
||||
it('renders Devin generation_model variants as friendly display names with effort tiers', async () => {
|
||||
await configureDevinRate()
|
||||
const cases = [
|
||||
{
|
||||
schema: '1.4',
|
||||
modelName: 'GPT-5.4',
|
||||
location: 'metadata',
|
||||
generationModel: 'gpt-5-3-codex-xhigh',
|
||||
expected: 'GPT-5.3 Codex (xhigh)',
|
||||
},
|
||||
{
|
||||
schema: '1.4',
|
||||
modelName: 'GPT-5.5',
|
||||
location: 'metadata',
|
||||
generationModel: 'gpt-5-4-low',
|
||||
expected: 'GPT-5.4 (low)',
|
||||
},
|
||||
{
|
||||
schema: '1.4',
|
||||
modelName: 'GPT-5.5',
|
||||
location: 'metadata',
|
||||
generationModel: 'gpt-5-5-medium',
|
||||
expected: 'GPT-5.5 (medium)',
|
||||
},
|
||||
{
|
||||
schema: '1.4',
|
||||
modelName: 'Gemini 3 Flash',
|
||||
location: 'metadata',
|
||||
generationModel: 'MODEL_PRIVATE_11',
|
||||
expected: 'Gemini 3 Flash',
|
||||
},
|
||||
{
|
||||
schema: '1.4',
|
||||
modelName: 'Gemini 3 Flash',
|
||||
location: 'metadata',
|
||||
generationModel: 'MODEL_GOOGLE_GEMINI_3_0_FLASH_MINIMAL',
|
||||
expected: 'Gemini 3 Flash',
|
||||
},
|
||||
{
|
||||
schema: '1.7',
|
||||
modelName: 'GPT-5.3-Codex',
|
||||
location: 'extra',
|
||||
generationModel: 'gpt-5-3-codex-xhigh',
|
||||
expected: 'GPT-5.3 Codex (xhigh)',
|
||||
},
|
||||
{
|
||||
schema: '1.4',
|
||||
modelName: 'GPT-5',
|
||||
location: 'metadata',
|
||||
generationModel: 'gpt-5',
|
||||
expected: 'GPT-5',
|
||||
},
|
||||
{
|
||||
schema: '1.4',
|
||||
modelName: 'GPT-5.6',
|
||||
location: 'metadata',
|
||||
generationModel: 'gpt-5-6-medium',
|
||||
expected: 'GPT-5.6 (medium)',
|
||||
},
|
||||
{
|
||||
schema: '1.4',
|
||||
modelName: 'GPT-5',
|
||||
location: 'metadata',
|
||||
generationModel: 'gpt-5-codex-xhigh',
|
||||
expected: 'GPT-5 Codex (xhigh)',
|
||||
},
|
||||
{
|
||||
schema: '1.4',
|
||||
modelName: 'GPT-5',
|
||||
location: 'metadata',
|
||||
generationModel: 'gpt-5-codex',
|
||||
expected: 'GPT-5 Codex',
|
||||
},
|
||||
{
|
||||
schema: '1.4',
|
||||
modelName: 'GPT-4',
|
||||
location: 'metadata',
|
||||
generationModel: 'gpt-4-1106-preview',
|
||||
expected: 'gpt-4-1106-preview',
|
||||
},
|
||||
] as const
|
||||
|
||||
for (let index = 0; index < cases.length; index++) {
|
||||
const row = cases[index]!
|
||||
const metadata: Record<string, unknown> = {
|
||||
created_at: '2027-01-15T08:00:01.000Z',
|
||||
committed_acu_cost: 0.1,
|
||||
metrics: { input_tokens: 1 },
|
||||
}
|
||||
const extra: Record<string, unknown> = {}
|
||||
if (row.location === 'metadata') {
|
||||
metadata['generation_model'] = row.generationModel
|
||||
} else {
|
||||
extra['generation_model'] = row.generationModel
|
||||
}
|
||||
|
||||
const step: Record<string, unknown> = {
|
||||
step_id: index + 1,
|
||||
source: 'assistant',
|
||||
message: 'working',
|
||||
metadata,
|
||||
}
|
||||
if (row.location === 'extra') step['extra'] = extra
|
||||
|
||||
const filePath = await writeTranscript(`model-variant-${index}.json`, {
|
||||
schema_version: row.schema,
|
||||
session_id: `model-variant-${index}`,
|
||||
agent: { model_name: row.modelName },
|
||||
steps: [step],
|
||||
})
|
||||
|
||||
const calls = await parseTranscript(filePath)
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]!.model).toBe(row.expected)
|
||||
}
|
||||
})
|
||||
|
||||
it('leaves already-friendly Devin model display names unchanged', () => {
|
||||
const provider = createDevinProvider(tmpDir)
|
||||
|
||||
expect(provider.modelDisplayName('GPT-5.3 Codex (xhigh)')).toBe('GPT-5.3 Codex (xhigh)')
|
||||
})
|
||||
|
||||
it('includes token-only steps and skips user-input or empty steps', async () => {
|
||||
await configureDevinRate()
|
||||
const filePath = await writeTranscript('token-only.json', {
|
||||
session_id: 'token-session',
|
||||
agent: { model_name: 'agent-model' },
|
||||
steps: [
|
||||
{
|
||||
step_id: 'user-cost',
|
||||
metadata: {
|
||||
is_user_input: true,
|
||||
committed_acu_cost: 99,
|
||||
metrics: { input_tokens: 99 },
|
||||
},
|
||||
},
|
||||
{ step_id: 'empty', metadata: { created_at: '2026-06-05T10:00:00.000Z' } },
|
||||
{
|
||||
step_id: 'tokens',
|
||||
metadata: {
|
||||
created_at: '2026-06-05T10:00:01.000Z',
|
||||
metrics: { output_tokens: 42 },
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const calls = await parseTranscript(filePath)
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]!.model).toBe('agent-model')
|
||||
expect(calls[0]!.outputTokens).toBe(42)
|
||||
expect(calls[0]!.costUSD).toBe(0)
|
||||
})
|
||||
|
||||
it('converts ACUs to costUSD using the configured Devin rate', async () => {
|
||||
await configureDevinRate(2.5)
|
||||
const filePath = await writeTranscript('configured-rate.json', {
|
||||
session_id: 'configured-rate',
|
||||
agent: { model_name: 'agent-model' },
|
||||
steps: [
|
||||
{ step_id: 's1', metadata: { committed_acu_cost: 0.4 } },
|
||||
],
|
||||
})
|
||||
|
||||
const calls = await parseTranscript(filePath)
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]!.costUSD).toBeCloseTo(1, 12)
|
||||
})
|
||||
|
||||
it('falls back to filename session id and deduplicates by step id', async () => {
|
||||
await configureDevinRate()
|
||||
const filePath = await writeTranscript('fallback-session.json', {
|
||||
steps: [
|
||||
{
|
||||
step_id: 1,
|
||||
metadata: {
|
||||
request_id: 'req-1',
|
||||
committed_acu_cost: 0.1,
|
||||
},
|
||||
},
|
||||
{
|
||||
step_id: 2,
|
||||
metadata: {
|
||||
created_at: '2026-06-05T10:00:00.000Z',
|
||||
committed_acu_cost: 0.2,
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const calls = await parseTranscript(filePath)
|
||||
|
||||
expect(calls.map(c => c.sessionId)).toEqual(['fallback-session', 'fallback-session'])
|
||||
expect(calls.map(c => c.model)).toEqual(['devin', 'devin'])
|
||||
expect(calls.map(c => c.deduplicationKey)).toEqual([
|
||||
'devin:fallback-session:1',
|
||||
'devin:fallback-session:2',
|
||||
])
|
||||
})
|
||||
|
||||
it('extracts user message from ContentPart[] messages (ATIF v1.7 multimodal)', async () => {
|
||||
await configureDevinRate()
|
||||
const filePath = await writeTranscript('content-parts.json', {
|
||||
session_id: 'cp-session',
|
||||
agent: { model_name: 'agent-model' },
|
||||
steps: [
|
||||
{
|
||||
step_id: 1,
|
||||
message: [
|
||||
{ type: 'text', text: 'look at this screenshot' },
|
||||
{ type: 'image', source: { media_type: 'image/png', path: '/tmp/screenshot.png' } },
|
||||
],
|
||||
metadata: { is_user_input: true, created_at: '2027-01-15T08:00:00.000Z' },
|
||||
},
|
||||
{
|
||||
step_id: 2,
|
||||
metadata: {
|
||||
created_at: '2027-01-15T08:00:01.000Z',
|
||||
committed_acu_cost: 0.1,
|
||||
metrics: { input_tokens: 50 },
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const calls = await parseTranscript(filePath)
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]!.userMessage).toBe('look at this screenshot /tmp/screenshot.png')
|
||||
})
|
||||
|
||||
it('parses ATIF v1.7 transcripts with agent.extra, final_metrics, and observations', async () => {
|
||||
await configureDevinRate()
|
||||
const filePath = await writeTranscript('atif-v17.json', {
|
||||
schema_version: '1.7',
|
||||
session_id: 'v17-session',
|
||||
agent: {
|
||||
name: 'devin',
|
||||
version: '2.0',
|
||||
model_name: 'claude-sonnet-4-6',
|
||||
extra: { backend: 'cloud', permission_mode: 'auto' },
|
||||
},
|
||||
final_metrics: {
|
||||
total_prompt_tokens: 500,
|
||||
total_completion_tokens: 200,
|
||||
total_cached_tokens: 50,
|
||||
total_steps: 2,
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
step_id: 1,
|
||||
message: 'fix the bug',
|
||||
metadata: { is_user_input: true, created_at: '2027-01-15T08:00:00.000Z' },
|
||||
},
|
||||
{
|
||||
step_id: 2,
|
||||
source: 'assistant',
|
||||
model_name: 'claude-sonnet-4-6',
|
||||
message: 'I will read the file first',
|
||||
tool_calls: [{ tool_call_id: 'tc1', function_name: 'read_file', arguments: { path: 'src/main.ts' } }],
|
||||
observation: {
|
||||
results: [{ source_call_id: 'tc1', content: 'file contents here' }],
|
||||
},
|
||||
extra: {
|
||||
committed_acu_cost: 0.15,
|
||||
generation_model: 'claude-sonnet-4-6',
|
||||
telemetry: { source: 'devin-cli', operation: 'generate' },
|
||||
},
|
||||
metadata: {
|
||||
created_at: '2027-01-15T08:00:01.000Z',
|
||||
committed_acu_cost: 0.15,
|
||||
metrics: { input_tokens: 200, output_tokens: 50, cache_creation_tokens: 20, cache_read_tokens: 10 },
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const calls = await parseTranscript(filePath)
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]).toMatchObject({
|
||||
provider: 'devin',
|
||||
model: 'Sonnet 4.6',
|
||||
inputTokens: 200,
|
||||
outputTokens: 50,
|
||||
cacheCreationInputTokens: 20,
|
||||
cacheReadInputTokens: 10,
|
||||
costUSD: 0.15,
|
||||
tools: ['read_file'],
|
||||
userMessage: 'fix the bug',
|
||||
sessionId: 'v17-session',
|
||||
})
|
||||
})
|
||||
|
||||
it('handles plain string user messages alongside ContentPart[] messages', async () => {
|
||||
await configureDevinRate()
|
||||
const filePath = await writeTranscript('mixed-messages.json', {
|
||||
session_id: 'mixed-msg-session',
|
||||
agent: { model_name: 'agent-model' },
|
||||
steps: [
|
||||
{
|
||||
step_id: 1,
|
||||
message: 'plain text user message',
|
||||
metadata: { is_user_input: true, created_at: '2027-01-15T08:00:00.000Z' },
|
||||
},
|
||||
{
|
||||
step_id: 2,
|
||||
metadata: {
|
||||
created_at: '2027-01-15T08:00:01.000Z',
|
||||
committed_acu_cost: 0.1,
|
||||
metrics: { input_tokens: 50 },
|
||||
},
|
||||
},
|
||||
{
|
||||
step_id: 3,
|
||||
message: [{ type: 'text', text: 'multimodal user message' }],
|
||||
metadata: { is_user_input: true, created_at: '2027-01-15T08:00:02.000Z' },
|
||||
},
|
||||
{
|
||||
step_id: 4,
|
||||
metadata: {
|
||||
created_at: '2027-01-15T08:00:03.000Z',
|
||||
committed_acu_cost: 0.2,
|
||||
metrics: { input_tokens: 100 },
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const calls = await parseTranscript(filePath)
|
||||
|
||||
expect(calls).toHaveLength(2)
|
||||
expect(calls[0]!.userMessage).toBe('plain text user message')
|
||||
expect(calls[1]!.userMessage).toBe('multimodal user message')
|
||||
})
|
||||
|
||||
it('reads ACU cost from step.extra when metadata.committed_acu_cost is absent', async () => {
|
||||
await configureDevinRate()
|
||||
const filePath = await writeTranscript('extra-acu.json', {
|
||||
session_id: 'extra-acu-session',
|
||||
agent: { model_name: 'agent-model' },
|
||||
steps: [
|
||||
{
|
||||
step_id: 1,
|
||||
extra: { committed_acu_cost: 0.3 },
|
||||
metadata: {
|
||||
created_at: '2027-01-15T08:00:00.000Z',
|
||||
metrics: { input_tokens: 10 },
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const calls = await parseTranscript(filePath)
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]!.costUSD).toBeCloseTo(0.3, 12)
|
||||
})
|
||||
|
||||
it('prefers metadata.committed_acu_cost over extra.committed_acu_cost', async () => {
|
||||
await configureDevinRate()
|
||||
const filePath = await writeTranscript('acu-priority.json', {
|
||||
session_id: 'acu-priority-session',
|
||||
agent: { model_name: 'agent-model' },
|
||||
steps: [
|
||||
{
|
||||
step_id: 1,
|
||||
extra: { committed_acu_cost: 0.99 },
|
||||
metadata: {
|
||||
created_at: '2027-01-15T08:00:00.000Z',
|
||||
committed_acu_cost: 0.11,
|
||||
metrics: { input_tokens: 10 },
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const calls = await parseTranscript(filePath)
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]!.costUSD).toBeCloseTo(0.11, 12)
|
||||
})
|
||||
|
||||
it('reads tokens from step.metrics when metadata.metrics is absent', async () => {
|
||||
await configureDevinRate()
|
||||
const filePath = await writeTranscript('step-metrics.json', {
|
||||
session_id: 'step-metrics-session',
|
||||
agent: { model_name: 'agent-model' },
|
||||
steps: [
|
||||
{
|
||||
step_id: 1,
|
||||
metrics: {
|
||||
prompt_tokens: 300,
|
||||
completion_tokens: 75,
|
||||
cached_tokens: 15,
|
||||
extra: { cache_creation_input_tokens: 25 },
|
||||
},
|
||||
extra: { committed_acu_cost: 0.2 },
|
||||
metadata: { created_at: '2027-01-15T08:00:00.000Z' },
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const calls = await parseTranscript(filePath)
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]).toMatchObject({
|
||||
inputTokens: 300,
|
||||
outputTokens: 75,
|
||||
cacheCreationInputTokens: 25,
|
||||
cacheReadInputTokens: 15,
|
||||
cachedInputTokens: 15,
|
||||
costUSD: 0.2,
|
||||
})
|
||||
})
|
||||
|
||||
it('prefers step.metrics over metadata.metrics when both are present', async () => {
|
||||
await configureDevinRate()
|
||||
const filePath = await writeTranscript('metrics-priority.json', {
|
||||
session_id: 'metrics-priority-session',
|
||||
agent: { model_name: 'agent-model' },
|
||||
steps: [
|
||||
{
|
||||
step_id: 1,
|
||||
metrics: {
|
||||
prompt_tokens: 500,
|
||||
completion_tokens: 100,
|
||||
cached_tokens: 20,
|
||||
extra: { cache_creation_input_tokens: 30 },
|
||||
},
|
||||
metadata: {
|
||||
created_at: '2027-01-15T08:00:00.000Z',
|
||||
committed_acu_cost: 0.1,
|
||||
metrics: {
|
||||
input_tokens: 1,
|
||||
output_tokens: 1,
|
||||
cache_creation_tokens: 1,
|
||||
cache_read_tokens: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const calls = await parseTranscript(filePath)
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]).toMatchObject({
|
||||
inputTokens: 500,
|
||||
outputTokens: 100,
|
||||
cacheCreationInputTokens: 30,
|
||||
cacheReadInputTokens: 20,
|
||||
cachedInputTokens: 20,
|
||||
})
|
||||
})
|
||||
|
||||
it('handles observation results with ContentPart[] content', async () => {
|
||||
await configureDevinRate()
|
||||
const filePath = await writeTranscript('observation-content-parts.json', {
|
||||
session_id: 'obs-cp-session',
|
||||
agent: { model_name: 'agent-model' },
|
||||
steps: [
|
||||
{
|
||||
step_id: 1,
|
||||
message: 'check the image',
|
||||
metadata: { is_user_input: true, created_at: '2027-01-15T08:00:00.000Z' },
|
||||
},
|
||||
{
|
||||
step_id: 2,
|
||||
source: 'assistant',
|
||||
message: 'reading file',
|
||||
tool_calls: [{ tool_call_id: 'tc1', function_name: 'read_file', arguments: {} }],
|
||||
observation: {
|
||||
results: [{
|
||||
source_call_id: 'tc1',
|
||||
content: [
|
||||
{ type: 'text', text: 'file output here' },
|
||||
{ type: 'image', source: { media_type: 'image/png', path: '/tmp/output.png' } },
|
||||
],
|
||||
}],
|
||||
},
|
||||
metadata: {
|
||||
created_at: '2027-01-15T08:00:01.000Z',
|
||||
committed_acu_cost: 0.1,
|
||||
metrics: { input_tokens: 100, output_tokens: 30 },
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const calls = await parseTranscript(filePath)
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]).toMatchObject({
|
||||
tools: ['read_file'],
|
||||
costUSD: 0.1,
|
||||
inputTokens: 100,
|
||||
outputTokens: 30,
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to metadata.metrics when step.metrics is present but empty', async () => {
|
||||
await configureDevinRate()
|
||||
const filePath = await writeTranscript('empty-step-metrics.json', {
|
||||
session_id: 'empty-metrics-session',
|
||||
agent: { model_name: 'agent-model' },
|
||||
steps: [
|
||||
{
|
||||
step_id: 1,
|
||||
metrics: {},
|
||||
metadata: {
|
||||
created_at: '2027-01-15T08:00:00.000Z',
|
||||
committed_acu_cost: 0.1,
|
||||
metrics: { input_tokens: 80, output_tokens: 20, cache_read_tokens: 5 },
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const calls = await parseTranscript(filePath)
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]).toMatchObject({
|
||||
inputTokens: 80,
|
||||
outputTokens: 20,
|
||||
cacheReadInputTokens: 5,
|
||||
costUSD: 0.1,
|
||||
})
|
||||
})
|
||||
|
||||
it('normalizes an image-only ContentPart[] user message to its path', async () => {
|
||||
await configureDevinRate()
|
||||
const filePath = await writeTranscript('image-only.json', {
|
||||
session_id: 'image-only-session',
|
||||
agent: { model_name: 'agent-model' },
|
||||
steps: [
|
||||
{
|
||||
step_id: 1,
|
||||
message: [
|
||||
{ type: 'image', source: { media_type: 'image/png', path: '/tmp/only.png' } },
|
||||
],
|
||||
metadata: { is_user_input: true, created_at: '2027-01-15T08:00:00.000Z' },
|
||||
},
|
||||
{
|
||||
step_id: 2,
|
||||
metadata: {
|
||||
created_at: '2027-01-15T08:00:01.000Z',
|
||||
committed_acu_cost: 0.1,
|
||||
metrics: { input_tokens: 40 },
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const calls = await parseTranscript(filePath)
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]!.userMessage).toBe('/tmp/only.png')
|
||||
})
|
||||
|
||||
it('ignores array-root and malformed transcripts', async () => {
|
||||
await configureDevinRate()
|
||||
const arrayPath = await writeTranscript('array.json', [])
|
||||
const malformedPath = join(tmpDir, 'transcripts', 'bad.json')
|
||||
await writeFile(malformedPath, '{')
|
||||
|
||||
expect(await parseTranscript(arrayPath)).toEqual([])
|
||||
expect(await parseTranscript(malformedPath)).toEqual([])
|
||||
})
|
||||
|
||||
it('deduplicates calls with a shared seen key set', async () => {
|
||||
await configureDevinRate()
|
||||
const filePath = await writeTranscript('dupe.json', {
|
||||
session_id: 'dupe-session',
|
||||
steps: [{ step_id: 's1', metadata: { committed_acu_cost: 0.5 } }],
|
||||
})
|
||||
const provider = createDevinProvider(tmpDir)
|
||||
const seenKeys = new Set<string>()
|
||||
const source = { path: filePath, project: 'devin', provider: 'devin' }
|
||||
|
||||
const first: ParsedProviderCall[] = []
|
||||
for await (const call of provider.createSessionParser(source, seenKeys).parse()) first.push(call)
|
||||
const second: ParsedProviderCall[] = []
|
||||
for await (const call of provider.createSessionParser(source, seenKeys).parse()) second.push(call)
|
||||
|
||||
expect(first).toHaveLength(1)
|
||||
expect(second).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
const skipUnlessSqlite = isSqliteAvailable() ? describe : describe.skip
|
||||
|
||||
skipUnlessSqlite('devin provider sessions.db enrichment', () => {
|
||||
it('uses sessions.db to enrich project, projectPath, model, and timestamp fallbacks', async () => {
|
||||
await configureDevinRate()
|
||||
createSessionsDb()
|
||||
const filePath = await writeTranscript('db-session.json', {
|
||||
session_id: 'db-session',
|
||||
steps: [
|
||||
{
|
||||
step_id: 's1',
|
||||
metadata: {
|
||||
committed_acu_cost: 0.25,
|
||||
metrics: { input_tokens: 10 },
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const calls = await parseTranscript(filePath, 'fallback-project')
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]).toMatchObject({
|
||||
model: 'Sonnet 4.6',
|
||||
project: 'codeburn',
|
||||
projectPath: '/Users/example/work/codeburn',
|
||||
timestamp: '2027-01-15T08:00:10.000Z',
|
||||
costUSD: 0.25,
|
||||
})
|
||||
})
|
||||
|
||||
it('uses sessions.db project labels during discovery when transcript filename matches the session id', async () => {
|
||||
await configureDevinRate()
|
||||
createSessionsDb()
|
||||
const filePath = await writeTranscript('db-session.json', { session_id: 'db-session', steps: [] })
|
||||
|
||||
const provider = createDevinProvider(tmpDir)
|
||||
const sources = await provider.discoverSessions()
|
||||
|
||||
expect(sources).toEqual([
|
||||
{ path: filePath, project: 'codeburn', provider: 'devin' },
|
||||
])
|
||||
})
|
||||
|
||||
it('skips sessions hidden in sessions.db', async () => {
|
||||
await configureDevinRate()
|
||||
createSessionsDb()
|
||||
await writeTranscript('hidden-session.json', {
|
||||
session_id: 'hidden-session',
|
||||
steps: [{ step_id: 's1', metadata: { committed_acu_cost: 0.25 } }],
|
||||
})
|
||||
|
||||
const provider = createDevinProvider(tmpDir)
|
||||
expect(await provider.discoverSessions()).toEqual([])
|
||||
|
||||
const calls = await parseTranscript(join(tmpDir, 'transcripts', 'hidden-session.json'))
|
||||
expect(calls).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,148 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||
import { mkdtemp, mkdir, writeFile, rm } from 'fs/promises'
|
||||
import { join } from 'path'
|
||||
import { tmpdir } from 'os'
|
||||
|
||||
import { createDroidProvider } from '../../src/providers/droid.js'
|
||||
import type { ParsedProviderCall } from '../../src/providers/types.js'
|
||||
|
||||
let factoryDir: string
|
||||
|
||||
async function writeSession(opts: {
|
||||
projectDir?: string
|
||||
sessionId?: string
|
||||
lines?: unknown[]
|
||||
settings?: unknown
|
||||
subdir?: string
|
||||
}): Promise<string> {
|
||||
const sessionId = opts.sessionId ?? 'session-1'
|
||||
const projectDir = opts.projectDir ?? '/tmp/my-project'
|
||||
const subdir = opts.subdir ?? '-tmp-my-project'
|
||||
const dir = join(factoryDir, 'sessions', subdir)
|
||||
await mkdir(dir, { recursive: true })
|
||||
const jsonlPath = join(dir, `${sessionId}.jsonl`)
|
||||
const lines = opts.lines ?? [
|
||||
{ type: 'session_start', id: sessionId, cwd: projectDir, title: 'Test session' },
|
||||
{ type: 'message', id: 'u1', timestamp: '2026-04-20T10:00:00.000Z', message: { role: 'user', content: [{ type: 'text', text: 'build this' }] } },
|
||||
{ type: 'message', id: 'a1', timestamp: '2026-04-20T10:00:01.000Z', message: { role: 'assistant', content: [{ type: 'text', text: 'done' }] } },
|
||||
]
|
||||
await writeFile(jsonlPath, lines.map(line => JSON.stringify(line)).join('\n'))
|
||||
|
||||
if (opts.settings !== undefined) {
|
||||
await writeFile(join(dir, `${sessionId}.settings.json`), JSON.stringify(opts.settings))
|
||||
}
|
||||
|
||||
return jsonlPath
|
||||
}
|
||||
|
||||
async function parseAll(filePath: string, seen = new Set<string>()): Promise<ParsedProviderCall[]> {
|
||||
const provider = createDroidProvider(factoryDir)
|
||||
const parser = provider.createSessionParser({ path: filePath, project: 'proj', provider: 'droid' }, seen)
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of parser.parse()) calls.push(call)
|
||||
return calls
|
||||
}
|
||||
|
||||
describe('droid provider', () => {
|
||||
beforeEach(async () => {
|
||||
factoryDir = await mkdtemp(join(tmpdir(), 'codeburn-droid-test-'))
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(factoryDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('discovers Droid JSONL sessions', async () => {
|
||||
await writeSession({ settings: { model: 'gpt-5', tokenUsage: { inputTokens: 10, outputTokens: 5, cacheCreationTokens: 0, cacheReadTokens: 0, thinkingTokens: 0 } } })
|
||||
|
||||
const provider = createDroidProvider(factoryDir)
|
||||
const sessions = await provider.discoverSessions()
|
||||
|
||||
expect(sessions).toHaveLength(1)
|
||||
expect(sessions[0]!.provider).toBe('droid')
|
||||
expect(sessions[0]!.path.endsWith('session-1.jsonl')).toBe(true)
|
||||
})
|
||||
|
||||
it('parses calls and distributes session-level token usage', async () => {
|
||||
const path = await writeSession({
|
||||
lines: [
|
||||
{ type: 'session_start', id: 'session-1', cwd: '/tmp/my-project' },
|
||||
{ type: 'message', id: 'u1', timestamp: '2026-04-20T10:00:00.000Z', message: { role: 'user', content: [{ type: 'text', text: '<system-reminder>x</system-reminder>' }, { type: 'text', text: 'build this' }] } },
|
||||
{ type: 'message', id: 'a1', timestamp: '2026-04-20T10:00:01.000Z', message: { role: 'assistant', content: [{ type: 'text', text: 'first' }] } },
|
||||
{ type: 'message', id: 'a2', timestamp: '2026-04-20T10:00:02.000Z', message: { role: 'assistant', content: [{ type: 'text', text: 'second' }] } },
|
||||
],
|
||||
settings: { model: 'custom:gpt-5-[Proxy]-0', tokenUsage: { inputTokens: 101, outputTokens: 51, cacheCreationTokens: 7, cacheReadTokens: 11, thinkingTokens: 5 } },
|
||||
})
|
||||
|
||||
const calls = await parseAll(path)
|
||||
|
||||
expect(calls).toHaveLength(2)
|
||||
expect(calls[0]!.provider).toBe('droid')
|
||||
expect(calls[0]!.model).toBe('gpt-5')
|
||||
expect(calls[0]!.inputTokens).toBe(50)
|
||||
expect(calls[1]!.inputTokens).toBe(51)
|
||||
expect(calls[0]!.outputTokens).toBe(25)
|
||||
expect(calls[1]!.outputTokens).toBe(26)
|
||||
expect(calls[0]!.cacheReadInputTokens).toBe(5)
|
||||
expect(calls[1]!.cacheReadInputTokens).toBe(6)
|
||||
expect(calls[0]!.userMessage).toBe('build this')
|
||||
expect(calls[0]!.sessionId).toBe('session-1')
|
||||
})
|
||||
|
||||
it('extracts tools and meaningful bash command names', async () => {
|
||||
const path = await writeSession({
|
||||
lines: [
|
||||
{ type: 'session_start', id: 'session-1', cwd: '/tmp/my-project' },
|
||||
{ type: 'message', id: 'a1', timestamp: '2026-04-20T10:00:01.000Z', message: { role: 'assistant', content: [
|
||||
{ type: 'tool_use', name: 'Execute', input: { command: "python3 - <<'PY'\nimport os\n}\nPY" } },
|
||||
{ type: 'tool_use', name: 'Read', input: { file_path: '/tmp/a' } },
|
||||
{ type: 'tool_use', name: 'Task', input: { prompt: 'do work' } },
|
||||
] } },
|
||||
],
|
||||
settings: { model: 'gpt-5', tokenUsage: { inputTokens: 10, outputTokens: 5, cacheCreationTokens: 0, cacheReadTokens: 0, thinkingTokens: 0 } },
|
||||
})
|
||||
|
||||
const calls = await parseAll(path)
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]!.tools).toEqual(['Bash', 'Read', 'Agent'])
|
||||
expect(calls[0]!.bashCommands).toContain('python3')
|
||||
expect(calls[0]!.bashCommands).not.toContain('import')
|
||||
expect(calls[0]!.bashCommands).not.toContain('}')
|
||||
})
|
||||
|
||||
it('deduplicates calls by session and message id', async () => {
|
||||
const path = await writeSession({ settings: { model: 'gpt-5', tokenUsage: { inputTokens: 10, outputTokens: 5, cacheCreationTokens: 0, cacheReadTokens: 0, thinkingTokens: 0 } } })
|
||||
const seen = new Set<string>()
|
||||
|
||||
expect(await parseAll(path, seen)).toHaveLength(1)
|
||||
expect(await parseAll(path, seen)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('strips Droid model wrappers for display', () => {
|
||||
const provider = createDroidProvider(factoryDir)
|
||||
expect(provider.modelDisplayName('custom:GLM-5.1-[Proxy]-0')).toBe('GLM-5.1')
|
||||
expect(provider.modelDisplayName('custom:claude-sonnet-4-6-1')).toBe('Sonnet 4.6')
|
||||
})
|
||||
|
||||
it('returns no calls when settings are missing', async () => {
|
||||
const path = await writeSession({})
|
||||
expect(await parseAll(path)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('skips internal .factory sessions during discovery', async () => {
|
||||
await writeSession({ projectDir: factoryDir, subdir: '-internal', settings: { model: 'gpt-5', tokenUsage: { inputTokens: 10, outputTokens: 5, cacheCreationTokens: 0, cacheReadTokens: 0, thinkingTokens: 0 } } })
|
||||
|
||||
const provider = createDroidProvider(factoryDir)
|
||||
expect(await provider.discoverSessions()).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('returns no calls for empty sessions', async () => {
|
||||
const path = await writeSession({
|
||||
lines: [{ type: 'session_start', id: 'empty', cwd: '/tmp/my-project' }],
|
||||
settings: { model: 'gpt-5', tokenUsage: { inputTokens: 10, outputTokens: 5, cacheCreationTokens: 0, cacheReadTokens: 0, thinkingTokens: 0 } },
|
||||
})
|
||||
|
||||
expect(await parseAll(path)).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,275 @@
|
||||
import { mkdtemp, readFile, rm } from 'fs/promises'
|
||||
import { join } from 'path'
|
||||
import { tmpdir } from 'os'
|
||||
import { createRequire } from 'node:module'
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { isSqliteAvailable } from '../../src/sqlite.js'
|
||||
import { createForgeProvider } from '../../src/providers/forge.js'
|
||||
import { getProvider } from '../../src/providers/index.js'
|
||||
import type { ParsedProviderCall } from '../../src/providers/types.js'
|
||||
|
||||
const requireForTest = createRequire(import.meta.url)
|
||||
|
||||
type TestDb = {
|
||||
exec(sql: string): void
|
||||
prepare(sql: string): { run(...params: unknown[]): void }
|
||||
close(): void
|
||||
}
|
||||
|
||||
let tmpRoot: string
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpRoot = await mkdtemp(join(tmpdir(), 'forge-test-'))
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tmpRoot, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function createForgeDb(): string {
|
||||
const dbPath = join(tmpRoot, '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()
|
||||
}
|
||||
}
|
||||
|
||||
function insertConversationRow(db: TestDb, overrides: {
|
||||
conversationId?: string
|
||||
title?: string | null
|
||||
workspaceId?: number | string
|
||||
context?: string | null
|
||||
createdAt?: string
|
||||
updatedAt?: string | null
|
||||
} = {}): void {
|
||||
db.prepare(`
|
||||
INSERT INTO conversations (conversation_id, title, workspace_id, context, created_at, updated_at, metrics)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
overrides.conversationId ?? 'conv-1',
|
||||
'title' in overrides ? overrides.title : 'Forge Project',
|
||||
overrides.workspaceId ?? 123,
|
||||
overrides.context ?? null,
|
||||
overrides.createdAt ?? '2026-05-06 15:00:00',
|
||||
'updatedAt' in overrides ? overrides.updatedAt : '2026-05-06 15:20:41.379094',
|
||||
null,
|
||||
)
|
||||
}
|
||||
|
||||
function insertConversation(db: TestDb): void {
|
||||
const context = {
|
||||
conversation_id: 'conv-1',
|
||||
messages: [
|
||||
{ message: { text: { role: 'User', content: 'implement forge' } } },
|
||||
{
|
||||
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 },
|
||||
total_tokens: { actual: 1500 },
|
||||
cached_tokens: { actual: 200 },
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
insertConversationRow(db, { context: JSON.stringify(context) })
|
||||
}
|
||||
|
||||
async function collect(parser: { parse(): AsyncGenerator<ParsedProviderCall> }): Promise<ParsedProviderCall[]> {
|
||||
const out: ParsedProviderCall[] = []
|
||||
for await (const call of parser.parse()) out.push(call)
|
||||
return out
|
||||
}
|
||||
|
||||
describe('forge provider', () => {
|
||||
it('discovers conversations with context and parses assistant usage/tool calls', async () => {
|
||||
if (!isSqliteAvailable()) return
|
||||
|
||||
const dbPath = createForgeDb()
|
||||
withTestDb(dbPath, insertConversation)
|
||||
|
||||
const provider = createForgeProvider(dbPath)
|
||||
const sources = await provider.discoverSessions()
|
||||
|
||||
expect(sources).toEqual([
|
||||
{
|
||||
path: `${dbPath}:conv-1`,
|
||||
project: 'Forge Project',
|
||||
provider: 'forge',
|
||||
},
|
||||
])
|
||||
|
||||
const seenKeys = new Set<string>()
|
||||
const calls = await collect(provider.createSessionParser(sources[0]!, seenKeys))
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]).toMatchObject({
|
||||
provider: 'forge',
|
||||
model: 'claude-opus-4-6',
|
||||
inputTokens: 1000,
|
||||
outputTokens: 300,
|
||||
cacheReadInputTokens: 200,
|
||||
cachedInputTokens: 200,
|
||||
cacheCreationInputTokens: 0,
|
||||
tools: ['Bash', 'Read'],
|
||||
bashCommands: ['git', 'npm'],
|
||||
userMessage: 'implement forge',
|
||||
sessionId: 'conv-1',
|
||||
timestamp: '2026-05-06T15:20:41.379Z',
|
||||
deduplicationKey: 'forge:conv-1:call-1',
|
||||
})
|
||||
|
||||
const duplicates = await collect(provider.createSessionParser(sources[0]!, seenKeys))
|
||||
expect(duplicates).toEqual([])
|
||||
})
|
||||
|
||||
it('does not select conversation context while discovering sessions', async () => {
|
||||
const source = await readFile(new URL('../../src/providers/forge.ts', import.meta.url), 'utf8')
|
||||
const discoverySql = source.match(/async function discoverFromDb[\s\S]*?db\.query<[^>]+>\(\s*`([\s\S]*?)`/)?.[1]
|
||||
const selectedColumns = discoverySql?.split('FROM conversations')[0] ?? ''
|
||||
|
||||
expect(discoverySql).toContain('WHERE context IS NOT NULL')
|
||||
expect(selectedColumns).not.toMatch(/\bcontext\b/)
|
||||
expect(selectedColumns).not.toMatch(/\bcreated_at\b|\bupdated_at\b/)
|
||||
})
|
||||
|
||||
it('returns no sessions when the database is missing', async () => {
|
||||
const provider = createForgeProvider(join(tmpRoot, 'missing.db'))
|
||||
|
||||
await expect(provider.discoverSessions()).resolves.toEqual([])
|
||||
})
|
||||
|
||||
it('skips zero-token assistant usage', async () => {
|
||||
if (!isSqliteAvailable()) return
|
||||
|
||||
const dbPath = createForgeDb()
|
||||
const context = {
|
||||
messages: [
|
||||
{ message: { text: { role: 'User', content: 'zero tokens' } } },
|
||||
{
|
||||
message: { text: { role: 'Assistant', model: 'claude-opus-4-6' } },
|
||||
usage: {
|
||||
prompt_tokens: { actual: 0 },
|
||||
completion_tokens: { actual: 0 },
|
||||
cached_tokens: { actual: 0 },
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
withTestDb(dbPath, db => insertConversationRow(db, { context: JSON.stringify(context) }))
|
||||
|
||||
const provider = createForgeProvider(dbPath)
|
||||
const sources = await provider.discoverSessions()
|
||||
const calls = await collect(provider.createSessionParser(sources[0]!, new Set()))
|
||||
|
||||
expect(calls).toEqual([])
|
||||
})
|
||||
|
||||
it('parses multiple assistant messages with the nearest previous user prompt', async () => {
|
||||
if (!isSqliteAvailable()) return
|
||||
|
||||
const dbPath = createForgeDb()
|
||||
const context = {
|
||||
messages: [
|
||||
{ message: { text: { role: 'User', content: 'first request' } } },
|
||||
{
|
||||
message: { text: { role: 'Assistant', model: 'claude-opus-4-6', tool_calls: [{ name: 'shell', call_id: 'call-1', arguments: { command: 'npm test' } }] } },
|
||||
usage: { prompt_tokens: { actual: 100 }, completion_tokens: { actual: 20 } },
|
||||
},
|
||||
{ message: { text: { role: 'User', content: 'second request' } } },
|
||||
{
|
||||
message: { text: { role: 'Assistant', model: 'claude-sonnet-4-6', tool_calls: [{ name: 'Read', call_id: 'call-2', arguments: { file_path: '/tmp/a' } }] } },
|
||||
usage: { prompt_tokens: { actual: 200 }, completion_tokens: { actual: 30 } },
|
||||
},
|
||||
],
|
||||
}
|
||||
withTestDb(dbPath, db => insertConversationRow(db, { context: JSON.stringify(context) }))
|
||||
|
||||
const provider = createForgeProvider(dbPath)
|
||||
const sources = await provider.discoverSessions()
|
||||
const calls = await collect(provider.createSessionParser(sources[0]!, new Set()))
|
||||
|
||||
expect(calls).toHaveLength(2)
|
||||
expect(calls.map(call => call.userMessage)).toEqual(['first request', 'second request'])
|
||||
expect(calls.map(call => call.deduplicationKey)).toEqual(['forge:conv-1:call-1', 'forge:conv-1:call-2'])
|
||||
})
|
||||
|
||||
it('uses workspace_id as project when title is null', async () => {
|
||||
if (!isSqliteAvailable()) return
|
||||
|
||||
const dbPath = createForgeDb()
|
||||
withTestDb(dbPath, db => insertConversationRow(db, { title: null, workspaceId: 'workspace-1', context: '{}' }))
|
||||
|
||||
const provider = createForgeProvider(dbPath)
|
||||
const sources = await provider.discoverSessions()
|
||||
|
||||
expect(sources[0]?.project).toBe('workspace-1')
|
||||
})
|
||||
|
||||
it('uses large integer workspace_id values as strings when title is null', async () => {
|
||||
if (!isSqliteAvailable()) return
|
||||
|
||||
const dbPath = createForgeDb()
|
||||
withTestDb(dbPath, db => {
|
||||
db.exec(`
|
||||
INSERT INTO conversations (conversation_id, title, workspace_id, context, created_at, updated_at, metrics)
|
||||
VALUES ('conv-1', NULL, 8549909960051246556, '{}', '2026-05-06 15:00:00', '2026-05-06 15:20:41.379094', NULL)
|
||||
`)
|
||||
})
|
||||
|
||||
const provider = createForgeProvider(dbPath)
|
||||
const sources = await provider.discoverSessions()
|
||||
|
||||
expect(sources[0]?.project).toBe('8549909960051246556')
|
||||
})
|
||||
|
||||
it('does not throw and yields no calls for invalid JSON context', async () => {
|
||||
if (!isSqliteAvailable()) return
|
||||
|
||||
const dbPath = createForgeDb()
|
||||
withTestDb(dbPath, db => insertConversationRow(db, { context: '{invalid' }))
|
||||
|
||||
const provider = createForgeProvider(dbPath)
|
||||
const sources = await provider.discoverSessions()
|
||||
|
||||
await expect(collect(provider.createSessionParser(sources[0]!, new Set()))).resolves.toEqual([])
|
||||
})
|
||||
|
||||
it('is available through the provider registry', async () => {
|
||||
const provider = await getProvider('forge')
|
||||
expect(provider?.name).toBe('forge')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,193 @@
|
||||
import { mkdtemp, rm, writeFile } from 'fs/promises'
|
||||
import { join } from 'path'
|
||||
import { tmpdir } from 'os'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { createGeminiProvider } from '../../src/providers/gemini.js'
|
||||
import type { ParsedProviderCall } from '../../src/providers/types.js'
|
||||
|
||||
let tmpDir: string
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await mkdtemp(join(tmpdir(), 'gemini-provider-'))
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
async function parseFixture(messages: unknown[]): Promise<ParsedProviderCall[]> {
|
||||
const filePath = join(tmpDir, 'session-gemini.json')
|
||||
await writeFile(filePath, JSON.stringify({
|
||||
sessionId: 'gemini-session-1',
|
||||
startTime: '2026-05-16T10:00:00.000Z',
|
||||
messages,
|
||||
}))
|
||||
|
||||
const provider = createGeminiProvider()
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of provider.createSessionParser({ path: filePath, project: 'gemini-project', provider: 'gemini' }, new Set()).parse()) {
|
||||
calls.push(call)
|
||||
}
|
||||
return calls
|
||||
}
|
||||
|
||||
describe('gemini provider', () => {
|
||||
it('emits one provider call per Gemini message with token usage', async () => {
|
||||
const calls = await parseFixture([
|
||||
{
|
||||
id: 'u1',
|
||||
timestamp: '2026-05-16T10:00:00.000Z',
|
||||
type: 'user',
|
||||
content: 'inspect the repo',
|
||||
},
|
||||
{
|
||||
id: 'g1',
|
||||
timestamp: '2026-05-16T10:00:05.000Z',
|
||||
type: 'gemini',
|
||||
content: 'reading files',
|
||||
model: 'gemini-3.1-pro-preview',
|
||||
tokens: { input: 120, cached: 20, output: 30, thoughts: 5 },
|
||||
toolCalls: [{ id: 't1', name: 'read_file', args: { path: 'src/index.ts' } }],
|
||||
},
|
||||
{
|
||||
id: 'u2',
|
||||
timestamp: '2026-05-16T10:01:00.000Z',
|
||||
type: 'user',
|
||||
content: [{ text: 'run tests' }],
|
||||
},
|
||||
{
|
||||
id: 'g2',
|
||||
timestamp: '2026-05-16T10:01:10.000Z',
|
||||
type: 'gemini',
|
||||
content: 'running tests',
|
||||
model: 'gemini-3.1-pro-preview',
|
||||
tokens: { input: 80, cached: 10, output: 25 },
|
||||
toolCalls: [{ id: 't2', name: 'run_command', args: { command: 'npm test' } }],
|
||||
},
|
||||
])
|
||||
|
||||
expect(calls).toHaveLength(2)
|
||||
expect(calls.map(c => c.deduplicationKey)).toEqual([
|
||||
'gemini:gemini-session-1:g1',
|
||||
'gemini:gemini-session-1:g2',
|
||||
])
|
||||
expect(calls.map(c => c.timestamp)).toEqual([
|
||||
'2026-05-16T10:00:05.000Z',
|
||||
'2026-05-16T10:01:10.000Z',
|
||||
])
|
||||
expect(calls.map(c => c.userMessage)).toEqual(['inspect the repo', 'run tests'])
|
||||
expect(calls[0]!.inputTokens).toBe(100)
|
||||
expect(calls[0]!.cacheReadInputTokens).toBe(20)
|
||||
expect(calls[0]!.reasoningTokens).toBe(5)
|
||||
expect(calls[0]!.tools).toEqual(['Read'])
|
||||
expect(calls[1]!.inputTokens).toBe(70)
|
||||
expect(calls[1]!.cacheReadInputTokens).toBe(10)
|
||||
expect(calls[1]!.tools).toEqual(['Bash'])
|
||||
expect(calls[1]!.bashCommands).toEqual(['npm'])
|
||||
})
|
||||
|
||||
it('keeps aggregate token totals when splitting a Gemini session into calls', async () => {
|
||||
const calls = await parseFixture([
|
||||
{ id: 'u1', timestamp: '2026-05-16T10:00:00.000Z', type: 'user', content: 'work' },
|
||||
{
|
||||
id: 'g1',
|
||||
timestamp: '2026-05-16T10:00:05.000Z',
|
||||
type: 'gemini',
|
||||
content: 'first',
|
||||
model: 'gemini-3.1-pro-preview',
|
||||
tokens: { input: 120, cached: 20, output: 30, thoughts: 5 },
|
||||
},
|
||||
{
|
||||
id: 'g2',
|
||||
timestamp: '2026-05-16T10:00:10.000Z',
|
||||
type: 'gemini',
|
||||
content: 'second',
|
||||
model: 'gemini-3.1-pro-preview',
|
||||
tokens: { input: 80, cached: 10, output: 25, thoughts: 0 },
|
||||
},
|
||||
])
|
||||
|
||||
expect(calls).toHaveLength(2)
|
||||
expect(calls.reduce((sum, call) => sum + call.inputTokens, 0)).toBe(170)
|
||||
expect(calls.reduce((sum, call) => sum + call.cacheReadInputTokens, 0)).toBe(30)
|
||||
expect(calls.reduce((sum, call) => sum + call.outputTokens, 0)).toBe(55)
|
||||
expect(calls.reduce((sum, call) => sum + call.reasoningTokens, 0)).toBe(5)
|
||||
})
|
||||
|
||||
it('skips Gemini messages without token usage', async () => {
|
||||
const calls = await parseFixture([
|
||||
{ id: 'u1', timestamp: '2026-05-16T10:00:00.000Z', type: 'user', content: 'work' },
|
||||
{
|
||||
id: 'info',
|
||||
timestamp: '2026-05-16T10:00:05.000Z',
|
||||
type: 'gemini',
|
||||
content: 'tool-only notice',
|
||||
model: 'gemini-3.1-pro-preview',
|
||||
},
|
||||
])
|
||||
|
||||
expect(calls).toEqual([])
|
||||
})
|
||||
|
||||
it('uses a deterministic ordinal key when Gemini message ids are missing', async () => {
|
||||
const messages = [
|
||||
{ id: 'u1', timestamp: '2026-05-16T10:00:00.000Z', type: 'user', content: 'work' },
|
||||
{
|
||||
timestamp: '2026-05-16T10:00:05.000Z',
|
||||
type: 'gemini',
|
||||
content: 'first',
|
||||
model: 'gemini-3.1-pro-preview',
|
||||
tokens: { input: 10, output: 5 },
|
||||
},
|
||||
{
|
||||
timestamp: '2026-05-16T10:00:10.000Z',
|
||||
type: 'gemini',
|
||||
content: 'second',
|
||||
model: 'gemini-3.1-pro-preview',
|
||||
tokens: { input: 12, output: 6 },
|
||||
},
|
||||
]
|
||||
|
||||
const first = await parseFixture(messages)
|
||||
const second = await parseFixture(messages)
|
||||
|
||||
expect(first.map(c => c.deduplicationKey)).toEqual([
|
||||
'gemini:gemini-session-1:idx-0',
|
||||
'gemini:gemini-session-1:idx-1',
|
||||
])
|
||||
expect(second.map(c => c.deduplicationKey)).toEqual(first.map(c => c.deduplicationKey))
|
||||
})
|
||||
|
||||
it('does not poison seenKeys when a Gemini message timestamp is invalid', async () => {
|
||||
const filePath = join(tmpDir, 'session-gemini.json')
|
||||
await writeFile(filePath, JSON.stringify({
|
||||
sessionId: 'gemini-session-1',
|
||||
startTime: '2026-05-16T10:00:00.000Z',
|
||||
messages: [
|
||||
{ id: 'u1', timestamp: '2026-05-16T10:00:00.000Z', type: 'user', content: 'work' },
|
||||
{
|
||||
id: 'g1',
|
||||
timestamp: 'not-a-date',
|
||||
type: 'gemini',
|
||||
content: 'first',
|
||||
model: 'gemini-3.1-pro-preview',
|
||||
tokens: { input: 10, output: 5 },
|
||||
},
|
||||
],
|
||||
}))
|
||||
|
||||
const provider = createGeminiProvider()
|
||||
const seenKeys = new Set<string>()
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of provider.createSessionParser(
|
||||
{ path: filePath, project: 'gemini-project', provider: 'gemini' },
|
||||
seenKeys,
|
||||
).parse()) {
|
||||
calls.push(call)
|
||||
}
|
||||
|
||||
expect(calls).toEqual([])
|
||||
expect(seenKeys.has('gemini:gemini-session-1:g1')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,187 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||
import { mkdtemp, mkdir, writeFile, rm } from 'fs/promises'
|
||||
import { join } from 'path'
|
||||
import { tmpdir } from 'os'
|
||||
|
||||
import { createGrokProvider } from '../../src/providers/grok.js'
|
||||
import type { ParsedProviderCall } from '../../src/providers/types.js'
|
||||
|
||||
let tmpDir: string
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await mkdtemp(join(tmpdir(), 'grok-test-'))
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
// Mirrors the real on-disk layout:
|
||||
// <sessionsDir>/<url-encoded-cwd>/<uuid>/{summary.json, signals.json, updates.jsonl}
|
||||
async function writeSession(opts: {
|
||||
cwdEncoded?: string
|
||||
uuid?: string
|
||||
cwd?: string
|
||||
model?: string
|
||||
turns?: Array<{ promptId: string; totals: number[] }>
|
||||
toolCalls?: Array<{ title: string; rawInput: Record<string, unknown> }>
|
||||
toolsUsed?: string[]
|
||||
} = {}) {
|
||||
const cwdEncoded = opts.cwdEncoded ?? '%2FUsers%2Ftest'
|
||||
const uuid = opts.uuid ?? '019edf9c-0000-7000-8000-000000000001'
|
||||
const cwd = opts.cwd ?? '/Users/test/myproject'
|
||||
const model = opts.model ?? 'grok-build'
|
||||
const dir = join(tmpDir, cwdEncoded, uuid)
|
||||
await mkdir(dir, { recursive: true })
|
||||
|
||||
await writeFile(join(dir, 'summary.json'), JSON.stringify({
|
||||
info: { id: uuid, cwd },
|
||||
created_at: '2026-06-19T11:20:40.686261Z',
|
||||
updated_at: '2026-06-19T11:31:12.282793Z',
|
||||
last_active_at: '2026-06-19T11:31:12.222328Z',
|
||||
num_messages: 42,
|
||||
current_model_id: model,
|
||||
session_summary: 'User asks about the repo',
|
||||
generated_title: 'User asks about the repo',
|
||||
}))
|
||||
|
||||
await writeFile(join(dir, 'signals.json'), JSON.stringify({
|
||||
primaryModelId: model,
|
||||
modelsUsed: [model],
|
||||
toolsUsed: opts.toolsUsed ?? ['read_file', 'run_terminal_command', 'grep'],
|
||||
contextTokensUsed: 40000,
|
||||
contextWindowTokens: 512000,
|
||||
}))
|
||||
|
||||
const turns = opts.turns ?? [
|
||||
{ promptId: 'p1', totals: [20000, 25000] },
|
||||
{ promptId: 'p2', totals: [30000, 35000] },
|
||||
{ promptId: 'p3', totals: [40000, 45000] },
|
||||
]
|
||||
const lines: string[] = []
|
||||
for (const turn of turns) {
|
||||
for (const total of turn.totals) {
|
||||
lines.push(JSON.stringify({
|
||||
timestamp: '2026-06-19T11:30:00.000Z',
|
||||
method: 'session/update',
|
||||
params: {
|
||||
sessionId: uuid,
|
||||
update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'hi' } },
|
||||
_meta: { totalTokens: total, promptId: turn.promptId, updateType: 'AgentMessageChunk', modelId: model },
|
||||
},
|
||||
}))
|
||||
}
|
||||
}
|
||||
for (const tc of opts.toolCalls ?? [
|
||||
{ title: 'read_file', rawInput: { target_directory: '.' } },
|
||||
{ title: 'grep', rawInput: { pattern: 'x' } },
|
||||
{ title: 'run_terminal_command', rawInput: { command: 'git status' } },
|
||||
{ title: 'spawn_subagent', rawInput: { subagent_type: 'general-purpose', prompt: 'x' } },
|
||||
]) {
|
||||
lines.push(JSON.stringify({
|
||||
timestamp: '2026-06-19T11:30:05.000Z',
|
||||
method: 'session/update',
|
||||
params: { sessionId: uuid, update: { sessionUpdate: 'tool_call', toolCallId: 'c1', title: tc.title, rawInput: tc.rawInput } },
|
||||
}))
|
||||
}
|
||||
await writeFile(join(dir, 'updates.jsonl'), lines.join('\n') + '\n')
|
||||
|
||||
return { dir, uuid }
|
||||
}
|
||||
|
||||
describe('grok provider - discovery', () => {
|
||||
it('discovers each session dir and derives project from cwd', async () => {
|
||||
await writeSession({ cwd: '/Users/test/myproject' })
|
||||
const sessions = await createGrokProvider(tmpDir).discoverSessions()
|
||||
expect(sessions).toHaveLength(1)
|
||||
expect(sessions[0]!.provider).toBe('grok')
|
||||
expect(sessions[0]!.project).toBe('myproject')
|
||||
expect(sessions[0]!.path).toMatch(/updates\.jsonl$/)
|
||||
})
|
||||
|
||||
it('returns empty for a non-existent sessions dir', async () => {
|
||||
const sessions = await createGrokProvider('/nope/does/not/exist').discoverSessions()
|
||||
expect(sessions).toEqual([])
|
||||
})
|
||||
|
||||
it('skips directories without a summary.json', async () => {
|
||||
await mkdir(join(tmpDir, '%2Ftmp', 'not-a-session'), { recursive: true })
|
||||
const sessions = await createGrokProvider(tmpDir).discoverSessions()
|
||||
expect(sessions).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('grok provider - parsing', () => {
|
||||
async function parse(seen = new Set<string>()) {
|
||||
const provider = createGrokProvider(tmpDir)
|
||||
const [source] = await provider.discoverSessions()
|
||||
const calls: ParsedProviderCall[] = []
|
||||
if (!source) return calls
|
||||
for await (const call of provider.createSessionParser(source, seen).parse()) {
|
||||
calls.push(call)
|
||||
}
|
||||
return calls
|
||||
}
|
||||
|
||||
it('emits one estimated call per session from the totalTokens curve', async () => {
|
||||
await writeSession()
|
||||
const calls = await parse()
|
||||
expect(calls).toHaveLength(1)
|
||||
const call = calls[0]!
|
||||
expect(call.model).toBe('grok-build')
|
||||
// input = peak context (max totalTokens across the session)
|
||||
expect(call.inputTokens).toBe(45000)
|
||||
// cache reads = re-sent context (sum of per-turn starts 90000 minus peak 45000)
|
||||
expect(call.cacheReadInputTokens).toBe(45000)
|
||||
// output = sum of per-turn growth (3 turns x 5000)
|
||||
expect(call.outputTokens).toBe(15000)
|
||||
expect(call.costIsEstimated).toBe(true)
|
||||
expect(call.costUSD).toBeGreaterThan(0)
|
||||
expect(call.tools).toEqual(['Read', 'Grep', 'Bash', 'Agent'])
|
||||
expect(call.bashCommands).toContain('git')
|
||||
expect(call.subagentTypes).toEqual(['general-purpose'])
|
||||
expect(call.project).toBe('myproject')
|
||||
expect(call.deduplicationKey).toContain('grok:')
|
||||
})
|
||||
|
||||
it('skips a session with no token growth', async () => {
|
||||
await writeSession({ turns: [{ promptId: 'p1', totals: [0, 0] }] })
|
||||
expect(await parse()).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('deduplicates across repeated parses', async () => {
|
||||
await writeSession()
|
||||
const seen = new Set<string>()
|
||||
expect(await parse(seen)).toHaveLength(1)
|
||||
expect(await parse(seen)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('sums fresh input across a compaction instead of only the last peak', async () => {
|
||||
await writeSession({ turns: [
|
||||
{ promptId: 'p1', totals: [100000, 400000] },
|
||||
{ promptId: 'p2', totals: [20000, 50000] },
|
||||
] })
|
||||
const calls = await parse()
|
||||
expect(calls).toHaveLength(1)
|
||||
// 400k (segment 1 peak) + 50k (post-compaction segment), not just the 400k global peak
|
||||
expect(calls[0]!.inputTokens).toBe(450000)
|
||||
})
|
||||
})
|
||||
|
||||
describe('grok provider - display names', () => {
|
||||
const provider = createGrokProvider('/tmp')
|
||||
|
||||
it('has the right name and displayName', () => {
|
||||
expect(provider.name).toBe('grok')
|
||||
expect(provider.displayName).toBe('Grok Build')
|
||||
})
|
||||
|
||||
it('labels grok-build', () => {
|
||||
expect(provider.modelDisplayName('grok-build')).toBe('Grok Build')
|
||||
})
|
||||
|
||||
it('normalizes tool names', () => {
|
||||
expect(provider.toolDisplayName('run_terminal_command')).toBe('Bash')
|
||||
expect(provider.toolDisplayName('mystery_tool')).toBe('mystery_tool')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,546 @@
|
||||
import { mkdir, mkdtemp, rm } from 'fs/promises'
|
||||
import { basename, dirname, join } from 'path'
|
||||
import { tmpdir } from 'os'
|
||||
import { createRequire } from 'node:module'
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { calculateCost } from '../../src/models.js'
|
||||
import { createHermesProvider } from '../../src/providers/hermes.js'
|
||||
import { isSqliteAvailable } from '../../src/sqlite.js'
|
||||
import type { ParsedProviderCall } from '../../src/providers/types.js'
|
||||
import type { DateRange } from '../../src/types.js'
|
||||
|
||||
const requireForTest = createRequire(import.meta.url)
|
||||
|
||||
type TestDb = {
|
||||
exec(sql: string): void
|
||||
prepare(sql: string): { run(...params: unknown[]): void }
|
||||
close(): void
|
||||
}
|
||||
|
||||
let tmpDir: string
|
||||
let cacheDir: string
|
||||
let originalHermesHome: string | undefined
|
||||
let originalCodeburnCacheDir: string | undefined
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await mkdtemp(join(tmpdir(), 'hermes-provider-test-'))
|
||||
cacheDir = await mkdtemp(join(tmpdir(), 'hermes-provider-cache-'))
|
||||
originalHermesHome = process.env['HERMES_HOME']
|
||||
originalCodeburnCacheDir = process.env['CODEBURN_CACHE_DIR']
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
if (originalHermesHome === undefined) delete process.env['HERMES_HOME']
|
||||
else process.env['HERMES_HOME'] = originalHermesHome
|
||||
if (originalCodeburnCacheDir === undefined) delete process.env['CODEBURN_CACHE_DIR']
|
||||
else process.env['CODEBURN_CACHE_DIR'] = originalCodeburnCacheDir
|
||||
await rm(tmpDir, { recursive: true, force: true })
|
||||
await rm(cacheDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function createHermesDb(homeDir: string): string {
|
||||
const { DatabaseSync: Database } = requireForTest('node:sqlite')
|
||||
const dbPath = join(homeDir, 'state.db')
|
||||
const db = new Database(dbPath)
|
||||
db.exec(`
|
||||
CREATE TABLE sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
source TEXT,
|
||||
model TEXT,
|
||||
cwd TEXT,
|
||||
billing_provider TEXT,
|
||||
billing_base_url TEXT,
|
||||
billing_mode TEXT,
|
||||
input_tokens INTEGER DEFAULT 0,
|
||||
output_tokens INTEGER DEFAULT 0,
|
||||
cache_read_tokens INTEGER DEFAULT 0,
|
||||
cache_write_tokens INTEGER DEFAULT 0,
|
||||
reasoning_tokens INTEGER DEFAULT 0,
|
||||
estimated_cost_usd REAL,
|
||||
actual_cost_usd REAL,
|
||||
cost_status TEXT,
|
||||
api_call_count INTEGER DEFAULT 0,
|
||||
message_count INTEGER DEFAULT 0,
|
||||
tool_call_count INTEGER DEFAULT 0,
|
||||
started_at REAL,
|
||||
ended_at REAL,
|
||||
title TEXT
|
||||
)
|
||||
`)
|
||||
db.exec(`
|
||||
CREATE TABLE messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id TEXT NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
content TEXT,
|
||||
tool_call_id TEXT,
|
||||
tool_calls TEXT,
|
||||
tool_name TEXT,
|
||||
timestamp REAL NOT NULL
|
||||
)
|
||||
`)
|
||||
db.close()
|
||||
return dbPath
|
||||
}
|
||||
|
||||
function createLegacyHermesDb(homeDir: string): string {
|
||||
const { DatabaseSync: Database } = requireForTest('node:sqlite')
|
||||
const dbPath = join(homeDir, 'state.db')
|
||||
const db = new Database(dbPath)
|
||||
db.exec(`
|
||||
CREATE TABLE sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
model TEXT,
|
||||
input_tokens INTEGER DEFAULT 0,
|
||||
output_tokens INTEGER DEFAULT 0,
|
||||
started_at REAL
|
||||
)
|
||||
`)
|
||||
db.exec(`
|
||||
CREATE TABLE messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id TEXT NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
content TEXT,
|
||||
tool_calls TEXT,
|
||||
timestamp REAL NOT NULL
|
||||
)
|
||||
`)
|
||||
db.close()
|
||||
return dbPath
|
||||
}
|
||||
|
||||
async function createProfileHermesDb(hermesHome: string, profile: string): Promise<string> {
|
||||
const profileDir = join(hermesHome, 'profiles', profile)
|
||||
await mkdir(profileDir, { recursive: true })
|
||||
return createHermesDb(profileDir)
|
||||
}
|
||||
|
||||
function insertSession(db: TestDb, values: {
|
||||
id: string
|
||||
source?: string
|
||||
model?: string
|
||||
cwd?: string | null
|
||||
billingProvider?: string
|
||||
inputTokens: number
|
||||
outputTokens: number
|
||||
cacheReadTokens: number
|
||||
cacheWriteTokens: number
|
||||
reasoningTokens: number
|
||||
estimatedCost?: number | null
|
||||
actualCost?: number | null
|
||||
apiCalls?: number
|
||||
toolCalls?: number
|
||||
startedAt: number
|
||||
title?: string
|
||||
}): void {
|
||||
db.prepare(
|
||||
`INSERT INTO sessions (
|
||||
id, source, model, cwd, billing_provider, input_tokens, output_tokens,
|
||||
cache_read_tokens, cache_write_tokens, reasoning_tokens, estimated_cost_usd,
|
||||
actual_cost_usd, api_call_count, tool_call_count, started_at, title
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
).run(
|
||||
values.id,
|
||||
values.source ?? 'cli',
|
||||
values.model ?? 'gpt-5.5',
|
||||
values.cwd ?? null,
|
||||
values.billingProvider ?? 'openai-codex',
|
||||
values.inputTokens,
|
||||
values.outputTokens,
|
||||
values.cacheReadTokens,
|
||||
values.cacheWriteTokens,
|
||||
values.reasoningTokens,
|
||||
values.estimatedCost ?? null,
|
||||
values.actualCost ?? null,
|
||||
values.apiCalls ?? 1,
|
||||
values.toolCalls ?? 0,
|
||||
values.startedAt,
|
||||
values.title ?? values.id,
|
||||
)
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
function dayRange(): DateRange {
|
||||
return {
|
||||
start: new Date('2026-05-23T00:00:00.000Z'),
|
||||
end: new Date('2026-05-23T23:59:59.999Z'),
|
||||
}
|
||||
}
|
||||
|
||||
async function loadParserWithHermesHome(hermesHome: string, codeburnCacheDir: string) {
|
||||
process.env['HERMES_HOME'] = hermesHome
|
||||
process.env['CODEBURN_CACHE_DIR'] = codeburnCacheDir
|
||||
vi.resetModules()
|
||||
const parser = await import('../../src/parser.js')
|
||||
return parser
|
||||
}
|
||||
|
||||
async function collectCalls(hermesHome: string, sourcePath: string): Promise<ParsedProviderCall[]> {
|
||||
const provider = createHermesProvider(hermesHome)
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of provider.createSessionParser({ path: sourcePath, project: 'hermes', provider: 'hermes' }, new Set()).parse()) {
|
||||
calls.push(call)
|
||||
}
|
||||
return calls
|
||||
}
|
||||
|
||||
const skipUnlessSqlite = isSqliteAvailable() ? describe : describe.skip
|
||||
|
||||
skipUnlessSqlite('hermes provider', () => {
|
||||
it('discovers state.db sessions with token usage', async () => {
|
||||
const dbPath = createHermesDb(tmpDir)
|
||||
withTestDb(dbPath, (db) => {
|
||||
insertSession(db, {
|
||||
id: 'session-1',
|
||||
inputTokens: 100,
|
||||
outputTokens: 20,
|
||||
cacheReadTokens: 50,
|
||||
cacheWriteTokens: 0,
|
||||
reasoningTokens: 5,
|
||||
startedAt: 1779549200,
|
||||
title: 'Test Project',
|
||||
})
|
||||
db.prepare(
|
||||
`INSERT INTO sessions (id, source, model, input_tokens, output_tokens, started_at, title)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
).run('empty', 'cli', 'gpt-5.5', 0, 0, 1779549300, 'Empty')
|
||||
})
|
||||
|
||||
const provider = createHermesProvider(tmpDir)
|
||||
const sessions = await provider.discoverSessions()
|
||||
expect(sessions).toHaveLength(1)
|
||||
expect(sessions[0]!.provider).toBe('hermes')
|
||||
expect(sessions[0]!.path).toBe(`${dbPath}#hermes-session=session-1`)
|
||||
expect(sessions[0]!.project).toBe('default')
|
||||
})
|
||||
|
||||
it('parses session-level token usage and tool calls from messages', async () => {
|
||||
const dbPath = createHermesDb(tmpDir)
|
||||
withTestDb(dbPath, (db) => {
|
||||
insertSession(db, {
|
||||
id: 'session-1',
|
||||
source: 'tui',
|
||||
inputTokens: 1000,
|
||||
outputTokens: 200,
|
||||
cacheReadTokens: 300,
|
||||
cacheWriteTokens: 40,
|
||||
reasoningTokens: 25,
|
||||
estimatedCost: 0.12,
|
||||
apiCalls: 3,
|
||||
toolCalls: 2,
|
||||
startedAt: 1779549200,
|
||||
title: 'Provider Work',
|
||||
})
|
||||
db.prepare('INSERT INTO messages (session_id, role, content, timestamp) VALUES (?, ?, ?, ?)')
|
||||
.run('session-1', 'user', 'Add Hermes support', 1779549201)
|
||||
db.prepare('INSERT INTO messages (session_id, role, content, tool_calls, timestamp) VALUES (?, ?, ?, ?, ?)')
|
||||
.run(
|
||||
'session-1',
|
||||
'assistant',
|
||||
'',
|
||||
JSON.stringify([
|
||||
{ function: { name: 'read_file', arguments: JSON.stringify({ path: '/tmp/file.ts' }) } },
|
||||
{ function: { name: 'terminal', arguments: JSON.stringify({ command: 'npm test' }) } },
|
||||
]),
|
||||
1779549202,
|
||||
)
|
||||
})
|
||||
|
||||
const calls = await collectCalls(tmpDir, `${dbPath}#hermes-session=session-1`)
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]!).toMatchObject({
|
||||
provider: 'hermes',
|
||||
model: 'gpt-5.5',
|
||||
inputTokens: 1000,
|
||||
outputTokens: 200,
|
||||
cacheReadInputTokens: 300,
|
||||
cacheCreationInputTokens: 40,
|
||||
cachedInputTokens: 300,
|
||||
reasoningTokens: 25,
|
||||
costUSD: 0.12,
|
||||
userMessage: 'Add Hermes support',
|
||||
sessionId: 'session-1',
|
||||
deduplicationKey: 'hermes:default:session-1',
|
||||
})
|
||||
expect(calls[0]!.tools).toEqual(['Read', 'Bash'])
|
||||
expect(calls[0]!.bashCommands).toEqual(['npm test'])
|
||||
expect(calls[0]!.toolSequence).toEqual([
|
||||
[{ tool: 'Read', file: '/tmp/file.ts' }, { tool: 'Bash', command: 'npm test' }],
|
||||
])
|
||||
})
|
||||
|
||||
|
||||
it('maps composio MCP tools before generic MCP prefixes', () => {
|
||||
const provider = createHermesProvider(tmpDir)
|
||||
expect(provider.toolDisplayName('mcp_composio_GMAIL_SEND_EMAIL')).toBe('MCP')
|
||||
expect(provider.toolDisplayName('mcp__github__create_issue')).toBe('mcp__github__create_issue')
|
||||
})
|
||||
|
||||
it('falls back to calculateCost when no actual or estimated cost is recorded', async () => {
|
||||
const dbPath = createHermesDb(tmpDir)
|
||||
withTestDb(dbPath, (db) => {
|
||||
insertSession(db, {
|
||||
id: 'no-cost-session',
|
||||
model: 'claude-sonnet-4-20250514',
|
||||
inputTokens: 1000,
|
||||
outputTokens: 200,
|
||||
cacheReadTokens: 0,
|
||||
cacheWriteTokens: 0,
|
||||
reasoningTokens: 50,
|
||||
estimatedCost: null,
|
||||
actualCost: null,
|
||||
startedAt: 1779549200,
|
||||
})
|
||||
db.prepare('INSERT INTO messages (session_id, role, content, timestamp) VALUES (?, ?, ?, ?)')
|
||||
.run('no-cost-session', 'user', 'Test calculateCost fallback', 1779549201)
|
||||
})
|
||||
|
||||
const calls = await collectCalls(tmpDir, `${dbPath}#hermes-session=no-cost-session`)
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]!.costUSD).toBe(calculateCost('claude-sonnet-4-20250514', 1000, 250, 0, 0, 0))
|
||||
expect(calls[0]!.reasoningTokens).toBe(50)
|
||||
})
|
||||
|
||||
it('does not split multibyte characters when truncating the first user message', async () => {
|
||||
const dbPath = createHermesDb(tmpDir)
|
||||
const message = `${'a'.repeat(499)}😀truncated tail`
|
||||
withTestDb(dbPath, (db) => {
|
||||
insertSession(db, {
|
||||
id: 'emoji-session',
|
||||
inputTokens: 10,
|
||||
outputTokens: 5,
|
||||
cacheReadTokens: 0,
|
||||
cacheWriteTokens: 0,
|
||||
reasoningTokens: 0,
|
||||
estimatedCost: 0.01,
|
||||
startedAt: 1779549200,
|
||||
})
|
||||
db.prepare('INSERT INTO messages (session_id, role, content, timestamp) VALUES (?, ?, ?, ?)')
|
||||
.run('emoji-session', 'user', message, 1779549201)
|
||||
})
|
||||
|
||||
const calls = await collectCalls(tmpDir, `${dbPath}#hermes-session=emoji-session`)
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]!.userMessage).toBe(`${'a'.repeat(499)}😀`)
|
||||
})
|
||||
|
||||
it('parses legacy databases that predate optional accounting columns', async () => {
|
||||
const dbPath = createLegacyHermesDb(tmpDir)
|
||||
withTestDb(dbPath, (db) => {
|
||||
db.prepare(
|
||||
`INSERT INTO sessions (id, model, input_tokens, output_tokens, started_at)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
).run('legacy-session', 'gpt-5.5', 12, 34, 1779549200)
|
||||
db.prepare('INSERT INTO messages (session_id, role, content, timestamp) VALUES (?, ?, ?, ?)')
|
||||
.run('legacy-session', 'user', 'Legacy Hermes DB', 1779549201)
|
||||
})
|
||||
|
||||
const provider = createHermesProvider(tmpDir)
|
||||
const discovered = await provider.discoverSessions()
|
||||
expect(discovered.map(s => s.path)).toEqual([`${dbPath}#hermes-session=legacy-session`])
|
||||
|
||||
const calls = await collectCalls(tmpDir, `${dbPath}#hermes-session=legacy-session`)
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]).toMatchObject({
|
||||
inputTokens: 12,
|
||||
outputTokens: 34,
|
||||
cacheReadInputTokens: 0,
|
||||
cacheCreationInputTokens: 0,
|
||||
reasoningTokens: 0,
|
||||
userMessage: 'Legacy Hermes DB',
|
||||
})
|
||||
})
|
||||
|
||||
it('discovers root and profile databases and preserves Hermes DB accounting through parser aggregation', async () => {
|
||||
const rootDbPath = createHermesDb(tmpDir)
|
||||
const profileDbPath = await createProfileHermesDb(tmpDir, 'coder')
|
||||
|
||||
withTestDb(rootDbPath, (db) => {
|
||||
insertSession(db, {
|
||||
id: 'root-session',
|
||||
model: 'gpt-5.5',
|
||||
inputTokens: 100,
|
||||
outputTokens: 20,
|
||||
cacheReadTokens: 30,
|
||||
cacheWriteTokens: 40,
|
||||
reasoningTokens: 5,
|
||||
estimatedCost: 0.25,
|
||||
actualCost: 0.30,
|
||||
startedAt: 1779494400,
|
||||
title: 'Root session',
|
||||
})
|
||||
db.prepare('INSERT INTO messages (session_id, role, content, timestamp) VALUES (?, ?, ?, ?)')
|
||||
.run('root-session', 'user', 'Current working directory: /tmp/root-project\nImplement root support', 1779494401)
|
||||
})
|
||||
withTestDb(profileDbPath, (db) => {
|
||||
insertSession(db, {
|
||||
id: 'profile-session',
|
||||
model: 'gpt-5.5',
|
||||
inputTokens: 200,
|
||||
outputTokens: 70,
|
||||
cacheReadTokens: 11,
|
||||
cacheWriteTokens: 13,
|
||||
reasoningTokens: 17,
|
||||
estimatedCost: 0.42,
|
||||
actualCost: null,
|
||||
startedAt: 1779501600,
|
||||
title: 'Profile session',
|
||||
})
|
||||
db.prepare('INSERT INTO messages (session_id, role, content, timestamp) VALUES (?, ?, ?, ?)')
|
||||
.run('profile-session', 'user', 'Current working directory: /tmp/profile-project\nImplement profile support', 1779501601)
|
||||
})
|
||||
|
||||
const provider = createHermesProvider(tmpDir)
|
||||
const discovered = await provider.discoverSessions()
|
||||
expect(discovered.map(s => s.path).sort()).toEqual([
|
||||
`${profileDbPath}#hermes-session=profile-session`,
|
||||
`${rootDbPath}#hermes-session=root-session`,
|
||||
].sort())
|
||||
expect(discovered.map(s => s.project).sort()).toEqual(['coder', 'default'])
|
||||
|
||||
const rootCalls = await collectCalls(tmpDir, `${rootDbPath}#hermes-session=root-session`)
|
||||
const profileCalls = await collectCalls(tmpDir, `${profileDbPath}#hermes-session=profile-session`)
|
||||
expect(rootCalls[0]).toMatchObject({ inputTokens: 100, outputTokens: 20, cacheReadInputTokens: 30, cacheCreationInputTokens: 40, reasoningTokens: 5, costUSD: 0.30 })
|
||||
expect(profileCalls[0]).toMatchObject({ inputTokens: 200, outputTokens: 70, cacheReadInputTokens: 11, cacheCreationInputTokens: 13, reasoningTokens: 17, costUSD: 0.42 })
|
||||
|
||||
const { clearSessionCache, parseAllSessions } = await loadParserWithHermesHome(tmpDir, cacheDir)
|
||||
clearSessionCache()
|
||||
const projects = await parseAllSessions(dayRange(), 'hermes')
|
||||
const sessions = projects.flatMap(project => project.sessions)
|
||||
expect(sessions).toHaveLength(2)
|
||||
expect(sessions.reduce((sum, session) => sum + session.totalInputTokens, 0)).toBe(300)
|
||||
expect(sessions.reduce((sum, session) => sum + session.totalOutputTokens, 0)).toBe(90)
|
||||
expect(sessions.reduce((sum, session) => sum + session.totalReasoningTokens, 0)).toBe(22)
|
||||
expect(sessions.reduce((sum, session) => sum + session.totalCacheReadTokens, 0)).toBe(41)
|
||||
expect(sessions.reduce((sum, session) => sum + session.totalCacheWriteTokens, 0)).toBe(53)
|
||||
expect(sessions.reduce((sum, session) => sum + session.totalCostUSD, 0)).toBeCloseTo(0.72)
|
||||
expect(projects.map(project => project.project).sort()).toEqual(['tmp-profile-project', 'tmp-root-project'])
|
||||
|
||||
const modelTokens = sessions.flatMap(session => Object.values(session.modelBreakdown).map(model => model.tokens))
|
||||
expect(modelTokens.reduce((sum, tokens) => sum + tokens.outputTokens, 0)).toBe(90)
|
||||
expect(modelTokens.reduce((sum, tokens) => sum + tokens.reasoningTokens, 0)).toBe(22)
|
||||
})
|
||||
|
||||
it('treats sibling profile-like directories as default sessions', async () => {
|
||||
const profileLikeDir = join(dirname(tmpDir), `${basename(tmpDir)}-profiles_backup`, 'coder')
|
||||
await mkdir(profileLikeDir, { recursive: true })
|
||||
const dbPath = createHermesDb(profileLikeDir)
|
||||
|
||||
withTestDb(dbPath, (db) => {
|
||||
insertSession(db, {
|
||||
id: 'sibling-session',
|
||||
inputTokens: 10,
|
||||
outputTokens: 5,
|
||||
cacheReadTokens: 0,
|
||||
cacheWriteTokens: 0,
|
||||
reasoningTokens: 0,
|
||||
startedAt: 1779549200,
|
||||
})
|
||||
db.prepare('INSERT INTO messages (session_id, role, content, timestamp) VALUES (?, ?, ?, ?)')
|
||||
.run('sibling-session', 'user', 'Sibling profile-like directory', 1779549201)
|
||||
})
|
||||
|
||||
const calls = await collectCalls(tmpDir, `${dbPath}#hermes-session=sibling-session`)
|
||||
expect(calls[0]).toMatchObject({
|
||||
deduplicationKey: 'hermes:default:sibling-session',
|
||||
project: 'default',
|
||||
})
|
||||
})
|
||||
|
||||
it('infers projects from Windows current working directory messages', async () => {
|
||||
const dbPath = createHermesDb(tmpDir)
|
||||
withTestDb(dbPath, (db) => {
|
||||
insertSession(db, {
|
||||
id: 'windows-cwd-session',
|
||||
inputTokens: 10,
|
||||
outputTokens: 5,
|
||||
cacheReadTokens: 0,
|
||||
cacheWriteTokens: 0,
|
||||
reasoningTokens: 0,
|
||||
startedAt: 1779549200,
|
||||
})
|
||||
db.prepare('INSERT INTO messages (session_id, role, content, timestamp) VALUES (?, ?, ?, ?)')
|
||||
.run('windows-cwd-session', 'user', 'Current working directory: C:\\AI_LAB\\OPENCLAW\nAdd Windows path support', 1779549201)
|
||||
})
|
||||
|
||||
const calls = await collectCalls(tmpDir, `${dbPath}#hermes-session=windows-cwd-session`)
|
||||
expect(calls[0]).toMatchObject({
|
||||
project: 'C--AI_LAB-OPENCLAW',
|
||||
projectPath: 'C:\\AI_LAB\\OPENCLAW',
|
||||
})
|
||||
})
|
||||
|
||||
it('groups by the sessions.cwd column when present, ahead of message scraping', async () => {
|
||||
const dbPath = createHermesDb(tmpDir)
|
||||
withTestDb(dbPath, (db) => {
|
||||
insertSession(db, {
|
||||
id: 'cwd-session',
|
||||
cwd: '/Users/me/projects/codeburn',
|
||||
inputTokens: 30,
|
||||
outputTokens: 10,
|
||||
cacheReadTokens: 0,
|
||||
cacheWriteTokens: 0,
|
||||
reasoningTokens: 0,
|
||||
startedAt: 1779549200,
|
||||
})
|
||||
db.prepare('INSERT INTO messages (session_id, role, content, timestamp) VALUES (?, ?, ?, ?)')
|
||||
.run('cwd-session', 'user', 'Current working directory: /tmp/decoy\nbuild it', 1779549201)
|
||||
})
|
||||
|
||||
const calls = await collectCalls(tmpDir, `${dbPath}#hermes-session=cwd-session`)
|
||||
expect(calls[0]).toMatchObject({
|
||||
project: 'Users-me-projects-codeburn',
|
||||
projectPath: '/Users/me/projects/codeburn',
|
||||
})
|
||||
})
|
||||
|
||||
it('flags estimated cost only when Hermes recorded none', async () => {
|
||||
const dbPath = createHermesDb(tmpDir)
|
||||
withTestDb(dbPath, (db) => {
|
||||
insertSession(db, {
|
||||
id: 'no-cost',
|
||||
inputTokens: 100, outputTokens: 20, cacheReadTokens: 0, cacheWriteTokens: 0, reasoningTokens: 0,
|
||||
startedAt: 1779549200,
|
||||
})
|
||||
insertSession(db, {
|
||||
id: 'recorded-cost',
|
||||
actualCost: 1.23,
|
||||
inputTokens: 100, outputTokens: 20, cacheReadTokens: 0, cacheWriteTokens: 0, reasoningTokens: 0,
|
||||
startedAt: 1779549300,
|
||||
})
|
||||
})
|
||||
|
||||
const noCost = await collectCalls(tmpDir, `${dbPath}#hermes-session=no-cost`)
|
||||
expect(noCost[0]!.costIsEstimated).toBe(true)
|
||||
|
||||
const recorded = await collectCalls(tmpDir, `${dbPath}#hermes-session=recorded-cost`)
|
||||
expect(recorded[0]).toMatchObject({ costUSD: 1.23, costIsEstimated: false })
|
||||
})
|
||||
|
||||
it('counts tool-result messages by their tool_name', async () => {
|
||||
const dbPath = createHermesDb(tmpDir)
|
||||
withTestDb(dbPath, (db) => {
|
||||
insertSession(db, {
|
||||
id: 'tool-result-session',
|
||||
inputTokens: 10, outputTokens: 5, cacheReadTokens: 0, cacheWriteTokens: 0, reasoningTokens: 0,
|
||||
startedAt: 1779549200,
|
||||
})
|
||||
db.prepare('INSERT INTO messages (session_id, role, content, tool_name, timestamp) VALUES (?, ?, ?, ?, ?)')
|
||||
.run('tool-result-session', 'tool', null, 'read_file', 1779549201)
|
||||
})
|
||||
|
||||
const calls = await collectCalls(tmpDir, `${dbPath}#hermes-session=tool-result-session`)
|
||||
expect(calls[0]!.tools).toContain('Read')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,164 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||
import { mkdtemp, mkdir, writeFile, rm } from 'fs/promises'
|
||||
import { join } from 'path'
|
||||
import { tmpdir } from 'os'
|
||||
|
||||
import { ibmBob, createIBMBobProvider } from '../../src/providers/ibm-bob.js'
|
||||
import type { ParsedProviderCall } from '../../src/providers/types.js'
|
||||
|
||||
let tmpDir: string
|
||||
|
||||
function makeUiMessages(opts: {
|
||||
tokensIn?: number
|
||||
tokensOut?: number
|
||||
cacheReads?: number
|
||||
cacheWrites?: number
|
||||
cost?: number
|
||||
userMessage?: string
|
||||
ts?: number
|
||||
}): string {
|
||||
const messages: unknown[] = []
|
||||
|
||||
if (opts.userMessage) {
|
||||
messages.push({ type: 'say', say: 'user_feedback', text: opts.userMessage, ts: 1_700_000_000_000 })
|
||||
}
|
||||
|
||||
const apiData: Record<string, unknown> = {
|
||||
tokensIn: opts.tokensIn ?? 100,
|
||||
tokensOut: opts.tokensOut ?? 50,
|
||||
cacheReads: opts.cacheReads ?? 0,
|
||||
cacheWrites: opts.cacheWrites ?? 0,
|
||||
}
|
||||
if (opts.cost !== undefined) apiData.cost = opts.cost
|
||||
|
||||
messages.push({
|
||||
type: 'say',
|
||||
say: 'api_req_started',
|
||||
text: JSON.stringify(apiData),
|
||||
ts: opts.ts ?? 1_700_000_001_000,
|
||||
})
|
||||
|
||||
return JSON.stringify(messages)
|
||||
}
|
||||
|
||||
function makeApiHistory(model?: string): string {
|
||||
const modelTag = model ? `<model>${model}</model>` : ''
|
||||
return JSON.stringify([
|
||||
{ role: 'user', content: [{ type: 'text', text: `hello\n<environment_details>\n${modelTag}\n</environment_details>` }] },
|
||||
{ role: 'assistant', content: [{ type: 'text', text: 'response' }] },
|
||||
])
|
||||
}
|
||||
|
||||
describe('ibm-bob provider - discovery and parsing', () => {
|
||||
beforeEach(async () => {
|
||||
tmpDir = await mkdtemp(join(tmpdir(), 'ibm-bob-test-'))
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('discovers IBM Bob task directories with ui_messages.json', async () => {
|
||||
const task1 = join(tmpDir, 'tasks', 'task-a')
|
||||
const task2 = join(tmpDir, 'tasks', 'task-b')
|
||||
await mkdir(task1, { recursive: true })
|
||||
await mkdir(task2, { recursive: true })
|
||||
await writeFile(join(task1, 'ui_messages.json'), '[]')
|
||||
await writeFile(join(task2, 'ui_messages.json'), '[]')
|
||||
|
||||
const provider = createIBMBobProvider(tmpDir)
|
||||
const sessions = await provider.discoverSessions()
|
||||
|
||||
expect(sessions).toHaveLength(2)
|
||||
expect(sessions.every(s => s.provider === 'ibm-bob')).toBe(true)
|
||||
expect(sessions.every(s => s.project === 'IBM Bob')).toBe(true)
|
||||
})
|
||||
|
||||
it('skips tasks without ui_messages.json', async () => {
|
||||
const task = join(tmpDir, 'tasks', 'task-no-ui')
|
||||
await mkdir(task, { recursive: true })
|
||||
await writeFile(join(task, 'api_conversation_history.json'), '[]')
|
||||
|
||||
const provider = createIBMBobProvider(tmpDir)
|
||||
const sessions = await provider.discoverSessions()
|
||||
|
||||
expect(sessions).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('parses token usage and provider cost from Bob ui messages', async () => {
|
||||
const taskDir = join(tmpDir, 'tasks', 'task-001')
|
||||
await mkdir(taskDir, { recursive: true })
|
||||
await writeFile(join(taskDir, 'ui_messages.json'), makeUiMessages({
|
||||
tokensIn: 250,
|
||||
tokensOut: 125,
|
||||
cacheReads: 60,
|
||||
cacheWrites: 30,
|
||||
cost: 0.08,
|
||||
userMessage: 'modernize this class',
|
||||
}))
|
||||
await writeFile(join(taskDir, 'api_conversation_history.json'), makeApiHistory('anthropic/claude-sonnet-4-6'))
|
||||
|
||||
const source = { path: taskDir, project: 'IBM Bob', provider: 'ibm-bob' }
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of ibmBob.createSessionParser(source, new Set()).parse()) calls.push(call)
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]!).toMatchObject({
|
||||
provider: 'ibm-bob',
|
||||
model: 'claude-sonnet-4-6',
|
||||
inputTokens: 250,
|
||||
outputTokens: 125,
|
||||
cacheReadInputTokens: 60,
|
||||
cacheCreationInputTokens: 30,
|
||||
costUSD: 0.08,
|
||||
userMessage: 'modernize this class',
|
||||
sessionId: 'task-001',
|
||||
})
|
||||
expect(calls[0]!.deduplicationKey).toBe('ibm-bob:task-001:0')
|
||||
})
|
||||
|
||||
it('falls back to IBM Bob auto model when history has no model tag', async () => {
|
||||
const taskDir = join(tmpDir, 'tasks', 'task-002')
|
||||
await mkdir(taskDir, { recursive: true })
|
||||
await writeFile(join(taskDir, 'ui_messages.json'), makeUiMessages({ tokensIn: 100, tokensOut: 50 }))
|
||||
await writeFile(join(taskDir, 'api_conversation_history.json'), makeApiHistory())
|
||||
|
||||
const source = { path: taskDir, project: 'IBM Bob', provider: 'ibm-bob' }
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of ibmBob.createSessionParser(source, new Set()).parse()) calls.push(call)
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]!.model).toBe('ibm-bob-auto')
|
||||
expect(calls[0]!.costUSD).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('deduplicates across parser runs', async () => {
|
||||
const taskDir = join(tmpDir, 'tasks', 'task-003')
|
||||
await mkdir(taskDir, { recursive: true })
|
||||
await writeFile(join(taskDir, 'ui_messages.json'), makeUiMessages({ tokensIn: 100, tokensOut: 50 }))
|
||||
|
||||
const source = { path: taskDir, project: 'IBM Bob', provider: 'ibm-bob' }
|
||||
const seenKeys = new Set<string>()
|
||||
|
||||
const calls1: ParsedProviderCall[] = []
|
||||
for await (const call of ibmBob.createSessionParser(source, seenKeys).parse()) calls1.push(call)
|
||||
|
||||
const calls2: ParsedProviderCall[] = []
|
||||
for await (const call of ibmBob.createSessionParser(source, seenKeys).parse()) calls2.push(call)
|
||||
|
||||
expect(calls1).toHaveLength(1)
|
||||
expect(calls2).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('ibm-bob provider - metadata', () => {
|
||||
it('has correct name and displayName', () => {
|
||||
expect(ibmBob.name).toBe('ibm-bob')
|
||||
expect(ibmBob.displayName).toBe('IBM Bob')
|
||||
})
|
||||
|
||||
it('uses shared short model display names', () => {
|
||||
expect(ibmBob.modelDisplayName('ibm-bob-auto')).toBe('IBM Bob (auto)')
|
||||
expect(ibmBob.modelDisplayName('claude-sonnet-4-6')).toBe('Sonnet 4.6')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,62 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||
import { mkdtemp, mkdir, writeFile, rm } from 'fs/promises'
|
||||
import { join } from 'path'
|
||||
import { tmpdir } from 'os'
|
||||
|
||||
import { kiloCode, createKiloCodeProvider } from '../../src/providers/kilo-code.js'
|
||||
import type { ParsedProviderCall } from '../../src/providers/types.js'
|
||||
|
||||
let tmpDir: string
|
||||
|
||||
describe('kilo-code provider - discovery path differentiation', () => {
|
||||
beforeEach(async () => {
|
||||
tmpDir = await mkdtemp(join(tmpdir(), 'kilo-code-test-'))
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('discovers tasks using kilo-code extension path', async () => {
|
||||
const task = join(tmpDir, 'tasks', 'task-kilo-1')
|
||||
await mkdir(task, { recursive: true })
|
||||
await writeFile(join(task, 'ui_messages.json'), JSON.stringify([
|
||||
{ type: 'say', say: 'api_req_started', text: JSON.stringify({ tokensIn: 100, tokensOut: 50 }), ts: 1700000000000 },
|
||||
]))
|
||||
|
||||
const provider = createKiloCodeProvider(tmpDir)
|
||||
const sessions = await provider.discoverSessions()
|
||||
const fromOverride = sessions.filter(s => s.path.startsWith(tmpDir))
|
||||
|
||||
expect(fromOverride).toHaveLength(1)
|
||||
expect(fromOverride[0]!.provider).toBe('kilo-code')
|
||||
})
|
||||
|
||||
it('parses with kilo-code provider name in dedup key', async () => {
|
||||
const task = join(tmpDir, 'tasks', 'task-kilo-2')
|
||||
await mkdir(task, { recursive: true })
|
||||
await writeFile(join(task, 'ui_messages.json'), JSON.stringify([
|
||||
{ type: 'say', say: 'api_req_started', text: JSON.stringify({ tokensIn: 200, tokensOut: 100 }), ts: 1700000000000 },
|
||||
]))
|
||||
|
||||
const source = { path: task, project: 'task-kilo-2', provider: 'kilo-code' }
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of kiloCode.createSessionParser(source, new Set()).parse()) calls.push(call)
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]!.provider).toBe('kilo-code')
|
||||
expect(calls[0]!.deduplicationKey).toMatch(/^kilo-code:/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('kilo-code provider - metadata', () => {
|
||||
it('has correct name and displayName', () => {
|
||||
expect(kiloCode.name).toBe('kilo-code')
|
||||
expect(kiloCode.displayName).toBe('KiloCode')
|
||||
})
|
||||
|
||||
it('uses different extension ID than roo-code', () => {
|
||||
expect(kiloCode.name).toBe('kilo-code')
|
||||
expect(kiloCode.name).not.toBe('roo-code')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,192 @@
|
||||
import { createHash } from 'crypto'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { mkdir, mkdtemp, rm, writeFile } from 'fs/promises'
|
||||
import { join } from 'path'
|
||||
import { tmpdir } from 'os'
|
||||
|
||||
import { createKimiProvider } from '../../src/providers/kimi.js'
|
||||
import type { ParsedProviderCall } from '../../src/providers/types.js'
|
||||
|
||||
let tmpDir: string
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await mkdtemp(join(tmpdir(), 'kimi-test-'))
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
delete process.env.KIMI_MODEL_NAME
|
||||
await rm(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function md5(value: string): string {
|
||||
return createHash('md5').update(value, 'utf-8').digest('hex')
|
||||
}
|
||||
|
||||
function record(timestamp: number, type: string, payload: Record<string, unknown>): string {
|
||||
return JSON.stringify({
|
||||
timestamp,
|
||||
message: { type, payload },
|
||||
})
|
||||
}
|
||||
|
||||
async function writeSession(workDir: string, sessionId: string, lines: string[]): Promise<string> {
|
||||
const hash = md5(workDir)
|
||||
const sessionDir = join(tmpDir, 'sessions', hash, sessionId)
|
||||
await mkdir(sessionDir, { recursive: true })
|
||||
const wirePath = join(sessionDir, 'wire.jsonl')
|
||||
await writeFile(wirePath, [
|
||||
JSON.stringify({ type: 'metadata', protocol_version: '2' }),
|
||||
...lines,
|
||||
].join('\n') + '\n')
|
||||
return wirePath
|
||||
}
|
||||
|
||||
async function collect(provider: ReturnType<typeof createKimiProvider>, path: string, seen = new Set<string>()): Promise<ParsedProviderCall[]> {
|
||||
const parser = provider.createSessionParser({ path, project: 'app', provider: 'kimi' }, seen)
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of parser.parse()) calls.push(call)
|
||||
return calls
|
||||
}
|
||||
|
||||
describe('Kimi provider', () => {
|
||||
it('discovers session and subagent wire logs under KIMI_SHARE_DIR layout', async () => {
|
||||
const workDir = '/Users/test/work/app'
|
||||
const hash = md5(workDir)
|
||||
await writeFile(join(tmpDir, 'kimi.json'), JSON.stringify({
|
||||
work_dirs: [{ path: workDir, kaos: 'local', last_session_id: 'sess-1' }],
|
||||
}))
|
||||
|
||||
const sessionDir = join(tmpDir, 'sessions', hash, 'sess-1')
|
||||
const subagentDir = join(sessionDir, 'subagents', 'agent-1')
|
||||
await mkdir(subagentDir, { recursive: true })
|
||||
await writeFile(join(sessionDir, 'wire.jsonl'), '\n')
|
||||
await writeFile(join(subagentDir, 'wire.jsonl'), '\n')
|
||||
|
||||
const sources = await createKimiProvider(tmpDir).discoverSessions()
|
||||
|
||||
expect(sources).toHaveLength(2)
|
||||
expect(sources.map(s => s.project)).toEqual(['app', 'app'])
|
||||
expect(sources.map(s => s.provider)).toEqual(['kimi', 'kimi'])
|
||||
expect(sources.map(s => s.path).sort()).toEqual([
|
||||
join(sessionDir, 'subagents', 'agent-1', 'wire.jsonl'),
|
||||
join(sessionDir, 'wire.jsonl'),
|
||||
].sort())
|
||||
})
|
||||
|
||||
it('parses Kimi wire StatusUpdate usage, tools, bash commands, and configured model', async () => {
|
||||
await writeFile(join(tmpDir, 'config.toml'), [
|
||||
'default_model = "kimi-code/k2"',
|
||||
'',
|
||||
'[models."kimi-code/k2"]',
|
||||
'model = "kimi-k2-thinking-turbo"',
|
||||
].join('\n'))
|
||||
|
||||
const wirePath = await writeSession('/Users/test/work/app', 'sess-1', [
|
||||
record(1776162400, 'TurnBegin', { user_input: 'add status endpoint' }),
|
||||
record(1776162401, 'ToolCall', {
|
||||
type: 'function',
|
||||
id: 'call-shell',
|
||||
function: { name: 'Shell', arguments: JSON.stringify({ command: 'git status && npm test' }) },
|
||||
}),
|
||||
record(1776162402, 'ToolCall', {
|
||||
type: 'function',
|
||||
id: 'call-read',
|
||||
function: { name: 'ReadFile', arguments: JSON.stringify({ path: 'src/index.ts' }) },
|
||||
}),
|
||||
record(1776162403, 'StatusUpdate', {
|
||||
message_id: 'msg-1',
|
||||
token_usage: {
|
||||
input_other: 100,
|
||||
input_cache_read: 25,
|
||||
input_cache_creation: 10,
|
||||
output: 40,
|
||||
},
|
||||
}),
|
||||
])
|
||||
|
||||
const calls = await collect(createKimiProvider(tmpDir), wirePath)
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]).toMatchObject({
|
||||
provider: 'kimi',
|
||||
model: 'kimi-k2-thinking-turbo',
|
||||
inputTokens: 100,
|
||||
outputTokens: 40,
|
||||
cacheReadInputTokens: 25,
|
||||
cacheCreationInputTokens: 10,
|
||||
cachedInputTokens: 25,
|
||||
tools: ['Bash', 'Read'],
|
||||
bashCommands: ['git', 'npm'],
|
||||
timestamp: '2026-04-14T10:26:43.000Z',
|
||||
deduplicationKey: 'kimi:sess-1:msg-1',
|
||||
userMessage: 'add status endpoint',
|
||||
sessionId: 'sess-1',
|
||||
})
|
||||
expect(calls[0]!.costUSD).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('uses content parts, model payload overrides, and message-id deduplication', async () => {
|
||||
process.env.KIMI_MODEL_NAME = 'kimi-k2-thinking'
|
||||
const wirePath = await writeSession('/Users/test/work/app', 'sess-2', [
|
||||
record(1776023300, 'TurnBegin', {
|
||||
user_input: [
|
||||
{ type: 'text', text: 'refactor parser' },
|
||||
{ type: 'image_url', image_url: { url: 'file://diagram.png' } },
|
||||
{ type: 'text', text: 'carefully' },
|
||||
],
|
||||
}),
|
||||
record(1776023301, 'ToolCallRequest', {
|
||||
id: 'call-write',
|
||||
name: 'WriteFile',
|
||||
arguments: JSON.stringify({ path: 'src/parser.ts', content: 'x' }),
|
||||
}),
|
||||
record(1776023302, 'StatusUpdate', {
|
||||
message_id: 'msg-2',
|
||||
model_name: 'kimi-k2.6',
|
||||
token_usage: { input_other: 5, output: 7 },
|
||||
}),
|
||||
record(1776023303, 'StatusUpdate', {
|
||||
message_id: 'msg-2',
|
||||
model_name: 'kimi-k2.6',
|
||||
token_usage: { input_other: 5, output: 7 },
|
||||
}),
|
||||
])
|
||||
|
||||
const calls = await collect(createKimiProvider(tmpDir), wirePath)
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]).toMatchObject({
|
||||
model: 'kimi-k2.6',
|
||||
userMessage: 'refactor parser carefully',
|
||||
tools: ['Write'],
|
||||
deduplicationKey: 'kimi:sess-2:msg-2',
|
||||
})
|
||||
})
|
||||
|
||||
it('skips non-usage updates and supports legacy input total fields defensively', async () => {
|
||||
const wirePath = await writeSession('/Users/test/work/app', 'sess-3', [
|
||||
record(1776023400, 'TurnBegin', { user_input: 'summarize' }),
|
||||
record(1776023401, 'StatusUpdate', { context_usage: 0.5 }),
|
||||
record(1776023402, 'StatusUpdate', {
|
||||
message_id: 'msg-3',
|
||||
token_usage: {
|
||||
input: 120,
|
||||
input_cache_read: 30,
|
||||
input_cache_creation: 10,
|
||||
output_tokens: 20,
|
||||
},
|
||||
}),
|
||||
])
|
||||
|
||||
const calls = await collect(createKimiProvider(tmpDir), wirePath)
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]).toMatchObject({
|
||||
inputTokens: 80,
|
||||
cacheReadInputTokens: 30,
|
||||
cacheCreationInputTokens: 10,
|
||||
outputTokens: 20,
|
||||
model: 'kimi-auto',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,948 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||
import { mkdtemp, mkdir, writeFile, rm } from 'fs/promises'
|
||||
import { join } from 'path'
|
||||
import { tmpdir } from 'os'
|
||||
|
||||
import { kiro, createKiroProvider } from '../../src/providers/kiro.js'
|
||||
import type { ParsedProviderCall } from '../../src/providers/types.js'
|
||||
|
||||
let tmpDir: string
|
||||
|
||||
function makeChatFile(opts: {
|
||||
executionId?: string
|
||||
modelId?: string
|
||||
workflowId?: string
|
||||
startTime?: number
|
||||
endTime?: number
|
||||
userPrompt?: string
|
||||
botResponses?: string[]
|
||||
}) {
|
||||
const chat = [
|
||||
{ role: 'human', content: '<identity>\nYou are Kiro.\n</identity>' },
|
||||
{ role: 'bot', content: '' },
|
||||
{ role: 'tool', content: 'workspace tree...' },
|
||||
{ role: 'bot', content: 'I will follow these instructions.' },
|
||||
]
|
||||
|
||||
if (opts.userPrompt) {
|
||||
chat.push({ role: 'human', content: opts.userPrompt })
|
||||
}
|
||||
|
||||
for (const resp of opts.botResponses ?? ['Done.']) {
|
||||
chat.push({ role: 'bot', content: resp })
|
||||
}
|
||||
|
||||
return JSON.stringify({
|
||||
executionId: opts.executionId ?? 'exec-001',
|
||||
actionId: 'act',
|
||||
context: [],
|
||||
validations: {},
|
||||
chat,
|
||||
metadata: {
|
||||
modelId: opts.modelId ?? 'claude-haiku-4-5',
|
||||
modelProvider: 'qdev',
|
||||
workflow: 'act',
|
||||
workflowId: opts.workflowId ?? 'wf-001',
|
||||
startTime: opts.startTime ?? 1777333000000,
|
||||
endTime: opts.endTime ?? 1777333010000,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function makeModernExecutionFile(opts: {
|
||||
executionId?: string
|
||||
sessionId?: string
|
||||
modelId?: string
|
||||
startTime?: number | string
|
||||
userPrompt?: string
|
||||
assistantResponse?: string
|
||||
}) {
|
||||
const startTime = opts.startTime ?? 1777333000000
|
||||
return JSON.stringify({
|
||||
executionId: opts.executionId ?? 'exec-modern-001',
|
||||
sessionId: opts.sessionId ?? 'session-modern-001',
|
||||
workflowType: 'chat-agent',
|
||||
status: 'succeed',
|
||||
startTime,
|
||||
endTime: typeof startTime === 'number' ? startTime + 10000 : 1777333010000,
|
||||
modelId: opts.modelId ?? 'claude-sonnet-4.5',
|
||||
messages: [
|
||||
{ role: 'user', content: opts.userPrompt ?? 'explain the new kiro storage layout' },
|
||||
{
|
||||
role: 'assistant',
|
||||
content: opts.assistantResponse ?? 'Done. <tool_use><name>runCommand</name></tool_use>',
|
||||
toolCalls: [{ name: 'readFile' }],
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
describe('kiro provider - chat file parsing', () => {
|
||||
beforeEach(async () => {
|
||||
tmpDir = await mkdtemp(join(tmpdir(), 'kiro-test-'))
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('parses a basic chat file', async () => {
|
||||
const wsHash = 'a'.repeat(32)
|
||||
const wsDir = join(tmpDir, wsHash)
|
||||
await mkdir(wsDir, { recursive: true })
|
||||
const chatPath = join(wsDir, 'abc123.chat')
|
||||
await writeFile(chatPath, makeChatFile({
|
||||
modelId: 'claude-haiku-4-5',
|
||||
userPrompt: 'explain the code',
|
||||
botResponses: ['Here is an explanation of the code structure.'],
|
||||
}))
|
||||
|
||||
const source = { path: chatPath, project: 'myproject', provider: 'kiro' }
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of kiro.createSessionParser(source, new Set()).parse()) calls.push(call)
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
const call = calls[0]!
|
||||
expect(call.provider).toBe('kiro')
|
||||
expect(call.model).toBe('claude-haiku-4-5')
|
||||
expect(call.outputTokens).toBeGreaterThan(0)
|
||||
expect(call.userMessage).toBe('explain the code')
|
||||
expect(call.bashCommands).toEqual([])
|
||||
expect(call.costUSD).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('stores kiro-auto when model is auto', async () => {
|
||||
const wsHash = 'b'.repeat(32)
|
||||
const wsDir = join(tmpDir, wsHash)
|
||||
await mkdir(wsDir, { recursive: true })
|
||||
const chatPath = join(wsDir, 'abc.chat')
|
||||
await writeFile(chatPath, makeChatFile({
|
||||
modelId: 'auto',
|
||||
botResponses: ['some output'],
|
||||
}))
|
||||
|
||||
const source = { path: chatPath, project: 'test', provider: 'kiro' }
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of kiro.createSessionParser(source, new Set()).parse()) calls.push(call)
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]!.model).toBe('kiro-auto')
|
||||
expect(calls[0]!.costUSD).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('skips chat files with no bot output', async () => {
|
||||
const wsHash = 'c'.repeat(32)
|
||||
const wsDir = join(tmpDir, wsHash)
|
||||
await mkdir(wsDir, { recursive: true })
|
||||
const chatPath = join(wsDir, 'empty.chat')
|
||||
await writeFile(chatPath, JSON.stringify({
|
||||
executionId: 'exec-empty',
|
||||
actionId: 'act',
|
||||
context: [],
|
||||
validations: {},
|
||||
chat: [
|
||||
{ role: 'human', content: '<identity>\nYou are Kiro.\n</identity>' },
|
||||
{ role: 'bot', content: '' },
|
||||
{ role: 'human', content: 'do something' },
|
||||
{ role: 'bot', content: '' },
|
||||
],
|
||||
metadata: {
|
||||
modelId: 'claude-haiku-4-5',
|
||||
modelProvider: 'qdev',
|
||||
workflow: 'act',
|
||||
workflowId: 'wf-empty',
|
||||
startTime: 1777333000000,
|
||||
endTime: 1777333010000,
|
||||
},
|
||||
}))
|
||||
|
||||
const source = { path: chatPath, project: 'test', provider: 'kiro' }
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of kiro.createSessionParser(source, new Set()).parse()) calls.push(call)
|
||||
|
||||
expect(calls).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('deduplicates across parser runs', async () => {
|
||||
const wsHash = 'd'.repeat(32)
|
||||
const wsDir = join(tmpDir, wsHash)
|
||||
await mkdir(wsDir, { recursive: true })
|
||||
const chatPath = join(wsDir, 'dup.chat')
|
||||
await writeFile(chatPath, makeChatFile({ botResponses: ['hello'] }))
|
||||
|
||||
const source = { path: chatPath, project: 'test', provider: 'kiro' }
|
||||
const seenKeys = new Set<string>()
|
||||
|
||||
const calls1: ParsedProviderCall[] = []
|
||||
for await (const call of kiro.createSessionParser(source, seenKeys).parse()) calls1.push(call)
|
||||
|
||||
const calls2: ParsedProviderCall[] = []
|
||||
for await (const call of kiro.createSessionParser(source, seenKeys).parse()) calls2.push(call)
|
||||
|
||||
expect(calls1).toHaveLength(1)
|
||||
expect(calls2).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('returns empty for missing file', async () => {
|
||||
const source = { path: '/nonexistent/test.chat', project: 'test', provider: 'kiro' }
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of kiro.createSessionParser(source, new Set()).parse()) calls.push(call)
|
||||
expect(calls).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('returns empty for invalid JSON', async () => {
|
||||
const wsHash = 'e'.repeat(32)
|
||||
const wsDir = join(tmpDir, wsHash)
|
||||
await mkdir(wsDir, { recursive: true })
|
||||
const chatPath = join(wsDir, 'bad.chat')
|
||||
await writeFile(chatPath, 'not json at all')
|
||||
|
||||
const source = { path: chatPath, project: 'test', provider: 'kiro' }
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of kiro.createSessionParser(source, new Set()).parse()) calls.push(call)
|
||||
expect(calls).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('estimates tokens from text length', async () => {
|
||||
const wsHash = 'f'.repeat(32)
|
||||
const wsDir = join(tmpDir, wsHash)
|
||||
await mkdir(wsDir, { recursive: true })
|
||||
const chatPath = join(wsDir, 'tokens.chat')
|
||||
const longResponse = 'x'.repeat(400)
|
||||
await writeFile(chatPath, makeChatFile({ botResponses: [longResponse] }))
|
||||
|
||||
const source = { path: chatPath, project: 'test', provider: 'kiro' }
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of kiro.createSessionParser(source, new Set()).parse()) calls.push(call)
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]!.outputTokens).toBe(109)
|
||||
})
|
||||
|
||||
it('normalizes dot-versioned model IDs to dashes', async () => {
|
||||
const wsHash = 'h'.repeat(32)
|
||||
const wsDir = join(tmpDir, wsHash)
|
||||
await mkdir(wsDir, { recursive: true })
|
||||
const chatPath = join(wsDir, 'dot.chat')
|
||||
await writeFile(chatPath, makeChatFile({
|
||||
modelId: 'claude-haiku-4.5',
|
||||
botResponses: ['response text here'],
|
||||
}))
|
||||
|
||||
const source = { path: chatPath, project: 'test', provider: 'kiro' }
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of kiro.createSessionParser(source, new Set()).parse()) calls.push(call)
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]!.model).toBe('claude-haiku-4-5')
|
||||
expect(calls[0]!.costUSD).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('uses workflowId as sessionId', async () => {
|
||||
const wsHash = 'g'.repeat(32)
|
||||
const wsDir = join(tmpDir, wsHash)
|
||||
await mkdir(wsDir, { recursive: true })
|
||||
const chatPath = join(wsDir, 'sess.chat')
|
||||
await writeFile(chatPath, makeChatFile({
|
||||
workflowId: 'my-workflow-id',
|
||||
botResponses: ['ok'],
|
||||
}))
|
||||
|
||||
const source = { path: chatPath, project: 'test', provider: 'kiro' }
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of kiro.createSessionParser(source, new Set()).parse()) calls.push(call)
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]!.sessionId).toBe('my-workflow-id')
|
||||
})
|
||||
|
||||
it('parses a post-February extensionless execution file', async () => {
|
||||
const wsHash = 'i'.repeat(32)
|
||||
const sessionHash = 'session-modern'
|
||||
const wsDir = join(tmpDir, wsHash, sessionHash)
|
||||
await mkdir(wsDir, { recursive: true })
|
||||
const executionPath = join(wsDir, 'execution-modern')
|
||||
await writeFile(executionPath, makeModernExecutionFile({
|
||||
executionId: 'exec-modern',
|
||||
sessionId: 'session-modern',
|
||||
modelId: 'claude-sonnet-4.5',
|
||||
userPrompt: 'summarize this workspace',
|
||||
assistantResponse: 'I reviewed it. <tool_use><name>runCommand</name></tool_use>',
|
||||
}))
|
||||
|
||||
const source = { path: executionPath, project: 'test', provider: 'kiro' }
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of kiro.createSessionParser(source, new Set()).parse()) calls.push(call)
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
const call = calls[0]!
|
||||
expect(call.provider).toBe('kiro')
|
||||
expect(call.model).toBe('claude-sonnet-4-5')
|
||||
expect(call.sessionId).toBe('session-modern')
|
||||
expect(call.userMessage).toBe('summarize this workspace')
|
||||
expect(call.inputTokens).toBeGreaterThan(0)
|
||||
expect(call.outputTokens).toBeGreaterThan(0)
|
||||
expect(call.tools).toEqual(['Bash', 'Read'])
|
||||
expect(call.costUSD).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('skips session index files without conversation content', async () => {
|
||||
const wsHash = 'j'.repeat(32)
|
||||
const wsDir = join(tmpDir, wsHash)
|
||||
await mkdir(wsDir, { recursive: true })
|
||||
const indexPath = join(wsDir, 'session-index')
|
||||
await writeFile(indexPath, JSON.stringify({
|
||||
executions: [{
|
||||
executionId: 'exec-indexed',
|
||||
type: 'chat-agent',
|
||||
status: 'succeed',
|
||||
startTime: 1777333000000,
|
||||
endTime: 1777333010000,
|
||||
}],
|
||||
}))
|
||||
|
||||
const source = { path: indexPath, project: 'test', provider: 'kiro' }
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of kiro.createSessionParser(source, new Set()).parse()) calls.push(call)
|
||||
|
||||
expect(calls).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('parses direct prompt and response fields from modern execution files', async () => {
|
||||
const wsHash = 'k'.repeat(32)
|
||||
const wsDir = join(tmpDir, wsHash)
|
||||
await mkdir(wsDir, { recursive: true })
|
||||
const executionPath = join(wsDir, 'execution-direct')
|
||||
await writeFile(executionPath, JSON.stringify({
|
||||
executionId: 'exec-direct',
|
||||
workflowType: 'chat-agent',
|
||||
status: 'succeed',
|
||||
startTime: 1777333000000,
|
||||
model: { id: 'auto' },
|
||||
prompt: 'make a small change',
|
||||
response: 'Changed it. <tool_use><name>writeFile</name></tool_use>',
|
||||
}))
|
||||
|
||||
const source = { path: executionPath, project: 'test', provider: 'kiro' }
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of kiro.createSessionParser(source, new Set()).parse()) calls.push(call)
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]!.model).toBe('kiro-auto')
|
||||
expect(calls[0]!.userMessage).toBe('make a small change')
|
||||
expect(calls[0]!.tools).toEqual(['Edit'])
|
||||
})
|
||||
|
||||
it('accepts second-based modern timestamps', async () => {
|
||||
const wsHash = 'n'.repeat(32)
|
||||
const wsDir = join(tmpDir, wsHash)
|
||||
await mkdir(wsDir, { recursive: true })
|
||||
const executionPath = join(wsDir, 'execution-seconds')
|
||||
await writeFile(executionPath, makeModernExecutionFile({
|
||||
executionId: 'exec-seconds',
|
||||
startTime: 1777333000,
|
||||
}))
|
||||
|
||||
const source = { path: executionPath, project: 'test', provider: 'kiro' }
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of kiro.createSessionParser(source, new Set()).parse()) calls.push(call)
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]!.timestamp).toBe('2026-04-27T23:36:40.000Z')
|
||||
})
|
||||
|
||||
it('accepts numeric-string modern timestamps', async () => {
|
||||
const wsHash = 'o'.repeat(32)
|
||||
const wsDir = join(tmpDir, wsHash)
|
||||
await mkdir(wsDir, { recursive: true })
|
||||
const executionPath = join(wsDir, 'execution-string-time')
|
||||
await writeFile(executionPath, makeModernExecutionFile({
|
||||
executionId: 'exec-string-time',
|
||||
startTime: '1777333000000',
|
||||
}))
|
||||
|
||||
const source = { path: executionPath, project: 'test', provider: 'kiro' }
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of kiro.createSessionParser(source, new Set()).parse()) calls.push(call)
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]!.timestamp).toBe('2026-04-27T23:36:40.000Z')
|
||||
})
|
||||
|
||||
it('does not poison dedup keys when a modern execution has an invalid timestamp', async () => {
|
||||
const wsHash = 'p'.repeat(32)
|
||||
const wsDir = join(tmpDir, wsHash)
|
||||
await mkdir(wsDir, { recursive: true })
|
||||
const invalidPath = join(wsDir, 'execution-invalid-time')
|
||||
const validPath = join(wsDir, 'execution-valid-time')
|
||||
const shared = {
|
||||
executionId: 'exec-recovered',
|
||||
sessionId: 'session-recovered',
|
||||
}
|
||||
await writeFile(invalidPath, makeModernExecutionFile({
|
||||
...shared,
|
||||
startTime: 'not-a-timestamp',
|
||||
}))
|
||||
await writeFile(validPath, makeModernExecutionFile({
|
||||
...shared,
|
||||
startTime: 1777333000000,
|
||||
}))
|
||||
|
||||
const seenKeys = new Set<string>()
|
||||
const invalidCalls: ParsedProviderCall[] = []
|
||||
for await (const call of kiro.createSessionParser({ path: invalidPath, project: 'test', provider: 'kiro' }, seenKeys).parse()) {
|
||||
invalidCalls.push(call)
|
||||
}
|
||||
const validCalls: ParsedProviderCall[] = []
|
||||
for await (const call of kiro.createSessionParser({ path: validPath, project: 'test', provider: 'kiro' }, seenKeys).parse()) {
|
||||
validCalls.push(call)
|
||||
}
|
||||
|
||||
expect(invalidCalls).toHaveLength(0)
|
||||
expect(validCalls).toHaveLength(1)
|
||||
})
|
||||
|
||||
it.each(['conversation', 'chat', 'transcript', 'entries', 'events'])('parses modern execution conversation arrays from %s', async (key) => {
|
||||
const wsHash = 'q'.repeat(32)
|
||||
const wsDir = join(tmpDir, wsHash)
|
||||
await mkdir(wsDir, { recursive: true })
|
||||
const executionPath = join(wsDir, `execution-${key}`)
|
||||
await writeFile(executionPath, JSON.stringify({
|
||||
executionId: `exec-${key}`,
|
||||
workflowType: 'chat-agent',
|
||||
status: 'succeed',
|
||||
startTime: 1777333000000,
|
||||
modelId: 'claude-sonnet-4.5',
|
||||
[key]: [
|
||||
{ role: 'user', content: `request from ${key}` },
|
||||
{ role: 'assistant', content: `response from ${key}`, toolCalls: [{ name: 'readFile' }] },
|
||||
],
|
||||
}))
|
||||
|
||||
const source = { path: executionPath, project: 'test', provider: 'kiro' }
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of kiro.createSessionParser(source, new Set()).parse()) calls.push(call)
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]!.userMessage).toBe(`request from ${key}`)
|
||||
expect(calls[0]!.tools).toEqual(['Read'])
|
||||
})
|
||||
|
||||
it('keeps modern executions with structured assistant tool calls and no assistant text', async () => {
|
||||
const wsHash = 'l'.repeat(32)
|
||||
const wsDir = join(tmpDir, wsHash, 'session-tools')
|
||||
await mkdir(wsDir, { recursive: true })
|
||||
const executionPath = join(wsDir, 'execution-tools')
|
||||
await writeFile(executionPath, JSON.stringify({
|
||||
executionId: 'exec-tools',
|
||||
sessionId: 'session-tools',
|
||||
workflowType: 'chat-agent',
|
||||
status: 'succeed',
|
||||
startTime: 1777333000000,
|
||||
modelId: 'claude-sonnet-4.5',
|
||||
messages: [
|
||||
{ role: 'user', content: 'run the test suite' },
|
||||
{ role: 'assistant', toolCalls: [{ name: 'runCommand' }] },
|
||||
],
|
||||
}))
|
||||
|
||||
const source = { path: executionPath, project: 'test', provider: 'kiro' }
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of kiro.createSessionParser(source, new Set()).parse()) calls.push(call)
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]!.tools).toEqual(['Bash'])
|
||||
expect(calls[0]!.inputTokens).toBeGreaterThan(0)
|
||||
expect(calls[0]!.outputTokens).toBe(0)
|
||||
})
|
||||
|
||||
it('keeps direct modern executions with root tool calls and no response text', async () => {
|
||||
const wsHash = 'm'.repeat(32)
|
||||
const wsDir = join(tmpDir, wsHash)
|
||||
await mkdir(wsDir, { recursive: true })
|
||||
const executionPath = join(wsDir, 'execution-root-tools')
|
||||
await writeFile(executionPath, JSON.stringify({
|
||||
executionId: 'exec-root-tools',
|
||||
workflowType: 'chat-agent',
|
||||
status: 'succeed',
|
||||
startTime: 1777333000000,
|
||||
model: { id: 'auto' },
|
||||
name: 'workflow-name',
|
||||
prompt: 'edit a file',
|
||||
toolCalls: [{ name: 'writeFile' }],
|
||||
}))
|
||||
|
||||
const source = { path: executionPath, project: 'test', provider: 'kiro' }
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of kiro.createSessionParser(source, new Set()).parse()) calls.push(call)
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]!.tools).toEqual(['Edit'])
|
||||
expect(calls[0]!.tools).not.toContain('workflow-name')
|
||||
expect(calls[0]!.outputTokens).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('kiro provider - discoverSessions', () => {
|
||||
beforeEach(async () => {
|
||||
tmpDir = await mkdtemp(join(tmpdir(), 'kiro-test-'))
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('discovers chat files from workspace hash directories', async () => {
|
||||
const wsHash = 'a1b2c3d4e5f6'.padEnd(32, '0')
|
||||
const wsDir = join(tmpDir, wsHash)
|
||||
await mkdir(wsDir, { recursive: true })
|
||||
await writeFile(join(wsDir, 'session1.chat'), makeChatFile({}))
|
||||
await writeFile(join(wsDir, 'session2.chat'), makeChatFile({}))
|
||||
|
||||
const provider = createKiroProvider(tmpDir, '/nonexistent/ws')
|
||||
const sessions = await provider.discoverSessions()
|
||||
|
||||
expect(sessions).toHaveLength(2)
|
||||
expect(sessions.every(s => s.provider === 'kiro')).toBe(true)
|
||||
expect(sessions.every(s => s.path.endsWith('.chat'))).toBe(true)
|
||||
})
|
||||
|
||||
it('discovers extensionless session index files and nested execution files', async () => {
|
||||
const wsHash = 'd'.repeat(32)
|
||||
const wsDir = join(tmpDir, wsHash)
|
||||
const sessionDir = join(wsDir, 'session-dir')
|
||||
await mkdir(sessionDir, { recursive: true })
|
||||
await writeFile(join(wsDir, 'session-index'), JSON.stringify({ executions: [] }))
|
||||
await writeFile(join(wsDir, 'legacy.chat'), makeChatFile({}))
|
||||
await writeFile(join(wsDir, 'ignored.json'), '{}')
|
||||
await writeFile(join(wsDir, '.DS_Store'), 'ignored')
|
||||
await writeFile(join(sessionDir, 'execution-1'), makeModernExecutionFile({}))
|
||||
await writeFile(join(sessionDir, '.hidden'), 'ignored')
|
||||
await writeFile(join(sessionDir, 'ignored.txt'), 'hello')
|
||||
|
||||
const provider = createKiroProvider(tmpDir, '/nonexistent/ws')
|
||||
const sessions = await provider.discoverSessions()
|
||||
const paths = sessions.map(s => s.path).sort()
|
||||
|
||||
expect(paths).toEqual([
|
||||
join(sessionDir, 'execution-1'),
|
||||
join(wsDir, 'legacy.chat'),
|
||||
join(wsDir, 'session-index'),
|
||||
].sort())
|
||||
})
|
||||
|
||||
it('reads project name from workspace.json', async () => {
|
||||
const wsHash = 'b'.repeat(32)
|
||||
const agentWsDir = join(tmpDir, wsHash)
|
||||
await mkdir(agentWsDir, { recursive: true })
|
||||
await writeFile(join(agentWsDir, 'test.chat'), makeChatFile({}))
|
||||
|
||||
const workspaceStorageDir = join(tmpDir, 'ws-storage')
|
||||
const wsStorageEntry = join(workspaceStorageDir, wsHash)
|
||||
await mkdir(wsStorageEntry, { recursive: true })
|
||||
await writeFile(join(wsStorageEntry, 'workspace.json'), JSON.stringify({ folder: 'file:///home/user/myapp' }))
|
||||
|
||||
const provider = createKiroProvider(tmpDir, workspaceStorageDir)
|
||||
const sessions = await provider.discoverSessions()
|
||||
|
||||
expect(sessions).toHaveLength(1)
|
||||
expect(sessions[0]!.project).toBe('myapp')
|
||||
})
|
||||
|
||||
it('returns empty when directory does not exist', async () => {
|
||||
const provider = createKiroProvider('/nonexistent/agent', '/nonexistent/ws')
|
||||
const sessions = await provider.discoverSessions()
|
||||
expect(sessions).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('skips non-32-char directories', async () => {
|
||||
const shortDir = join(tmpDir, 'short')
|
||||
await mkdir(shortDir, { recursive: true })
|
||||
await writeFile(join(shortDir, 'test.chat'), makeChatFile({}))
|
||||
|
||||
const provider = createKiroProvider(tmpDir, '/nonexistent/ws')
|
||||
const sessions = await provider.discoverSessions()
|
||||
expect(sessions).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('skips files with unsupported extensions', async () => {
|
||||
const wsHash = 'c'.repeat(32)
|
||||
const wsDir = join(tmpDir, wsHash)
|
||||
await mkdir(wsDir, { recursive: true })
|
||||
await writeFile(join(wsDir, 'index.json'), '{}')
|
||||
await writeFile(join(wsDir, 'notes.txt'), 'hello')
|
||||
|
||||
const provider = createKiroProvider(tmpDir, '/nonexistent/ws')
|
||||
const sessions = await provider.discoverSessions()
|
||||
expect(sessions).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('kiro provider - metadata', () => {
|
||||
it('has correct name and displayName', () => {
|
||||
expect(kiro.name).toBe('kiro')
|
||||
expect(kiro.displayName).toBe('Kiro')
|
||||
})
|
||||
|
||||
it('normalizes model display names', () => {
|
||||
expect(kiro.modelDisplayName('claude-haiku-4-5')).toBe('Haiku 4.5')
|
||||
expect(kiro.modelDisplayName('claude-sonnet-4-5')).toBe('Sonnet 4.5')
|
||||
expect(kiro.modelDisplayName('claude-sonnet-4-6')).toBe('Sonnet 4.6')
|
||||
expect(kiro.modelDisplayName('unknown-model')).toBe('unknown-model')
|
||||
})
|
||||
|
||||
it('normalizes tool display names', () => {
|
||||
expect(kiro.toolDisplayName('readFile')).toBe('Read')
|
||||
expect(kiro.toolDisplayName('writeFile')).toBe('Edit')
|
||||
expect(kiro.toolDisplayName('runCommand')).toBe('Bash')
|
||||
expect(kiro.toolDisplayName('searchFiles')).toBe('Grep')
|
||||
expect(kiro.toolDisplayName('unknown_tool')).toBe('unknown_tool')
|
||||
})
|
||||
|
||||
it('normalizes CLI-specific tool names', () => {
|
||||
expect(kiro.toolDisplayName('code')).toBe('Read')
|
||||
expect(kiro.toolDisplayName('subagent')).toBe('Agent')
|
||||
expect(kiro.toolDisplayName('web_fetch')).toBe('WebFetch')
|
||||
})
|
||||
|
||||
it('passes through MCP tool names unchanged', () => {
|
||||
expect(kiro.toolDisplayName('mcp__server__searchJira')).toBe('mcp__server__searchJira')
|
||||
expect(kiro.toolDisplayName('mcp__atlassian__getIssue')).toBe('mcp__atlassian__getIssue')
|
||||
})
|
||||
|
||||
it('longest-prefix match for versioned model IDs', () => {
|
||||
expect(kiro.modelDisplayName('claude-sonnet-4-5-20260101')).toBe('Sonnet 4.5')
|
||||
expect(kiro.modelDisplayName('claude-haiku-4-5-20260101')).toBe('Haiku 4.5')
|
||||
})
|
||||
})
|
||||
|
||||
describe('kiro provider - CLI session discovery', () => {
|
||||
let cliDir: string
|
||||
|
||||
beforeEach(async () => {
|
||||
cliDir = await mkdtemp(join(tmpdir(), 'kiro-cli-test-'))
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(cliDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('discovers .jsonl files from CLI sessions directory', async () => {
|
||||
const sessionId = '11111111-1111-1111-1111-111111111111'
|
||||
await writeFile(join(cliDir, `${sessionId}.jsonl`), '')
|
||||
await writeFile(join(cliDir, `${sessionId}.json`), JSON.stringify({
|
||||
session_id: sessionId,
|
||||
cwd: '/home/user/my-project',
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
updated_at: '2026-01-01T00:01:00Z',
|
||||
}))
|
||||
|
||||
const provider = createKiroProvider('/nonexistent', '/nonexistent', cliDir)
|
||||
const sessions = await provider.discoverSessions()
|
||||
expect(sessions).toHaveLength(1)
|
||||
expect(sessions[0]!.project).toBe('my-project')
|
||||
expect(sessions[0]!.path).toContain('.jsonl')
|
||||
expect(sessions[0]!.provider).toBe('kiro')
|
||||
})
|
||||
|
||||
it('parses CLI session JSONL into calls', async () => {
|
||||
const sessionId = '22222222-2222-2222-2222-222222222222'
|
||||
const jsonl = [
|
||||
JSON.stringify({ version: '1', kind: 'Prompt', data: { message_id: 'm1', content: [{ kind: 'text', data: 'hello world' }], meta: { timestamp: 1700000000 } } }),
|
||||
JSON.stringify({ version: '1', kind: 'AssistantMessage', data: { message_id: 'm2', content: [{ kind: 'text', data: 'Hello! How can I help you today?' }, { kind: 'toolUse', data: { toolUseId: 't1', name: 'read', input: {} } }] } }),
|
||||
JSON.stringify({ version: '1', kind: 'ToolResults', data: { message_id: 'm3', content: [{ kind: 'text', data: 'file contents here' }], results: { t1: { output: 'ok' } } } }),
|
||||
JSON.stringify({ version: '1', kind: 'AssistantMessage', data: { message_id: 'm4', content: [{ kind: 'text', data: 'I read the file for you.' }] } }),
|
||||
].join('\n')
|
||||
|
||||
await writeFile(join(cliDir, `${sessionId}.jsonl`), jsonl)
|
||||
await writeFile(join(cliDir, `${sessionId}.json`), JSON.stringify({
|
||||
session_id: sessionId,
|
||||
cwd: '/tmp/test-project',
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
updated_at: '2026-01-01T00:01:00Z',
|
||||
session_state: {
|
||||
rts_model_state: { model_info: { model_id: 'auto' } },
|
||||
conversation_metadata: {
|
||||
user_turn_metadatas: [{
|
||||
end_timestamp: '2026-01-01T00:00:30Z',
|
||||
metering_usage: [{ value: 0.05, unit: 'credit' }, { value: 0.08, unit: 'credit' }],
|
||||
}],
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
const source = { path: join(cliDir, `${sessionId}.jsonl`), project: 'test-project', provider: 'kiro' }
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of kiro.createSessionParser(source, new Set()).parse()) calls.push(call)
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
const call = calls[0]!
|
||||
expect(call.provider).toBe('kiro')
|
||||
expect(call.model).toBe('kiro-auto')
|
||||
expect(call.tools).toContain('Read')
|
||||
expect(call.userMessage).toBe('hello world')
|
||||
expect(call.costUSD).toBeCloseTo(0.13, 2)
|
||||
expect(call.deduplicationKey).toBe(`kiro-cli:${sessionId}:0`)
|
||||
expect(call.timestamp).toBe('2026-01-01T00:00:30.000Z')
|
||||
expect(call.project).toBe('test-project')
|
||||
})
|
||||
|
||||
it('parses multiple turns from a CLI session', async () => {
|
||||
const sessionId = '33333333-3333-3333-3333-333333333333'
|
||||
const jsonl = [
|
||||
JSON.stringify({ version: '1', kind: 'Prompt', data: { message_id: 'm1', content: [{ kind: 'text', data: 'first question' }], meta: { timestamp: 1700000000 } } }),
|
||||
JSON.stringify({ version: '1', kind: 'AssistantMessage', data: { message_id: 'm2', content: [{ kind: 'text', data: 'first answer' }] } }),
|
||||
JSON.stringify({ version: '1', kind: 'Prompt', data: { message_id: 'm3', content: [{ kind: 'text', data: 'second question' }], meta: { timestamp: 1700000060 } } }),
|
||||
JSON.stringify({ version: '1', kind: 'AssistantMessage', data: { message_id: 'm4', content: [{ kind: 'text', data: 'second answer' }] } }),
|
||||
].join('\n')
|
||||
|
||||
await writeFile(join(cliDir, `${sessionId}.jsonl`), jsonl)
|
||||
await writeFile(join(cliDir, `${sessionId}.json`), JSON.stringify({
|
||||
session_id: sessionId,
|
||||
cwd: '/tmp/multi',
|
||||
created_at: '2026-01-01T00:00:00Z',
|
||||
updated_at: '2026-01-01T00:02:00Z',
|
||||
session_state: {
|
||||
rts_model_state: { model_info: { model_id: 'claude-sonnet-4' } },
|
||||
conversation_metadata: {
|
||||
user_turn_metadatas: [
|
||||
{ end_timestamp: '2026-01-01T00:00:30Z', metering_usage: [{ value: 0.04, unit: 'credit' }] },
|
||||
{ end_timestamp: '2026-01-01T00:01:30Z', metering_usage: [{ value: 0.06, unit: 'credit' }] },
|
||||
],
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
const source = { path: join(cliDir, `${sessionId}.jsonl`), project: 'multi', provider: 'kiro' }
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of kiro.createSessionParser(source, new Set()).parse()) calls.push(call)
|
||||
|
||||
expect(calls).toHaveLength(2)
|
||||
expect(calls[0]!.userMessage).toBe('first question')
|
||||
expect(calls[0]!.model).toBe('claude-sonnet-4')
|
||||
expect(calls[1]!.userMessage).toBe('second question')
|
||||
expect(calls[1]!.costUSD).toBeCloseTo(0.06, 2)
|
||||
})
|
||||
|
||||
it('skips non-jsonl files in CLI directory', async () => {
|
||||
await writeFile(join(cliDir, 'something.json'), '{}')
|
||||
await writeFile(join(cliDir, 'something.lock'), '')
|
||||
|
||||
const provider = createKiroProvider('/nonexistent', '/nonexistent', cliDir)
|
||||
const sessions = await provider.discoverSessions()
|
||||
expect(sessions).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('kiro provider - context.messages with entries', () => {
|
||||
beforeEach(async () => {
|
||||
tmpDir = await mkdtemp(join(tmpdir(), 'kiro-ctx-'))
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('parses context.messages using entries field', async () => {
|
||||
// Simulates the real Kiro IDE format where messages use "entries" not "content"
|
||||
const file = JSON.stringify({
|
||||
executionId: 'exec-ctx-001',
|
||||
workflowType: 'chat-agent',
|
||||
status: 'succeed',
|
||||
startTime: 1777333000000,
|
||||
chatSessionId: 'session-ctx-001',
|
||||
context: {
|
||||
messages: [
|
||||
{ role: 'human', entries: ['What is the meaning of life?'] },
|
||||
{ role: 'bot', entries: ['The meaning of life is 42, according to Douglas Adams.'] },
|
||||
{ role: 'human', entries: ['Tell me more'] },
|
||||
{ role: 'bot', entries: ['The answer comes from The Hitchhiker\'s Guide to the Galaxy.'] },
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
const wsHash = 'a'.repeat(32)
|
||||
const subDir = 'b'.repeat(32)
|
||||
await mkdir(join(tmpDir, wsHash, subDir), { recursive: true })
|
||||
await writeFile(join(tmpDir, wsHash, subDir, 'exec-ctx-001'), file)
|
||||
|
||||
const provider = createKiroProvider(tmpDir, tmpDir, '/nonexistent')
|
||||
const sessions = await provider.discoverSessions()
|
||||
expect(sessions.length).toBeGreaterThan(0)
|
||||
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for (const source of sessions) {
|
||||
for await (const call of provider.createSessionParser(source, new Set()).parse()) {
|
||||
calls.push(call)
|
||||
}
|
||||
}
|
||||
|
||||
expect(calls.length).toBeGreaterThan(0)
|
||||
const call = calls[0]!
|
||||
expect(call.inputTokens).toBeGreaterThan(0)
|
||||
expect(call.outputTokens).toBeGreaterThan(0)
|
||||
expect(call.sessionId).toBe('session-ctx-001')
|
||||
})
|
||||
|
||||
it('extracts tools from usageSummary', async () => {
|
||||
const file = JSON.stringify({
|
||||
executionId: 'exec-tools-001',
|
||||
workflowType: 'chat-agent',
|
||||
status: 'succeed',
|
||||
startTime: 1777333000000,
|
||||
chatSessionId: 'session-tools-001',
|
||||
context: {
|
||||
messages: [
|
||||
{ role: 'human', entries: ['Search for accounts'] },
|
||||
{ role: 'bot', entries: ['Found 5 accounts.'] },
|
||||
],
|
||||
},
|
||||
usageSummary: [
|
||||
{ usedTools: ['mcp_aws_sentral_mcp_search_accounts'], usage: 0.5, unit: 'credit' },
|
||||
{ usedTools: ['executeBash', 'readFile'], usage: 1.0, unit: 'credit' },
|
||||
],
|
||||
})
|
||||
|
||||
const wsHash = 'c'.repeat(32)
|
||||
const subDir = 'd'.repeat(32)
|
||||
await mkdir(join(tmpDir, wsHash, subDir), { recursive: true })
|
||||
await writeFile(join(tmpDir, wsHash, subDir, 'exec-tools-001'), file)
|
||||
|
||||
const provider = createKiroProvider(tmpDir, tmpDir, '/nonexistent')
|
||||
const sessions = await provider.discoverSessions()
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for (const source of sessions) {
|
||||
for await (const call of provider.createSessionParser(source, new Set()).parse()) {
|
||||
calls.push(call)
|
||||
}
|
||||
}
|
||||
|
||||
expect(calls.length).toBeGreaterThan(0)
|
||||
const call = calls[0]!
|
||||
expect(call.tools).toContain('aws_sentral_mcp_search_accounts')
|
||||
expect(call.tools).toContain('Bash')
|
||||
expect(call.tools).toContain('Read')
|
||||
})
|
||||
|
||||
it('skips execution index files with executions array', async () => {
|
||||
// The session index file has {executions: [...], version: 2}
|
||||
const indexFile = JSON.stringify({
|
||||
executions: [
|
||||
{ executionId: 'exec-001', type: 'chat-agent', status: 'succeed', startTime: 1777333000000 },
|
||||
],
|
||||
version: 2,
|
||||
})
|
||||
|
||||
const wsHash = 'e'.repeat(32)
|
||||
await mkdir(join(tmpDir, wsHash), { recursive: true })
|
||||
await writeFile(join(tmpDir, wsHash, 'f'.repeat(32)), indexFile)
|
||||
|
||||
const provider = createKiroProvider(tmpDir, tmpDir, '/nonexistent')
|
||||
const sessions = await provider.discoverSessions()
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for (const source of sessions) {
|
||||
for await (const call of provider.createSessionParser(source, new Set()).parse()) {
|
||||
calls.push(call)
|
||||
}
|
||||
}
|
||||
|
||||
expect(calls).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('kiro provider - workspace-sessions format', () => {
|
||||
beforeEach(async () => {
|
||||
tmpDir = await mkdtemp(join(tmpdir(), 'kiro-wss-'))
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('discovers and parses workspace-sessions files', async () => {
|
||||
// Create workspace-sessions/<base64>/<sessionId>.json
|
||||
const wsSessionsDir = join(tmpDir, 'workspace-sessions', 'L3RtcC90ZXN0')
|
||||
await mkdir(wsSessionsDir, { recursive: true })
|
||||
|
||||
const sessionFile = JSON.stringify({
|
||||
sessionId: 'ws-session-001',
|
||||
title: 'Test session',
|
||||
selectedModel: 'claude-opus-4.8',
|
||||
workspaceDirectory: '/tmp/test',
|
||||
history: [
|
||||
{ message: { role: 'user', content: [{ type: 'text', text: 'What is TypeScript?' }] } },
|
||||
{ message: { role: 'assistant', content: 'TypeScript is a typed superset of JavaScript.' } },
|
||||
{ message: { role: 'user', content: [{ type: 'text', text: 'How do I use generics?' }] } },
|
||||
{ message: { role: 'assistant', content: 'Generics allow you to create reusable components.' } },
|
||||
],
|
||||
})
|
||||
|
||||
await writeFile(join(wsSessionsDir, 'ws-session-001.json'), sessionFile)
|
||||
// Also need sessions.json (should be skipped)
|
||||
await writeFile(join(wsSessionsDir, 'sessions.json'), '[]')
|
||||
|
||||
const provider = createKiroProvider(tmpDir, tmpDir, '/nonexistent')
|
||||
const sessions = await provider.discoverSessions()
|
||||
|
||||
const wsSessions = sessions.filter(s => s.path.includes('workspace-sessions'))
|
||||
expect(wsSessions).toHaveLength(1)
|
||||
expect(wsSessions[0]!.path).toContain('ws-session-001.json')
|
||||
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for (const source of wsSessions) {
|
||||
for await (const call of provider.createSessionParser(source, new Set()).parse()) {
|
||||
calls.push(call)
|
||||
}
|
||||
}
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
const call = calls[0]!
|
||||
expect(call.model).toBe('claude-opus-4-8')
|
||||
expect(call.sessionId).toBe('ws-session-001')
|
||||
expect(call.inputTokens).toBeGreaterThan(0)
|
||||
expect(call.outputTokens).toBeGreaterThan(0)
|
||||
expect(call.deduplicationKey).toBe('kiro:ws-session:ws-session-001')
|
||||
})
|
||||
|
||||
it('skips workspace-sessions with only stub assistant replies referencing execution files', async () => {
|
||||
const wsSessionsDir = join(tmpDir, 'workspace-sessions', 'L3RtcC90ZXN0')
|
||||
await mkdir(wsSessionsDir, { recursive: true })
|
||||
|
||||
// Session where assistant only says "On it." with executionId refs
|
||||
// (real output is in execution files — skip to avoid double-counting)
|
||||
const sessionFile = JSON.stringify({
|
||||
sessionId: 'ws-session-stub',
|
||||
selectedModel: 'auto',
|
||||
workspaceDirectory: '/tmp/test',
|
||||
history: [
|
||||
{ message: { role: 'user', content: [{ type: 'text', text: 'Deploy the stack' }] } },
|
||||
{ message: { role: 'assistant', content: 'On it.' }, executionId: 'exec-ref-001' },
|
||||
],
|
||||
})
|
||||
|
||||
await writeFile(join(wsSessionsDir, 'ws-session-stub.json'), sessionFile)
|
||||
|
||||
const provider = createKiroProvider(tmpDir, tmpDir, '/nonexistent')
|
||||
const sessions = await provider.discoverSessions()
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for (const source of sessions) {
|
||||
for await (const call of provider.createSessionParser(source, new Set()).parse()) {
|
||||
calls.push(call)
|
||||
}
|
||||
}
|
||||
|
||||
// Should be skipped: has executionId refs but no real assistant content
|
||||
expect(calls).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('skips sessions.json file in workspace-sessions', async () => {
|
||||
const wsSessionsDir = join(tmpDir, 'workspace-sessions', 'L3RtcC90ZXN0')
|
||||
await mkdir(wsSessionsDir, { recursive: true })
|
||||
await writeFile(join(wsSessionsDir, 'sessions.json'), '[]')
|
||||
|
||||
const provider = createKiroProvider(tmpDir, tmpDir, '/nonexistent')
|
||||
const sessions = await provider.discoverSessions()
|
||||
const wsSessions = sessions.filter(s => s.path.includes('workspace-sessions'))
|
||||
expect(wsSessions).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,207 @@
|
||||
import { mkdir, mkdtemp, rm, writeFile } from 'fs/promises'
|
||||
import { join } from 'path'
|
||||
import { tmpdir } from 'os'
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { createLingTaiTuiProvider } from '../../src/providers/lingtai-tui.js'
|
||||
import type { ParsedProviderCall } from '../../src/providers/types.js'
|
||||
|
||||
let tmpDir: string
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await mkdtemp(join(tmpdir(), 'lingtai-tui-provider-test-'))
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
async function writeAgent(name: string, opts: {
|
||||
home?: string
|
||||
manifest?: Record<string, unknown>
|
||||
ledgerLines?: Array<Record<string, unknown> | string>
|
||||
} = {}): Promise<string> {
|
||||
const home = opts.home ?? tmpDir
|
||||
const agentDir = join(home, name)
|
||||
await mkdir(join(agentDir, 'logs'), { recursive: true })
|
||||
if (opts.manifest) {
|
||||
await writeFile(join(agentDir, '.agent.json'), JSON.stringify(opts.manifest, null, 2))
|
||||
}
|
||||
const ledgerPath = join(agentDir, 'logs', 'token_ledger.jsonl')
|
||||
const lines = opts.ledgerLines ?? []
|
||||
await writeFile(
|
||||
ledgerPath,
|
||||
lines.map(line => typeof line === 'string' ? line : JSON.stringify(line)).join('\n') + (lines.length ? '\n' : ''),
|
||||
)
|
||||
return ledgerPath
|
||||
}
|
||||
|
||||
async function collectCalls(provider: ReturnType<typeof createLingTaiTuiProvider>, sourcePath: string, project = 'agent'): Promise<ParsedProviderCall[]> {
|
||||
const calls: ParsedProviderCall[] = []
|
||||
const parser = provider.createSessionParser({ path: sourcePath, project, provider: 'lingtai-tui' }, new Set())
|
||||
for await (const call of parser.parse()) calls.push(call)
|
||||
return calls
|
||||
}
|
||||
|
||||
describe('lingtai-tui provider', () => {
|
||||
it('discovers top-level LingTai token ledgers', async () => {
|
||||
const ledgerPath = await writeAgent('agent-a', {
|
||||
manifest: {
|
||||
agent_id: 'agent-001',
|
||||
agent_name: 'Operator Agent',
|
||||
llm: { model: 'gpt-5.5', base_url: 'example-endpoint' },
|
||||
},
|
||||
ledgerLines: [
|
||||
{ source: 'main', ts: '2026-06-04T01:25:09Z', input: 100, output: 20, thinking: 5, cached: 10, model: 'gpt-5.5' },
|
||||
],
|
||||
})
|
||||
await mkdir(join(tmpDir, 'agent-a', 'daemons', 'em-1', 'logs'), { recursive: true })
|
||||
await writeFile(join(tmpDir, 'agent-a', 'daemons', 'em-1', 'logs', 'token_ledger.jsonl'), '{}\n')
|
||||
|
||||
const provider = createLingTaiTuiProvider(tmpDir)
|
||||
const sessions = await provider.discoverSessions()
|
||||
|
||||
expect(sessions).toEqual([{ path: ledgerPath, project: 'Operator Agent', provider: 'lingtai-tui' }])
|
||||
})
|
||||
|
||||
it('discovers project-local LingTai homes from the TUI registry', async () => {
|
||||
const defaultHome = join(tmpDir, 'home', '.lingtai')
|
||||
const globalDir = join(tmpDir, 'home', '.lingtai-tui')
|
||||
const projectRoot = join(tmpDir, 'projects', 'sample-project')
|
||||
const projectHome = join(projectRoot, '.lingtai')
|
||||
|
||||
const defaultLedger = await writeAgent('personal', {
|
||||
home: defaultHome,
|
||||
manifest: { agent_name: 'Personal Agent' },
|
||||
ledgerLines: [{ ts: '2026-07-06T01:00:00Z', input: 1, output: 1, model: 'gpt-5.5' }],
|
||||
})
|
||||
const projectLedger = await writeAgent('lingtai', {
|
||||
home: projectHome,
|
||||
manifest: { agent_name: 'Project Agent' },
|
||||
ledgerLines: [{ ts: '2026-07-06T02:00:00Z', input: 2, output: 2, model: 'gpt-5.5' }],
|
||||
})
|
||||
await mkdir(globalDir, { recursive: true })
|
||||
await writeFile(join(globalDir, 'registry.jsonl'), JSON.stringify({ path: projectRoot }) + '\n')
|
||||
|
||||
const provider = createLingTaiTuiProvider({
|
||||
defaultHomeOverride: defaultHome,
|
||||
globalDirOverride: globalDir,
|
||||
cwdOverride: join(tmpDir, 'elsewhere'),
|
||||
})
|
||||
const sessions = await provider.discoverSessions()
|
||||
|
||||
expect(sessions).toEqual([
|
||||
{ path: defaultLedger, project: 'Personal Agent', provider: 'lingtai-tui' },
|
||||
{ path: projectLedger, project: 'sample-project-Project Agent', provider: 'lingtai-tui' },
|
||||
])
|
||||
})
|
||||
|
||||
it('parses ledger entries and separates cached input from fresh input', async () => {
|
||||
const ledgerPath = await writeAgent('agent-b', {
|
||||
manifest: {
|
||||
agent_id: 'agent-002',
|
||||
address: 'agent-b',
|
||||
llm: { model: 'fallback-model', base_url: 'fallback-endpoint' },
|
||||
},
|
||||
ledgerLines: [
|
||||
{ source: 'main', ts: '2026-06-04T01:25:09Z', input: 100, output: 20, thinking: 5, cached: 10, model: 'gpt-5.5', endpoint: 'example-endpoint' },
|
||||
{ source: 'tc_wake', ts: '2026-06-04T01:28:24Z', input: 25, output: 5, thinking: 0, cached: 10 },
|
||||
{ source: 'summarize_apriori', ts: '2026-06-04T01:29:24Z', input: 40, output: 10, thinking: 0, cached: 20 },
|
||||
{ source: 'daemon', em_id: 'em-1', run_id: 'run-1', ts: '2026-06-04T01:30:24Z', input: 50, output: 10, thinking: 0, cached: 50 },
|
||||
],
|
||||
})
|
||||
|
||||
const calls = await collectCalls(createLingTaiTuiProvider(tmpDir), ledgerPath, 'agent-b')
|
||||
|
||||
expect(calls).toHaveLength(4)
|
||||
expect(calls[0]).toMatchObject({
|
||||
provider: 'lingtai-tui',
|
||||
model: 'gpt-5.5',
|
||||
inputTokens: 90,
|
||||
outputTokens: 20,
|
||||
reasoningTokens: 5,
|
||||
cacheReadInputTokens: 10,
|
||||
cachedInputTokens: 10,
|
||||
timestamp: '2026-06-04T01:25:09.000Z',
|
||||
userMessage: 'LingTai main conversation',
|
||||
sessionId: 'agent-002:main',
|
||||
project: 'agent-b',
|
||||
})
|
||||
expect(calls[1]).toMatchObject({
|
||||
tools: ['Agent'],
|
||||
subagentTypes: ['lingtai-task-coordinator'],
|
||||
userMessage: 'LingTai task coordinator wake',
|
||||
sessionId: 'agent-002:tc_wake',
|
||||
})
|
||||
expect(calls[2]).toMatchObject({
|
||||
tools: ['EnterPlanMode'],
|
||||
subagentTypes: [],
|
||||
userMessage: 'LingTai planning summary',
|
||||
sessionId: 'agent-002:summarize_apriori',
|
||||
})
|
||||
expect(calls[1]).toMatchObject({
|
||||
model: 'fallback-model',
|
||||
})
|
||||
expect(calls[3]).toMatchObject({
|
||||
model: 'fallback-model',
|
||||
inputTokens: 0,
|
||||
cacheReadInputTokens: 50,
|
||||
tools: ['Agent'],
|
||||
subagentTypes: ['lingtai-daemon'],
|
||||
sessionId: 'run-1',
|
||||
userMessage: 'LingTai daemon task',
|
||||
})
|
||||
expect(calls[0]!.deduplicationKey).not.toBe(calls[3]!.deduplicationKey)
|
||||
})
|
||||
|
||||
it('skips corrupt and zero-token lines', async () => {
|
||||
const ledgerPath = await writeAgent('agent-c', {
|
||||
ledgerLines: [
|
||||
'not json',
|
||||
{ source: 'main', ts: '2026-06-04T01:25:09Z', input: 0, output: 0, thinking: 0, cached: 0, model: 'gpt-5.5' },
|
||||
{ source: 'main', ts: '2026-06-04T01:26:09Z', input: 1, output: 2, thinking: 3, cached: 0, model: 'gpt-5.5' },
|
||||
],
|
||||
})
|
||||
|
||||
const calls = await collectCalls(createLingTaiTuiProvider(tmpDir), ledgerPath)
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]!.inputTokens).toBe(1)
|
||||
expect(calls[0]!.outputTokens).toBe(2)
|
||||
expect(calls[0]!.reasoningTokens).toBe(3)
|
||||
})
|
||||
|
||||
it('uses shared model display names', () => {
|
||||
const provider = createLingTaiTuiProvider(tmpDir)
|
||||
expect(provider.modelDisplayName('claude-sonnet-4-6')).toBe('Sonnet 4.6')
|
||||
expect(provider.modelDisplayName('some-future-model')).toBe('some-future-model')
|
||||
})
|
||||
|
||||
// A planted .agent.json can be valid JSON with wrong-typed fields. Before the
|
||||
// manifest was normalized, an object-valued agent_name reached
|
||||
// sanitizeProject().trim() and threw — and discoverAllSessions loops providers
|
||||
// with no try/catch, so that one file broke usage discovery for EVERY
|
||||
// provider. Discovery and parsing must both survive it.
|
||||
it('does not crash on a valid-JSON manifest with wrong-typed fields', async () => {
|
||||
const ledgerPath = await writeAgent('agent-hostile', {
|
||||
manifest: {
|
||||
agent_name: {},
|
||||
agent_id: [1, 2],
|
||||
address: 42,
|
||||
nickname: { x: 1 },
|
||||
llm: { model: {}, base_url: [] },
|
||||
},
|
||||
ledgerLines: [
|
||||
{ source: 'main', ts: '2026-06-04T01:26:09Z', input: 10, output: 5, model: 'gpt-5.5' },
|
||||
],
|
||||
})
|
||||
|
||||
const provider = createLingTaiTuiProvider(tmpDir)
|
||||
await expect(provider.discoverSessions()).resolves.toBeInstanceOf(Array)
|
||||
const calls = await collectCalls(provider, ledgerPath)
|
||||
expect(calls).toHaveLength(1)
|
||||
// Wrong-typed manifest name falls back to the sanitized agent directory.
|
||||
expect(typeof calls[0]!.model).toBe('string')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,314 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||
import { mkdtemp, mkdir, writeFile, rm } from 'fs/promises'
|
||||
import { join } from 'path'
|
||||
import { tmpdir } from 'os'
|
||||
|
||||
import { createMistralVibeProvider } from '../../src/providers/mistral-vibe.js'
|
||||
import type { ParsedProviderCall } from '../../src/providers/types.js'
|
||||
|
||||
let tmpDir: string
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await mkdtemp(join(tmpdir(), 'mistral-vibe-test-'))
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function metadata(opts: {
|
||||
sessionId?: string
|
||||
cwd?: string
|
||||
input?: number
|
||||
output?: number
|
||||
sessionCost?: number
|
||||
inputPrice?: number
|
||||
outputPrice?: number
|
||||
activeModel?: string
|
||||
modelName?: string
|
||||
configInputPrice?: number
|
||||
configOutputPrice?: number
|
||||
endTime?: string | null
|
||||
title?: string
|
||||
} = {}) {
|
||||
const activeModel = opts.activeModel ?? 'mistral-medium-3.5'
|
||||
return {
|
||||
session_id: opts.sessionId ?? 'session-abc123',
|
||||
start_time: '2026-05-11T10:00:00+00:00',
|
||||
end_time: Object.hasOwn(opts, 'endTime') ? opts.endTime : '2026-05-11T10:05:00+00:00',
|
||||
environment: {
|
||||
working_directory: opts.cwd ?? '/Users/test/mistral-project',
|
||||
},
|
||||
stats: {
|
||||
session_prompt_tokens: opts.input ?? 2000,
|
||||
session_completion_tokens: opts.output ?? 3000,
|
||||
session_cost: opts.sessionCost,
|
||||
input_price_per_million: opts.inputPrice ?? 1.5,
|
||||
output_price_per_million: opts.outputPrice ?? 7.5,
|
||||
tokens_per_second: 42,
|
||||
},
|
||||
config: {
|
||||
active_model: activeModel,
|
||||
models: [
|
||||
{
|
||||
alias: activeModel,
|
||||
name: opts.modelName ?? 'mistral-vibe-cli-latest',
|
||||
provider: 'mistral',
|
||||
input_price: opts.configInputPrice ?? 1.5,
|
||||
output_price: opts.configOutputPrice ?? 7.5,
|
||||
},
|
||||
],
|
||||
},
|
||||
title: opts.title ?? 'implement mistral support',
|
||||
total_messages: 2,
|
||||
}
|
||||
}
|
||||
|
||||
function userMessage(content: unknown = 'implement mistral support') {
|
||||
return {
|
||||
role: 'user',
|
||||
content,
|
||||
message_id: 'msg-user-1',
|
||||
}
|
||||
}
|
||||
|
||||
function assistantMessage(toolCalls: Array<{ name: string; args?: Record<string, unknown> | string }> = []) {
|
||||
return {
|
||||
role: 'assistant',
|
||||
content: 'Done',
|
||||
message_id: 'msg-assistant-1',
|
||||
tool_calls: toolCalls.map((call, idx) => ({
|
||||
id: `tool-${idx}`,
|
||||
type: 'function',
|
||||
function: {
|
||||
name: call.name,
|
||||
arguments: typeof call.args === 'string' ? call.args : JSON.stringify(call.args ?? {}),
|
||||
},
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
async function writeSession(
|
||||
name: string,
|
||||
meta: Record<string, unknown>,
|
||||
messages = [userMessage(), assistantMessage()],
|
||||
root = tmpDir,
|
||||
) {
|
||||
const sessionDir = join(root, name)
|
||||
await mkdir(sessionDir, { recursive: true })
|
||||
await writeFile(join(sessionDir, 'meta.json'), JSON.stringify(meta, null, 2))
|
||||
await writeFile(join(sessionDir, 'messages.jsonl'), messages.map(m => JSON.stringify(m)).join('\n') + '\n')
|
||||
return sessionDir
|
||||
}
|
||||
|
||||
async function collect(sourcePath: string, provider = createMistralVibeProvider(tmpDir)): Promise<ParsedProviderCall[]> {
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of provider.createSessionParser({
|
||||
path: sourcePath,
|
||||
project: 'mistral-project',
|
||||
provider: 'mistral-vibe',
|
||||
}, new Set()).parse()) {
|
||||
calls.push(call)
|
||||
}
|
||||
return calls
|
||||
}
|
||||
|
||||
describe('mistral-vibe provider - session discovery', () => {
|
||||
it('discovers Vibe session folders and derives project from metadata cwd', async () => {
|
||||
const sessionDir = await writeSession('session_20260511_100000_sessiona', metadata({
|
||||
sessionId: 'session-a',
|
||||
cwd: '/Users/test/project-a',
|
||||
}))
|
||||
await mkdir(join(tmpDir, 'not-a-session'), { recursive: true })
|
||||
|
||||
const provider = createMistralVibeProvider(tmpDir)
|
||||
const sessions = await provider.discoverSessions()
|
||||
|
||||
expect(sessions).toHaveLength(1)
|
||||
expect(sessions[0]).toEqual({
|
||||
path: sessionDir,
|
||||
project: 'project-a',
|
||||
provider: 'mistral-vibe',
|
||||
})
|
||||
})
|
||||
|
||||
it('discovers subagent session folders nested under agents', async () => {
|
||||
const parentDir = await writeSession('session_20260511_100000_parent', metadata({
|
||||
sessionId: 'parent-session',
|
||||
cwd: '/Users/test/parent-project',
|
||||
}))
|
||||
const childDir = await writeSession('session_20260511_100001_child', metadata({
|
||||
sessionId: 'child-session',
|
||||
cwd: '/Users/test/child-project',
|
||||
}), [userMessage('child task'), assistantMessage()], join(parentDir, 'agents'))
|
||||
|
||||
const provider = createMistralVibeProvider(tmpDir)
|
||||
const sessions = await provider.discoverSessions()
|
||||
|
||||
expect(sessions.map(s => s.path).sort()).toEqual([childDir, parentDir].sort())
|
||||
expect(sessions.map(s => s.project).sort()).toEqual(['child-project', 'parent-project'])
|
||||
})
|
||||
|
||||
it('returns empty for a missing Vibe sessions directory', async () => {
|
||||
const provider = createMistralVibeProvider('/missing/vibe/logs/session')
|
||||
await expect(provider.discoverSessions()).resolves.toEqual([])
|
||||
})
|
||||
|
||||
it('uses VIBE_HOME when no override directory is provided', async () => {
|
||||
const vibeHome = join(tmpDir, 'vibe-home')
|
||||
process.env['VIBE_HOME'] = vibeHome
|
||||
const sessionsDir = join(vibeHome, 'logs', 'session')
|
||||
await writeSession('session_20260511_100000_sessiona', metadata({
|
||||
sessionId: 'env-session',
|
||||
cwd: '/Users/test/env-project',
|
||||
}), [userMessage(), assistantMessage()], sessionsDir)
|
||||
|
||||
const provider = createMistralVibeProvider()
|
||||
const sessions = await provider.discoverSessions()
|
||||
|
||||
expect(sessions).toHaveLength(1)
|
||||
expect(sessions[0]!.project).toBe('env-project')
|
||||
})
|
||||
})
|
||||
|
||||
describe('mistral-vibe provider - parsing', () => {
|
||||
it('parses cumulative session usage, tools, bash commands, and first user message', async () => {
|
||||
const sessionDir = await writeSession('session_20260511_100000_sessiona', metadata(), [
|
||||
userMessage([{ type: 'text', text: 'track Mistral Vibe usage' }]),
|
||||
assistantMessage([
|
||||
{ name: 'read_file', args: { path: 'src/index.ts' } },
|
||||
{ name: 'search_replace', args: { file_path: 'src/index.ts', content: 'patch' } },
|
||||
{ name: 'bash', args: { command: 'npm test && git status' } },
|
||||
]),
|
||||
])
|
||||
|
||||
const calls = await collect(sessionDir, createMistralVibeProvider(tmpDir))
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
const call = calls[0]!
|
||||
expect(call.provider).toBe('mistral-vibe')
|
||||
expect(call.model).toBe('mistral-medium-3.5')
|
||||
expect(call.inputTokens).toBe(2000)
|
||||
expect(call.outputTokens).toBe(3000)
|
||||
expect(call.costUSD).toBeCloseTo(0.0255, 8)
|
||||
expect(call.tools).toEqual(['Read', 'Edit', 'Bash'])
|
||||
expect(call.bashCommands).toEqual(['npm', 'git'])
|
||||
expect(call.timestamp).toBe('2026-05-11T10:05:00+00:00')
|
||||
expect(call.userMessage).toBe('track Mistral Vibe usage')
|
||||
expect(call.sessionId).toBe('session-abc123')
|
||||
expect(call.deduplicationKey).toBe('mistral-vibe:session-abc123:msg-assistant-1')
|
||||
})
|
||||
|
||||
it('prefers Vibe session_cost over price-derived estimates when present', async () => {
|
||||
const sessionDir = await writeSession('session_20260511_100000_sessiona', metadata({
|
||||
input: 364147,
|
||||
output: 1731,
|
||||
sessionCost: 0.381681,
|
||||
inputPrice: 100,
|
||||
outputPrice: 100,
|
||||
}))
|
||||
|
||||
const calls = await collect(sessionDir, createMistralVibeProvider(tmpDir))
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]!.costUSD).toBe(0.381681)
|
||||
})
|
||||
|
||||
it('uses configured model prices when stats omit prices', async () => {
|
||||
const sessionDir = await writeSession('session_20260511_100000_sessiona', metadata({
|
||||
inputPrice: 0,
|
||||
outputPrice: 0,
|
||||
input: 1000,
|
||||
output: 1000,
|
||||
}))
|
||||
|
||||
const calls = await collect(sessionDir, createMistralVibeProvider(tmpDir))
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]!.costUSD).toBeCloseTo(0.009, 8)
|
||||
})
|
||||
|
||||
it('falls back to LiteLLM pricing when Vibe does not provide prices', async () => {
|
||||
const sessionDir = await writeSession('session_20260511_100000_sessiona', metadata({
|
||||
activeModel: 'claude-sonnet-4-6',
|
||||
modelName: 'claude-sonnet-4-6',
|
||||
input: 1000,
|
||||
output: 1000,
|
||||
inputPrice: 0,
|
||||
outputPrice: 0,
|
||||
configInputPrice: 0,
|
||||
configOutputPrice: 0,
|
||||
}))
|
||||
|
||||
const calls = await collect(sessionDir, createMistralVibeProvider(tmpDir))
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]!.costUSD).toBeCloseTo(0.018, 8)
|
||||
})
|
||||
|
||||
it('falls back to start_time when end_time is missing', async () => {
|
||||
const sessionDir = await writeSession('session_20260511_100000_sessiona', metadata({
|
||||
endTime: null,
|
||||
}))
|
||||
|
||||
const calls = await collect(sessionDir, createMistralVibeProvider(tmpDir))
|
||||
|
||||
expect(calls[0]!.timestamp).toBe('2026-05-11T10:00:00+00:00')
|
||||
})
|
||||
|
||||
it('deduplicates by session id', async () => {
|
||||
const sessionDir = await writeSession('session_20260511_100000_sessiona', metadata())
|
||||
const provider = createMistralVibeProvider(tmpDir)
|
||||
const source = { path: sessionDir, project: 'mistral-project', provider: 'mistral-vibe' }
|
||||
const seen = new Set<string>()
|
||||
|
||||
const first: ParsedProviderCall[] = []
|
||||
for await (const call of provider.createSessionParser(source, seen).parse()) first.push(call)
|
||||
const second: ParsedProviderCall[] = []
|
||||
for await (const call of provider.createSessionParser(source, seen).parse()) second.push(call)
|
||||
|
||||
expect(first).toHaveLength(1)
|
||||
expect(second).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('skips sessions without cumulative token usage', async () => {
|
||||
const sessionDir = await writeSession('session_20260511_100000_empty', metadata({
|
||||
input: 0,
|
||||
output: 0,
|
||||
}))
|
||||
|
||||
const calls = await collect(sessionDir, createMistralVibeProvider(tmpDir))
|
||||
|
||||
expect(calls).toEqual([])
|
||||
})
|
||||
|
||||
it('skips sessions with malformed meta.json', async () => {
|
||||
const sessionDir = join(tmpDir, 'session_20260511_100000_bad')
|
||||
await mkdir(sessionDir, { recursive: true })
|
||||
await writeFile(join(sessionDir, 'meta.json'), '{{not json')
|
||||
await writeFile(join(sessionDir, 'messages.jsonl'), JSON.stringify(userMessage()) + '\n')
|
||||
|
||||
const provider = createMistralVibeProvider(tmpDir)
|
||||
const sessions = await provider.discoverSessions()
|
||||
expect(sessions).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('returns empty calls when messages.jsonl is malformed', async () => {
|
||||
const sessionDir = await writeSession('session_20260511_100000_badjsonl', metadata())
|
||||
await writeFile(join(sessionDir, 'messages.jsonl'), '{{not json\n{{also bad\n')
|
||||
|
||||
const calls = await collect(sessionDir, createMistralVibeProvider(tmpDir))
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]!.tools).toEqual([])
|
||||
expect(calls[0]!.bashCommands).toEqual([])
|
||||
})
|
||||
|
||||
it('formats model and tool display names', () => {
|
||||
const provider = createMistralVibeProvider(tmpDir)
|
||||
|
||||
expect(provider.modelDisplayName('mistral-medium-3.5')).toBe('Mistral Medium 3.5')
|
||||
expect(provider.modelDisplayName('devstral-small-latest')).toBe('Devstral Small')
|
||||
expect(provider.toolDisplayName('search_replace')).toBe('Edit')
|
||||
expect(provider.toolDisplayName('unknown_tool')).toBe('unknown_tool')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,404 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||
import { mkdtemp, mkdir, writeFile, rm } from 'fs/promises'
|
||||
import { join } from 'path'
|
||||
import { tmpdir } from 'os'
|
||||
|
||||
import { createMuxProvider } from '../../src/providers/mux.js'
|
||||
import type { ParsedProviderCall } from '../../src/providers/types.js'
|
||||
|
||||
let tmpDir: string
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await mkdtemp(join(tmpdir(), 'mux-test-'))
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
// ~/.mux/config.json shape: { projects: Array<[projectPath, { workspaces: [{ id }] }]> }
|
||||
async function writeConfig(root: string, entries: Array<[string, string[]]>) {
|
||||
const data = {
|
||||
projects: entries.map(([projectPath, ids]) => [
|
||||
projectPath,
|
||||
{ workspaces: ids.map(id => ({ id, name: 'main', path: `${projectPath}/wt-${id}` })) },
|
||||
]),
|
||||
}
|
||||
await writeFile(join(root, 'config.json'), JSON.stringify(data))
|
||||
}
|
||||
|
||||
async function writeWorkspace(root: string, workspaceId: string, lines: string[]) {
|
||||
const dir = join(root, 'sessions', workspaceId)
|
||||
await mkdir(dir, { recursive: true })
|
||||
const filePath = join(dir, 'chat.jsonl')
|
||||
await writeFile(filePath, lines.join('\n') + '\n')
|
||||
return filePath
|
||||
}
|
||||
|
||||
// Sub-agent transcripts live nested under the parent workspace, not as a
|
||||
// top-level sessions/<id> dir.
|
||||
async function writeSubagent(root: string, workspaceId: string, childTaskId: string, lines: string[]) {
|
||||
const dir = join(root, 'sessions', workspaceId, 'subagent-transcripts', childTaskId)
|
||||
await mkdir(dir, { recursive: true })
|
||||
const filePath = join(dir, 'chat.jsonl')
|
||||
await writeFile(filePath, lines.join('\n') + '\n')
|
||||
return filePath
|
||||
}
|
||||
|
||||
function userMessage(text: string, id = 'msg-user-1') {
|
||||
return JSON.stringify({
|
||||
id,
|
||||
role: 'user',
|
||||
parts: [{ type: 'text', text }],
|
||||
metadata: { historySequence: 0, timestamp: 1776023210000 },
|
||||
})
|
||||
}
|
||||
|
||||
type AsstOpts = {
|
||||
id?: string
|
||||
timestamp?: number
|
||||
model?: string
|
||||
input?: number
|
||||
output?: number
|
||||
reasoning?: number
|
||||
cacheRead?: number
|
||||
cacheCreate?: number
|
||||
tools?: Array<{ name: string; script?: string }>
|
||||
text?: string
|
||||
}
|
||||
|
||||
function assistantMessage(opts: AsstOpts = {}) {
|
||||
const parts: Array<Record<string, unknown>> = [{ type: 'text', text: opts.text ?? 'done' }]
|
||||
for (const t of opts.tools ?? []) {
|
||||
parts.push({
|
||||
type: 'dynamic-tool',
|
||||
toolCallId: `call-${t.name}`,
|
||||
toolName: t.name,
|
||||
input: t.script !== undefined ? { script: t.script } : {},
|
||||
state: 'output-available',
|
||||
output: {},
|
||||
})
|
||||
}
|
||||
const metadata: Record<string, unknown> = {
|
||||
historySequence: 1,
|
||||
model: opts.model ?? 'anthropic:claude-opus-4-8',
|
||||
timestamp: opts.timestamp ?? 1776023230000,
|
||||
usage: {
|
||||
inputTokens: opts.input ?? 1000,
|
||||
outputTokens: opts.output ?? 200,
|
||||
reasoningTokens: opts.reasoning ?? 0,
|
||||
cachedInputTokens: opts.cacheRead ?? 0,
|
||||
},
|
||||
...(opts.cacheCreate
|
||||
? { providerMetadata: { anthropic: { cacheCreationInputTokens: opts.cacheCreate } } }
|
||||
: {}),
|
||||
}
|
||||
return JSON.stringify({ id: opts.id ?? 'msg-asst-1', role: 'assistant', parts, metadata })
|
||||
}
|
||||
|
||||
describe('mux provider - session discovery', () => {
|
||||
it('discovers chat.jsonl per workspace and resolves project from config.json', async () => {
|
||||
await writeWorkspace(tmpDir, 'ws-abc', [userMessage('hi'), assistantMessage({})])
|
||||
await writeConfig(tmpDir, [['/Users/test/myproject', ['ws-abc']]])
|
||||
|
||||
const provider = createMuxProvider(tmpDir)
|
||||
const sessions = await provider.discoverSessions()
|
||||
|
||||
expect(sessions).toHaveLength(1)
|
||||
expect(sessions[0]!.provider).toBe('mux')
|
||||
expect(sessions[0]!.project).toBe('myproject')
|
||||
expect(sessions[0]!.path).toContain(join('sessions', 'ws-abc', 'chat.jsonl'))
|
||||
})
|
||||
|
||||
it('falls back to the workspaceId when config.json has no mapping', async () => {
|
||||
await writeWorkspace(tmpDir, 'ws-orphan', [assistantMessage({})])
|
||||
// no config.json written
|
||||
|
||||
const provider = createMuxProvider(tmpDir)
|
||||
const sessions = await provider.discoverSessions()
|
||||
|
||||
expect(sessions).toHaveLength(1)
|
||||
expect(sessions[0]!.project).toBe('ws-orphan')
|
||||
})
|
||||
|
||||
it('discovers multiple workspaces across projects', async () => {
|
||||
await writeWorkspace(tmpDir, 'ws-1', [assistantMessage({})])
|
||||
await writeWorkspace(tmpDir, 'ws-2', [assistantMessage({})])
|
||||
await writeConfig(tmpDir, [
|
||||
['/Users/test/project-a', ['ws-1']],
|
||||
['/Users/test/project-b', ['ws-2']],
|
||||
])
|
||||
|
||||
const provider = createMuxProvider(tmpDir)
|
||||
const sessions = await provider.discoverSessions()
|
||||
|
||||
expect(sessions.map(s => s.project).sort()).toEqual(['project-a', 'project-b'])
|
||||
})
|
||||
|
||||
it('returns empty for a non-existent root', async () => {
|
||||
const provider = createMuxProvider('/nonexistent/path/that/does/not/exist')
|
||||
expect(await provider.discoverSessions()).toEqual([])
|
||||
})
|
||||
|
||||
it('skips workspace directories without a chat.jsonl', async () => {
|
||||
await mkdir(join(tmpDir, 'sessions', 'ws-empty'), { recursive: true })
|
||||
const provider = createMuxProvider(tmpDir)
|
||||
expect(await provider.discoverSessions()).toEqual([])
|
||||
})
|
||||
|
||||
it('discovers sub-agent transcripts and attributes them to the parent project', async () => {
|
||||
await writeWorkspace(tmpDir, 'ws-parent', [assistantMessage({ id: 'parent-1' })])
|
||||
await writeSubagent(tmpDir, 'ws-parent', 'child-a', [assistantMessage({ id: 'child-a-1' })])
|
||||
await writeSubagent(tmpDir, 'ws-parent', 'child-b', [assistantMessage({ id: 'child-b-1' })])
|
||||
await writeConfig(tmpDir, [['/Users/test/myproject', ['ws-parent']]])
|
||||
|
||||
const provider = createMuxProvider(tmpDir)
|
||||
const sessions = await provider.discoverSessions()
|
||||
|
||||
// parent chat.jsonl + two sub-agent transcripts, all under one project.
|
||||
expect(sessions).toHaveLength(3)
|
||||
expect(sessions.every(s => s.project === 'myproject')).toBe(true)
|
||||
const subagentPaths = sessions
|
||||
.map(s => s.path)
|
||||
.filter(p => p.includes('subagent-transcripts'))
|
||||
.sort()
|
||||
expect(subagentPaths).toHaveLength(2)
|
||||
expect(subagentPaths[0]).toContain(join('subagent-transcripts', 'child-a', 'chat.jsonl'))
|
||||
})
|
||||
|
||||
it('discovers a sub-agent transcript even when the workspace has no top-level chat.jsonl', async () => {
|
||||
// mkdir the workspace dir without a chat.jsonl, but with a sub-agent transcript.
|
||||
await writeSubagent(tmpDir, 'ws-parent', 'child-only', [assistantMessage({ id: 'child-only-1' })])
|
||||
|
||||
const provider = createMuxProvider(tmpDir)
|
||||
const sessions = await provider.discoverSessions()
|
||||
|
||||
expect(sessions).toHaveLength(1)
|
||||
expect(sessions[0]!.path).toContain(join('subagent-transcripts', 'child-only', 'chat.jsonl'))
|
||||
})
|
||||
|
||||
it('counts sub-agent calls once, with a workspace-distinct dedup key', async () => {
|
||||
const seenKeys = new Set<string>()
|
||||
await writeWorkspace(tmpDir, 'ws-parent', [assistantMessage({ id: 'shared-id', input: 100 })])
|
||||
// Same message id inside the sub-agent must NOT collide with the parent's.
|
||||
await writeSubagent(tmpDir, 'ws-parent', 'child-a', [assistantMessage({ id: 'shared-id', input: 200 })])
|
||||
|
||||
const provider = createMuxProvider(tmpDir)
|
||||
const sessions = await provider.discoverSessions()
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for (const source of sessions) {
|
||||
for await (const call of provider.createSessionParser(source, seenKeys).parse()) calls.push(call)
|
||||
}
|
||||
|
||||
expect(calls).toHaveLength(2)
|
||||
expect(new Set(calls.map(c => c.deduplicationKey))).toEqual(
|
||||
new Set(['mux:ws-parent:shared-id', 'mux:child-a:shared-id']),
|
||||
)
|
||||
expect(calls.map(c => c.sessionId).sort()).toEqual(['child-a', 'ws-parent'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('mux provider - chat.jsonl parsing', () => {
|
||||
it('decomposes inclusive input/output usage into codeburn token fields', async () => {
|
||||
// input is inclusive of cache; output is inclusive of reasoning.
|
||||
const filePath = await writeWorkspace(tmpDir, 'ws-abc', [
|
||||
userMessage('implement the feature'),
|
||||
assistantMessage({
|
||||
id: 'msg-1',
|
||||
input: 1000,
|
||||
output: 230,
|
||||
reasoning: 30,
|
||||
cacheRead: 200,
|
||||
cacheCreate: 50,
|
||||
}),
|
||||
])
|
||||
|
||||
const provider = createMuxProvider(tmpDir)
|
||||
const source = { path: filePath, project: 'myproject', provider: 'mux' }
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of provider.createSessionParser(source, new Set()).parse()) {
|
||||
calls.push(call)
|
||||
}
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
const call = calls[0]!
|
||||
expect(call.provider).toBe('mux')
|
||||
expect(call.model).toBe('claude-opus-4-8') // provider prefix stripped so codeburn prices/displays it
|
||||
expect(call.inputTokens).toBe(750) // 1000 - 200 cacheRead - 50 cacheCreate
|
||||
expect(call.outputTokens).toBe(200) // 230 - 30 reasoning
|
||||
expect(call.reasoningTokens).toBe(30)
|
||||
expect(call.cacheReadInputTokens).toBe(200)
|
||||
expect(call.cachedInputTokens).toBe(200)
|
||||
expect(call.cacheCreationInputTokens).toBe(50)
|
||||
expect(call.webSearchRequests).toBe(0)
|
||||
expect(call.sessionId).toBe('ws-abc')
|
||||
expect(call.userMessage).toBe('implement the feature')
|
||||
expect(call.timestamp).toBe(new Date(1776023230000).toISOString())
|
||||
expect(call.costUSD).toBeGreaterThan(0)
|
||||
expect(call.deduplicationKey).toBe('mux:ws-abc:msg-1')
|
||||
})
|
||||
|
||||
it('maps tool names and extracts bash command programs', async () => {
|
||||
const filePath = await writeWorkspace(tmpDir, 'ws-abc', [
|
||||
assistantMessage({
|
||||
tools: [
|
||||
{ name: 'file_read' },
|
||||
{ name: 'file_edit_replace_string' },
|
||||
{ name: 'bash', script: 'git status && bun test' },
|
||||
],
|
||||
}),
|
||||
])
|
||||
|
||||
const provider = createMuxProvider(tmpDir)
|
||||
const source = { path: filePath, project: 'myproject', provider: 'mux' }
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of provider.createSessionParser(source, new Set()).parse()) {
|
||||
calls.push(call)
|
||||
}
|
||||
|
||||
expect(calls[0]!.tools).toEqual(['Read', 'Edit', 'Bash'])
|
||||
expect(calls[0]!.bashCommands).toEqual(['git', 'bun'])
|
||||
})
|
||||
|
||||
it('skips assistant messages without a usage blob', async () => {
|
||||
const filePath = await writeWorkspace(tmpDir, 'ws-abc', [
|
||||
JSON.stringify({
|
||||
id: 'no-usage',
|
||||
role: 'assistant',
|
||||
parts: [{ type: 'text', text: 'thinking...' }],
|
||||
metadata: { model: 'anthropic:claude-opus-4-8', timestamp: 1 },
|
||||
}),
|
||||
])
|
||||
|
||||
const provider = createMuxProvider(tmpDir)
|
||||
const source = { path: filePath, project: 'myproject', provider: 'mux' }
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of provider.createSessionParser(source, new Set()).parse()) {
|
||||
calls.push(call)
|
||||
}
|
||||
expect(calls).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('skips assistant messages with all-zero tokens', async () => {
|
||||
const filePath = await writeWorkspace(tmpDir, 'ws-abc', [
|
||||
assistantMessage({ input: 0, output: 0 }),
|
||||
])
|
||||
|
||||
const provider = createMuxProvider(tmpDir)
|
||||
const source = { path: filePath, project: 'myproject', provider: 'mux' }
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of provider.createSessionParser(source, new Set()).parse()) {
|
||||
calls.push(call)
|
||||
}
|
||||
expect(calls).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('deduplicates calls seen across multiple parses', async () => {
|
||||
const filePath = await writeWorkspace(tmpDir, 'ws-abc', [assistantMessage({ id: 'dup' })])
|
||||
|
||||
const provider = createMuxProvider(tmpDir)
|
||||
const source = { path: filePath, project: 'myproject', provider: 'mux' }
|
||||
const seenKeys = new Set<string>()
|
||||
|
||||
const first: ParsedProviderCall[] = []
|
||||
for await (const call of provider.createSessionParser(source, seenKeys).parse()) first.push(call)
|
||||
const second: ParsedProviderCall[] = []
|
||||
for await (const call of provider.createSessionParser(source, seenKeys).parse()) second.push(call)
|
||||
|
||||
expect(first).toHaveLength(1)
|
||||
expect(second).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('yields one call per assistant message and pairs the preceding user prompt', async () => {
|
||||
const filePath = await writeWorkspace(tmpDir, 'ws-multi', [
|
||||
userMessage('first question', 'u1'),
|
||||
assistantMessage({ id: 'a1', input: 500, output: 100 }),
|
||||
userMessage('second question', 'u2'),
|
||||
assistantMessage({ id: 'a2', input: 600, output: 120 }),
|
||||
])
|
||||
|
||||
const provider = createMuxProvider(tmpDir)
|
||||
const source = { path: filePath, project: 'myproject', provider: 'mux' }
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of provider.createSessionParser(source, new Set()).parse()) calls.push(call)
|
||||
|
||||
expect(calls).toHaveLength(2)
|
||||
expect(calls[0]!.userMessage).toBe('first question')
|
||||
expect(calls[0]!.inputTokens).toBe(500)
|
||||
expect(calls[1]!.userMessage).toBe('second question')
|
||||
expect(calls[1]!.inputTokens).toBe(600)
|
||||
})
|
||||
|
||||
it('handles a missing session file gracefully', async () => {
|
||||
const provider = createMuxProvider(tmpDir)
|
||||
const source = { path: join(tmpDir, 'sessions', 'nope', 'chat.jsonl'), project: 'x', provider: 'mux' }
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of provider.createSessionParser(source, new Set()).parse()) calls.push(call)
|
||||
expect(calls).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('keeps distinct id-less assistant messages via the line-index fallback', async () => {
|
||||
const asst = (text: string, input: number) =>
|
||||
JSON.stringify({
|
||||
role: 'assistant',
|
||||
parts: [{ type: 'text', text }],
|
||||
// No `id`, identical historySequence — the fallback must stay unique.
|
||||
metadata: { model: 'anthropic:claude-opus-4-8', timestamp: 1, historySequence: 5, usage: { inputTokens: input, outputTokens: 5 } },
|
||||
})
|
||||
const filePath = await writeWorkspace(tmpDir, 'ws-noid', [asst('a', 10), asst('b', 11)])
|
||||
|
||||
const provider = createMuxProvider(tmpDir)
|
||||
const source = { path: filePath, project: 'p', provider: 'mux' }
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of provider.createSessionParser(source, new Set()).parse()) calls.push(call)
|
||||
|
||||
expect(calls).toHaveLength(2)
|
||||
expect(new Set(calls.map(c => c.deduplicationKey)).size).toBe(2)
|
||||
})
|
||||
|
||||
it('tolerates malformed lines without dropping later valid turns', async () => {
|
||||
const filePath = await writeWorkspace(tmpDir, 'ws-abc', [
|
||||
// parts is an object, not an array (corrupt) — must not throw the loop
|
||||
JSON.stringify({ id: 'bad-parts', role: 'assistant', parts: { type: 'text', text: 'x' }, metadata: { model: 'anthropic:claude-opus-4-8', timestamp: 1, usage: { inputTokens: 10, outputTokens: 5 } } }),
|
||||
// out-of-range timestamp — must not throw; timestamp falls back to ''
|
||||
JSON.stringify({ id: 'bad-ts', role: 'assistant', parts: [{ type: 'text', text: 'x' }], metadata: { model: 'anthropic:claude-opus-4-8', timestamp: 1.7e18, usage: { inputTokens: 10, outputTokens: 5 } } }),
|
||||
assistantMessage({ id: 'good', input: 100, output: 20 }),
|
||||
])
|
||||
|
||||
const provider = createMuxProvider(tmpDir)
|
||||
const source = { path: filePath, project: 'p', provider: 'mux' }
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of provider.createSessionParser(source, new Set()).parse()) calls.push(call)
|
||||
|
||||
expect(calls).toHaveLength(3) // none of the malformed lines aborted the parse
|
||||
expect(calls.find(c => c.deduplicationKey === 'mux:ws-abc:good')?.inputTokens).toBe(100)
|
||||
expect(calls.find(c => c.deduplicationKey === 'mux:ws-abc:bad-ts')?.timestamp).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('mux provider - display names', () => {
|
||||
const provider = createMuxProvider('/tmp')
|
||||
|
||||
it('has correct name and displayName', () => {
|
||||
expect(provider.name).toBe('mux')
|
||||
expect(provider.displayName).toBe('Mux')
|
||||
})
|
||||
|
||||
it('strips the provider prefix and humanizes the model', () => {
|
||||
expect(provider.modelDisplayName('anthropic:claude-opus-4-8')).toBe('Opus 4.8')
|
||||
expect(provider.modelDisplayName('anthropic:claude-sonnet-4-6')).toBe('Sonnet 4.6')
|
||||
})
|
||||
|
||||
it('returns the bare id for unknown / prefixless models', () => {
|
||||
expect(provider.modelDisplayName('ollama:some-random-model')).toBe('some-random-model')
|
||||
expect(provider.modelDisplayName('some-random-model')).toBe('some-random-model')
|
||||
})
|
||||
|
||||
it('normalizes tool names to the canonical set', () => {
|
||||
expect(provider.toolDisplayName('bash')).toBe('Bash')
|
||||
expect(provider.toolDisplayName('file_read')).toBe('Read')
|
||||
expect(provider.toolDisplayName('file_edit_insert')).toBe('Edit')
|
||||
expect(provider.toolDisplayName('task')).toBe('Agent')
|
||||
expect(provider.toolDisplayName('unknown_tool')).toBe('unknown_tool')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,225 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||
import { mkdtemp, mkdir, writeFile, rm } from 'fs/promises'
|
||||
import { join } from 'path'
|
||||
import { tmpdir } from 'os'
|
||||
|
||||
import { createOmpProvider } from '../../src/providers/pi.js'
|
||||
import type { ParsedProviderCall } from '../../src/providers/types.js'
|
||||
|
||||
let tmpDir: string
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await mkdtemp(join(tmpdir(), 'omp-test-'))
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function sessionMeta(opts: { id?: string; cwd?: string } = {}) {
|
||||
return JSON.stringify({
|
||||
type: 'session',
|
||||
version: 3,
|
||||
id: opts.id ?? 'sess-001',
|
||||
timestamp: '2026-04-14T10:00:00.000Z',
|
||||
cwd: opts.cwd ?? '/Users/test/myproject',
|
||||
})
|
||||
}
|
||||
|
||||
function userMessage(text: string) {
|
||||
return JSON.stringify({
|
||||
type: 'message',
|
||||
id: 'msg-user-1',
|
||||
timestamp: '2026-04-14T10:00:10.000Z',
|
||||
message: {
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text }],
|
||||
timestamp: 1776023210000,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function assistantMessage(opts: {
|
||||
id?: string
|
||||
responseId?: string
|
||||
timestamp?: string
|
||||
model?: string
|
||||
input?: number
|
||||
output?: number
|
||||
cacheRead?: number
|
||||
cacheWrite?: number
|
||||
tools?: Array<{ name: string; command?: string }>
|
||||
}) {
|
||||
const content = (opts.tools ?? []).map(t => ({
|
||||
type: 'toolCall',
|
||||
id: `call-${t.name}`,
|
||||
name: t.name,
|
||||
arguments: t.command !== undefined ? { command: t.command } : {},
|
||||
}))
|
||||
|
||||
return JSON.stringify({
|
||||
type: 'message',
|
||||
id: opts.id ?? 'msg-asst-1',
|
||||
timestamp: opts.timestamp ?? '2026-04-14T10:00:30.000Z',
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content,
|
||||
provider: 'anthropic',
|
||||
model: opts.model ?? 'claude-sonnet-4-5',
|
||||
responseId: opts.responseId ?? 'resp-001',
|
||||
usage: {
|
||||
input: opts.input ?? 1000,
|
||||
output: opts.output ?? 200,
|
||||
cacheRead: opts.cacheRead ?? 0,
|
||||
cacheWrite: opts.cacheWrite ?? 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
timestamp: 1776023230000,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function writeSession(projectDir: string, filename: string, lines: string[]) {
|
||||
await mkdir(projectDir, { recursive: true })
|
||||
const filePath = join(projectDir, filename)
|
||||
await writeFile(filePath, lines.join('\n') + '\n')
|
||||
return filePath
|
||||
}
|
||||
|
||||
describe('omp provider - identity', () => {
|
||||
it('has correct name and displayName', () => {
|
||||
const provider = createOmpProvider(tmpDir)
|
||||
expect(provider.name).toBe('omp')
|
||||
expect(provider.displayName).toBe('OMP')
|
||||
})
|
||||
})
|
||||
|
||||
describe('omp provider - session discovery', () => {
|
||||
it('discovers sessions from the omp sessions directory', async () => {
|
||||
const projectDir = join(tmpDir, '--Users-test-myproject--')
|
||||
await writeSession(projectDir, '2026-04-14T10-00-00-000Z_sess-001.jsonl', [
|
||||
sessionMeta({ cwd: '/Users/test/myproject' }),
|
||||
assistantMessage({}),
|
||||
])
|
||||
|
||||
const provider = createOmpProvider(tmpDir)
|
||||
const sessions = await provider.discoverSessions()
|
||||
|
||||
expect(sessions).toHaveLength(1)
|
||||
expect(sessions[0]!.provider).toBe('omp')
|
||||
expect(sessions[0]!.project).toBe('myproject')
|
||||
})
|
||||
|
||||
it('returns empty for non-existent directory', async () => {
|
||||
const provider = createOmpProvider('/nonexistent/omp/path')
|
||||
const sessions = await provider.discoverSessions()
|
||||
expect(sessions).toEqual([])
|
||||
})
|
||||
|
||||
it('skips files whose first line is not a session entry', async () => {
|
||||
const projectDir = join(tmpDir, '--Users-test-myproject--')
|
||||
await writeSession(projectDir, 'bad.jsonl', [
|
||||
JSON.stringify({ type: 'message', id: 'x' }),
|
||||
])
|
||||
|
||||
const provider = createOmpProvider(tmpDir)
|
||||
const sessions = await provider.discoverSessions()
|
||||
expect(sessions).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('omp provider - JSONL parsing', () => {
|
||||
it('extracts token usage from an omp-format assistant message', async () => {
|
||||
const projectDir = join(tmpDir, '--Users-test-myproject--')
|
||||
const filePath = await writeSession(projectDir, 'session.jsonl', [
|
||||
sessionMeta({ id: 'sess-omp-1', cwd: '/Users/test/myproject' }),
|
||||
userMessage('write a test'),
|
||||
assistantMessage({
|
||||
responseId: 'resp-omp-1',
|
||||
timestamp: '2026-04-14T10:00:30.000Z',
|
||||
model: 'claude-sonnet-4-5',
|
||||
input: 1500,
|
||||
output: 300,
|
||||
cacheRead: 2000,
|
||||
cacheWrite: 50,
|
||||
}),
|
||||
])
|
||||
|
||||
const provider = createOmpProvider(tmpDir)
|
||||
const source = { path: filePath, project: 'myproject', provider: 'omp' }
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of provider.createSessionParser(source, new Set()).parse()) {
|
||||
calls.push(call)
|
||||
}
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
const call = calls[0]!
|
||||
expect(call.provider).toBe('omp')
|
||||
expect(call.model).toBe('claude-sonnet-4-5')
|
||||
expect(call.inputTokens).toBe(1500)
|
||||
expect(call.outputTokens).toBe(300)
|
||||
expect(call.cacheReadInputTokens).toBe(2000)
|
||||
expect(call.cachedInputTokens).toBe(2000)
|
||||
expect(call.cacheCreationInputTokens).toBe(50)
|
||||
expect(call.sessionId).toBe('sess-omp-1')
|
||||
expect(call.userMessage).toBe('write a test')
|
||||
expect(call.timestamp).toBe('2026-04-14T10:00:30.000Z')
|
||||
expect(call.deduplicationKey).toContain('omp:')
|
||||
expect(call.deduplicationKey).toContain('resp-omp-1')
|
||||
})
|
||||
|
||||
it('ignores the embedded usage.cost and recalculates cost', async () => {
|
||||
const projectDir = join(tmpDir, '--Users-test-myproject--')
|
||||
const filePath = await writeSession(projectDir, 'session.jsonl', [
|
||||
sessionMeta(),
|
||||
assistantMessage({ input: 1000, output: 200, cacheRead: 0, cacheWrite: 0 }),
|
||||
])
|
||||
|
||||
const provider = createOmpProvider(tmpDir)
|
||||
const source = { path: filePath, project: 'myproject', provider: 'omp' }
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of provider.createSessionParser(source, new Set()).parse()) {
|
||||
calls.push(call)
|
||||
}
|
||||
|
||||
// cost must be calculated by codeburn, not taken from usage.cost (which is zeroed in fixture)
|
||||
expect(calls[0]!.costUSD).toBeGreaterThanOrEqual(0)
|
||||
})
|
||||
|
||||
it('collects tool names from toolCall content items', async () => {
|
||||
const projectDir = join(tmpDir, '--Users-test-myproject--')
|
||||
const filePath = await writeSession(projectDir, 'session.jsonl', [
|
||||
sessionMeta(),
|
||||
assistantMessage({
|
||||
tools: [{ name: 'read' }, { name: 'edit' }, { name: 'bash', command: 'bun test' }],
|
||||
}),
|
||||
])
|
||||
|
||||
const provider = createOmpProvider(tmpDir)
|
||||
const source = { path: filePath, project: 'myproject', provider: 'omp' }
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of provider.createSessionParser(source, new Set()).parse()) {
|
||||
calls.push(call)
|
||||
}
|
||||
|
||||
expect(calls[0]!.tools).toEqual(['Read', 'Edit', 'Bash'])
|
||||
expect(calls[0]!.bashCommands).toEqual(['bun'])
|
||||
})
|
||||
|
||||
it('skips assistant messages with zero tokens', async () => {
|
||||
const projectDir = join(tmpDir, '--Users-test-myproject--')
|
||||
const filePath = await writeSession(projectDir, 'session.jsonl', [
|
||||
sessionMeta(),
|
||||
assistantMessage({ input: 0, output: 0 }),
|
||||
])
|
||||
|
||||
const provider = createOmpProvider(tmpDir)
|
||||
const source = { path: filePath, project: 'myproject', provider: 'omp' }
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of provider.createSessionParser(source, new Set()).parse()) {
|
||||
calls.push(call)
|
||||
}
|
||||
|
||||
expect(calls).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,155 @@
|
||||
import { mkdtemp, rm } from 'fs/promises'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||
|
||||
import { calculateCost } from '../../src/models.js'
|
||||
import { clearSessionCache, filterProjectsByDateRange, parseAllSessions } from '../../src/parser.js'
|
||||
import { allProviderNames } from '../../src/providers/index.js'
|
||||
import { createOpenDesignProvider } from '../../src/providers/open-design.js'
|
||||
import type { ParsedProviderCall, SessionSource } from '../../src/providers/types.js'
|
||||
|
||||
const fixtureRoot = join(import.meta.dirname, '../fixtures/open-design')
|
||||
const dataDir = join(fixtureRoot, 'namespaces', 'release-stable', 'data')
|
||||
|
||||
let previousOverride: string | undefined
|
||||
let previousCacheDir: string | undefined
|
||||
let cacheDir: string | undefined
|
||||
|
||||
async function collect(source: SessionSource, seenKeys = new Set<string>()): Promise<ParsedProviderCall[]> {
|
||||
const provider = createOpenDesignProvider()
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of provider.createSessionParser(source, seenKeys).parse()) calls.push(call)
|
||||
return calls
|
||||
}
|
||||
|
||||
async function fixtureSource(runId: string): Promise<SessionSource> {
|
||||
const provider = createOpenDesignProvider()
|
||||
const sources = await provider.discoverSessions()
|
||||
const source = sources.find(s => s.path.includes(`${runId}/events.jsonl`))
|
||||
expect(source).toBeDefined()
|
||||
return source!
|
||||
}
|
||||
|
||||
describe('open-design provider', () => {
|
||||
beforeEach(async () => {
|
||||
previousOverride = process.env['CODEBURN_OPEN_DESIGN_DIR']
|
||||
previousCacheDir = process.env['CODEBURN_CACHE_DIR']
|
||||
cacheDir = await mkdtemp(join(tmpdir(), 'codeburn-open-design-cache-'))
|
||||
process.env['CODEBURN_OPEN_DESIGN_DIR'] = dataDir
|
||||
process.env['CODEBURN_CACHE_DIR'] = cacheDir
|
||||
clearSessionCache()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
clearSessionCache()
|
||||
if (previousOverride === undefined) {
|
||||
delete process.env['CODEBURN_OPEN_DESIGN_DIR']
|
||||
} else {
|
||||
process.env['CODEBURN_OPEN_DESIGN_DIR'] = previousOverride
|
||||
}
|
||||
if (previousCacheDir === undefined) {
|
||||
delete process.env['CODEBURN_CACHE_DIR']
|
||||
} else {
|
||||
process.env['CODEBURN_CACHE_DIR'] = previousCacheDir
|
||||
}
|
||||
if (cacheDir) await rm(cacheDir, { recursive: true, force: true })
|
||||
cacheDir = undefined
|
||||
})
|
||||
|
||||
it('discovers per-run events.jsonl files from the env override data dir', async () => {
|
||||
const provider = createOpenDesignProvider()
|
||||
const sources = await provider.discoverSessions()
|
||||
|
||||
expect(sources.map(s => s.provider).every(p => p === 'open-design')).toBe(true)
|
||||
expect(sources.map(s => s.project)).toEqual(['release-stable', 'release-stable', 'release-stable'])
|
||||
expect(sources.map(s => s.path).sort()).toEqual([
|
||||
join(dataDir, 'runs', 'run-mixed', 'events.jsonl'),
|
||||
join(dataDir, 'runs', 'run-no-usage', 'events.jsonl'),
|
||||
join(dataDir, 'runs', 'run-start-seeded', 'events.jsonl'),
|
||||
].sort())
|
||||
})
|
||||
|
||||
it('parses a mixed-model run into separate per-model usage calls', async () => {
|
||||
const calls = await collect(await fixtureSource('run-mixed'))
|
||||
|
||||
expect(calls).toHaveLength(2)
|
||||
expect(calls.map(c => c.model)).toEqual(['openai-codex:gpt-5.5', 'glm-5.2'])
|
||||
|
||||
const codex = calls[0]!
|
||||
expect(codex.provider).toBe('open-design')
|
||||
expect(codex.sessionId).toBe('run-mixed')
|
||||
expect(codex.inputTokens).toBe(950)
|
||||
expect(codex.outputTokens).toBe(200)
|
||||
expect(codex.cacheCreationInputTokens).toBe(0)
|
||||
expect(codex.cacheReadInputTokens).toBe(50)
|
||||
expect(codex.cachedInputTokens).toBe(50)
|
||||
expect(codex.reasoningTokens).toBe(25)
|
||||
expect(codex.timestamp).toBe('2026-06-22T10:00:05.000Z')
|
||||
expect(new Date(codex.timestamp).toISOString()).toBe(codex.timestamp)
|
||||
expect(codex.costUSD).toBeCloseTo(
|
||||
calculateCost(codex.model, 950, 225, 0, 50, 0),
|
||||
12,
|
||||
)
|
||||
|
||||
const glm = calls[1]!
|
||||
expect(glm.inputTokens).toBe(2900)
|
||||
expect(glm.outputTokens).toBe(400)
|
||||
expect(glm.cacheCreationInputTokens).toBe(0)
|
||||
expect(glm.cacheReadInputTokens).toBe(100)
|
||||
expect(glm.cachedInputTokens).toBe(100)
|
||||
expect(glm.reasoningTokens).toBe(60)
|
||||
expect(glm.timestamp).toBe('2026-06-22T10:00:15.000Z')
|
||||
expect(glm.costUSD).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('does not emit calls for a run with no usage events', async () => {
|
||||
const calls = await collect(await fixtureSource('run-no-usage'))
|
||||
expect(calls).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('uses the start-seeded model before any status transition', async () => {
|
||||
const calls = await collect(await fixtureSource('run-start-seeded'))
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]!.model).toBe('glm-5.2')
|
||||
expect(calls[0]!.inputTokens).toBe(770)
|
||||
expect(calls[0]!.outputTokens).toBe(33)
|
||||
expect(calls[0]!.cacheReadInputTokens).toBe(7)
|
||||
expect(calls[0]!.reasoningTokens).toBe(3)
|
||||
expect(calls[0]!.costUSD).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('keeps numeric epoch timestamp usage in date-scoped aggregation', async () => {
|
||||
const projects = await parseAllSessions(undefined, 'open-design')
|
||||
const filtered = filterProjectsByDateRange(projects, {
|
||||
start: new Date('2026-06-22T00:00:00.000Z'),
|
||||
end: new Date('2026-06-22T23:59:59.999Z'),
|
||||
})
|
||||
const calls = filtered.flatMap(project =>
|
||||
project.sessions.flatMap(session =>
|
||||
session.turns.flatMap(turn => turn.assistantCalls),
|
||||
),
|
||||
)
|
||||
const numericTimestampCall = calls.find(call =>
|
||||
call.deduplicationKey === 'open-design:run-mixed:evt-codex-usage',
|
||||
)
|
||||
|
||||
expect(numericTimestampCall?.timestamp).toBe('2026-06-22T10:00:05.000Z')
|
||||
})
|
||||
|
||||
it('deduplicates usage events per run and event id across parser runs', async () => {
|
||||
const source = await fixtureSource('run-mixed')
|
||||
const seenKeys = new Set<string>()
|
||||
|
||||
const first = await collect(source, seenKeys)
|
||||
const second = await collect(source, seenKeys)
|
||||
|
||||
expect(first).toHaveLength(2)
|
||||
expect(second).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('registers open-design as a core provider', () => {
|
||||
expect(allProviderNames()).toContain('open-design')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,192 @@
|
||||
import { describe, it, expect, afterAll } from 'vitest'
|
||||
import { createOpenClawProvider } from '../../src/providers/openclaw.js'
|
||||
import { writeFile, mkdir, rm } from 'fs/promises'
|
||||
import { join } from 'path'
|
||||
import { tmpdir } from 'os'
|
||||
|
||||
const SESSION_LINES = [
|
||||
JSON.stringify({ type: 'session', version: 3, id: 'test-sess-1', timestamp: '2026-04-20T10:00:00.000Z', cwd: '/tmp' }),
|
||||
JSON.stringify({ type: 'model_change', id: 'mc1', timestamp: '2026-04-20T10:00:01.000Z', provider: 'anthropic', modelId: 'claude-sonnet-4-6' }),
|
||||
JSON.stringify({
|
||||
type: 'message', id: 'u1', timestamp: '2026-04-20T10:00:02.000Z',
|
||||
message: { role: 'user', content: [{ type: 'text', text: 'hello world' }] },
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: 'message', id: 'a1', timestamp: '2026-04-20T10:00:03.000Z',
|
||||
message: {
|
||||
role: 'assistant', model: 'claude-sonnet-4-6',
|
||||
content: [{ type: 'text', text: 'Hi!' }],
|
||||
usage: { input: 500, output: 100, cacheRead: 200, cacheWrite: 50, totalTokens: 850 },
|
||||
},
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: 'message', id: 'a2', timestamp: '2026-04-20T10:00:05.000Z',
|
||||
message: {
|
||||
role: 'assistant', model: 'claude-sonnet-4-6',
|
||||
content: [
|
||||
{ type: 'text', text: 'Running command' },
|
||||
{ type: 'toolCall', name: 'exec', arguments: { command: 'ls -la' } },
|
||||
{ type: 'toolCall', name: 'read', arguments: { path: '/tmp/x' } },
|
||||
{ type: 'tool_use', name: 'write', arguments: { path: '/tmp/y' } },
|
||||
],
|
||||
usage: { input: 600, output: 200, cacheRead: 100, cacheWrite: 0, totalTokens: 900, cost: { total: 0.05 } },
|
||||
},
|
||||
}),
|
||||
]
|
||||
|
||||
async function setupFixture(dir: string, agentName: string, sessionId: string, lines: string[]): Promise<string> {
|
||||
const sessionsDir = join(dir, agentName, 'sessions')
|
||||
await mkdir(sessionsDir, { recursive: true })
|
||||
const filePath = join(sessionsDir, `${sessionId}.jsonl`)
|
||||
await writeFile(filePath, lines.join('\n'))
|
||||
return filePath
|
||||
}
|
||||
|
||||
describe('openclaw provider', () => {
|
||||
const baseDir = join(tmpdir(), `codeburn-openclaw-test-${Date.now()}`)
|
||||
|
||||
it('discovers sessions in agent directories', async () => {
|
||||
const dir = join(baseDir, 'discover')
|
||||
await setupFixture(dir, 'myproject', 'sess-1', SESSION_LINES)
|
||||
const provider = createOpenClawProvider(dir)
|
||||
const sources = await provider.discoverSessions()
|
||||
expect(sources.length).toBe(1)
|
||||
expect(sources[0].provider).toBe('openclaw')
|
||||
expect(sources[0].project).toBe('myproject')
|
||||
})
|
||||
|
||||
it('parses assistant messages with usage', async () => {
|
||||
const dir = join(baseDir, 'parse')
|
||||
await setupFixture(dir, 'proj', 'test-sess-1', SESSION_LINES)
|
||||
const provider = createOpenClawProvider(dir)
|
||||
const sources = await provider.discoverSessions()
|
||||
const parser = provider.createSessionParser(sources[0], new Set())
|
||||
const calls: any[] = []
|
||||
for await (const call of parser.parse()) {
|
||||
calls.push(call)
|
||||
}
|
||||
expect(calls.length).toBe(2)
|
||||
expect(calls[0].provider).toBe('openclaw')
|
||||
expect(calls[0].model).toBe('claude-sonnet-4-6')
|
||||
expect(calls[0].inputTokens).toBe(500)
|
||||
expect(calls[0].outputTokens).toBe(100)
|
||||
expect(calls[0].cacheReadInputTokens).toBe(200)
|
||||
expect(calls[0].userMessage).toBe('hello world')
|
||||
expect(calls[0].sessionId).toBe('test-sess-1')
|
||||
})
|
||||
|
||||
it('uses cost.total from provider when available', async () => {
|
||||
const dir = join(baseDir, 'cost')
|
||||
await setupFixture(dir, 'proj', 'test-sess-1', SESSION_LINES)
|
||||
const provider = createOpenClawProvider(dir)
|
||||
const sources = await provider.discoverSessions()
|
||||
const parser = provider.createSessionParser(sources[0], new Set())
|
||||
const calls: any[] = []
|
||||
for await (const call of parser.parse()) calls.push(call)
|
||||
expect(calls[1].costUSD).toBe(0.05)
|
||||
})
|
||||
|
||||
it('extracts tools and bash commands', async () => {
|
||||
const dir = join(baseDir, 'tools')
|
||||
await setupFixture(dir, 'proj', 'test-sess-1', SESSION_LINES)
|
||||
const provider = createOpenClawProvider(dir)
|
||||
const sources = await provider.discoverSessions()
|
||||
const parser = provider.createSessionParser(sources[0], new Set())
|
||||
const calls: any[] = []
|
||||
for await (const call of parser.parse()) calls.push(call)
|
||||
expect(calls[1].tools).toContain('Bash')
|
||||
expect(calls[1].tools).toContain('Read')
|
||||
expect(calls[1].tools).toContain('Write')
|
||||
expect(calls[1].bashCommands).toContain('ls')
|
||||
})
|
||||
|
||||
it('deduplicates on re-parse', async () => {
|
||||
const dir = join(baseDir, 'dedup')
|
||||
await setupFixture(dir, 'proj', 'test-sess-1', SESSION_LINES)
|
||||
const provider = createOpenClawProvider(dir)
|
||||
const sources = await provider.discoverSessions()
|
||||
const seen = new Set<string>()
|
||||
const parser1 = provider.createSessionParser(sources[0], seen)
|
||||
const calls1: any[] = []
|
||||
for await (const c of parser1.parse()) calls1.push(c)
|
||||
expect(calls1.length).toBe(2)
|
||||
const parser2 = provider.createSessionParser(sources[0], seen)
|
||||
const calls2: any[] = []
|
||||
for await (const c of parser2.parse()) calls2.push(c)
|
||||
expect(calls2.length).toBe(0)
|
||||
})
|
||||
|
||||
it('reads model from model_change event', async () => {
|
||||
const lines = [
|
||||
JSON.stringify({ type: 'session', id: 'mc-test', timestamp: '2026-04-20T10:00:00.000Z' }),
|
||||
JSON.stringify({ type: 'model_change', id: 'mc1', modelId: 'gpt-5.5', provider: 'openai' }),
|
||||
JSON.stringify({
|
||||
type: 'message', id: 'a1', timestamp: '2026-04-20T10:00:01.000Z',
|
||||
message: { role: 'assistant', usage: { input: 100, output: 50, cacheRead: 0, cacheWrite: 0 } },
|
||||
}),
|
||||
]
|
||||
const dir = join(baseDir, 'model-change')
|
||||
await setupFixture(dir, 'proj', 'mc-test', lines)
|
||||
const provider = createOpenClawProvider(dir)
|
||||
const sources = await provider.discoverSessions()
|
||||
const parser = provider.createSessionParser(sources[0], new Set())
|
||||
const calls: any[] = []
|
||||
for await (const c of parser.parse()) calls.push(c)
|
||||
expect(calls[0].model).toBe('gpt-5.5')
|
||||
})
|
||||
|
||||
it('reads model from custom model-snapshot event', async () => {
|
||||
const lines = [
|
||||
JSON.stringify({ type: 'session', id: 'snap-test', timestamp: '2026-04-20T10:00:00.000Z' }),
|
||||
JSON.stringify({ type: 'custom', customType: 'model-snapshot', data: { modelId: 'glm-5.1:cloud', provider: 'ollama' }, id: 's1' }),
|
||||
JSON.stringify({
|
||||
type: 'message', id: 'a1', timestamp: '2026-04-20T10:00:01.000Z',
|
||||
message: { role: 'assistant', usage: { input: 200, output: 80, cacheRead: 0, cacheWrite: 0 } },
|
||||
}),
|
||||
]
|
||||
const dir = join(baseDir, 'snapshot')
|
||||
await setupFixture(dir, 'proj', 'snap-test', lines)
|
||||
const provider = createOpenClawProvider(dir)
|
||||
const sources = await provider.discoverSessions()
|
||||
const parser = provider.createSessionParser(sources[0], new Set())
|
||||
const calls: any[] = []
|
||||
for await (const c of parser.parse()) calls.push(c)
|
||||
expect(calls[0].model).toBe('glm-5.1:cloud')
|
||||
})
|
||||
|
||||
it('skips entries with invalid timestamps', async () => {
|
||||
const lines = [
|
||||
JSON.stringify({ type: 'session', id: 'bad-ts', timestamp: 'not-a-date' }),
|
||||
JSON.stringify({
|
||||
type: 'message', id: 'a1', timestamp: 'also-bad',
|
||||
message: { role: 'assistant', model: 'test', usage: { input: 100, output: 50, cacheRead: 0, cacheWrite: 0 } },
|
||||
}),
|
||||
]
|
||||
const dir = join(baseDir, 'bad-ts')
|
||||
await setupFixture(dir, 'proj', 'bad-ts', lines)
|
||||
const provider = createOpenClawProvider(dir)
|
||||
const sources = await provider.discoverSessions()
|
||||
const parser = provider.createSessionParser(sources[0], new Set())
|
||||
const calls: any[] = []
|
||||
for await (const c of parser.parse()) calls.push(c)
|
||||
expect(calls.length).toBe(0)
|
||||
})
|
||||
|
||||
it('tool and model display names work', () => {
|
||||
const provider = createOpenClawProvider()
|
||||
expect(provider.toolDisplayName('bash')).toBe('Bash')
|
||||
expect(provider.toolDisplayName('dispatch_agent')).toBe('Agent')
|
||||
expect(provider.toolDisplayName('unknown')).toBe('unknown')
|
||||
expect(provider.modelDisplayName('claude-sonnet-4-6')).toBe('claude-sonnet-4-6')
|
||||
})
|
||||
|
||||
it('returns empty for nonexistent directory', async () => {
|
||||
const provider = createOpenClawProvider('/tmp/nonexistent-openclaw-test')
|
||||
const sources = await provider.discoverSessions()
|
||||
expect(sources.length).toBe(0)
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await rm(baseDir, { recursive: true, force: true })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,243 @@
|
||||
import { mkdtemp, rm, mkdir, writeFile } from 'fs/promises'
|
||||
import { join } from 'path'
|
||||
import { tmpdir } from 'os'
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||
|
||||
import { createOpenCodeProvider } from '../../src/providers/opencode.js'
|
||||
import type { ParsedProviderCall } from '../../src/providers/types.js'
|
||||
|
||||
let tmpDir: string
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await mkdtemp(join(tmpdir(), 'opencode-file-test-'))
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
type Msg = { id: string; data: Record<string, unknown>; parts?: Array<Record<string, unknown>> }
|
||||
|
||||
// Mirrors the OpenCode 1.1+ on-disk layout under <dataDir>/storage/.
|
||||
async function writeSession(opts: {
|
||||
sessionId?: string
|
||||
projectId?: string
|
||||
directory?: string
|
||||
title?: string
|
||||
messages: Msg[]
|
||||
}) {
|
||||
const storage = join(tmpDir, 'opencode', 'storage')
|
||||
const sessionId = opts.sessionId ?? 'ses_test1'
|
||||
const projectId = opts.projectId ?? 'global'
|
||||
|
||||
const sessionDir = join(storage, 'session', projectId)
|
||||
await mkdir(sessionDir, { recursive: true })
|
||||
await writeFile(join(sessionDir, `${sessionId}.json`), JSON.stringify({
|
||||
id: sessionId,
|
||||
slug: 'cosmic-engine',
|
||||
version: '1.1.65',
|
||||
projectID: projectId,
|
||||
directory: opts.directory ?? '/Users/test/myproject',
|
||||
title: opts.title ?? 'Test session',
|
||||
time: { created: 1781886356809, updated: 1781886683506 },
|
||||
}))
|
||||
|
||||
const messageDir = join(storage, 'message', sessionId)
|
||||
await mkdir(messageDir, { recursive: true })
|
||||
for (const m of opts.messages) {
|
||||
await writeFile(join(messageDir, `${m.id}.json`), JSON.stringify({ id: m.id, sessionID: sessionId, ...m.data }))
|
||||
if (m.parts?.length) {
|
||||
const partDir = join(storage, 'part', m.id)
|
||||
await mkdir(partDir, { recursive: true })
|
||||
let i = 0
|
||||
for (const p of m.parts) {
|
||||
await writeFile(join(partDir, `prt_${m.id}_${String(i++).padStart(3, '0')}.json`), JSON.stringify(p))
|
||||
}
|
||||
}
|
||||
}
|
||||
return { sessionId }
|
||||
}
|
||||
|
||||
async function parseAll(seen = new Set<string>()): Promise<ParsedProviderCall[]> {
|
||||
const provider = createOpenCodeProvider(tmpDir)
|
||||
const sources = await provider.discoverSessions()
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for (const source of sources) {
|
||||
for await (const call of provider.createSessionParser(source, seen).parse()) calls.push(call)
|
||||
}
|
||||
return calls
|
||||
}
|
||||
|
||||
describe('opencode file-based provider - discovery', () => {
|
||||
it('discovers a file-based session and derives the project from directory', async () => {
|
||||
await writeSession({
|
||||
directory: '/Users/test/myproject',
|
||||
messages: [{
|
||||
id: 'msg_a',
|
||||
data: {
|
||||
role: 'assistant', modelID: 'gpt-5.3-codex-spark', cost: 0,
|
||||
tokens: { input: 1000, output: 200, reasoning: 50, cache: { read: 5000, write: 0 } },
|
||||
time: { created: 1781886356900 },
|
||||
},
|
||||
parts: [{ type: 'text', text: 'hello' }],
|
||||
}],
|
||||
})
|
||||
const provider = createOpenCodeProvider(tmpDir)
|
||||
const sources = await provider.discoverSessions()
|
||||
expect(sources).toHaveLength(1)
|
||||
expect(sources[0]!.provider).toBe('opencode')
|
||||
expect(sources[0]!.project).toBe('Users-test-myproject')
|
||||
expect(sources[0]!.path.endsWith('.json')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns nothing when neither file storage nor a DB exists', async () => {
|
||||
const provider = createOpenCodeProvider(tmpDir)
|
||||
expect(await provider.discoverSessions()).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('opencode file-based provider - parsing', () => {
|
||||
it('extracts tokens, tools, bash commands, and the preceding user message', async () => {
|
||||
await writeSession({
|
||||
messages: [
|
||||
{
|
||||
id: 'msg_user',
|
||||
data: { role: 'user', time: { created: 1 } },
|
||||
parts: [{ type: 'text', text: 'find the git repos' }],
|
||||
},
|
||||
{
|
||||
id: 'msg_a',
|
||||
data: {
|
||||
role: 'assistant', modelID: 'gpt-5.3-codex-spark', cost: 0,
|
||||
tokens: { input: 1200, output: 300, reasoning: 100, cache: { read: 8000, write: 0 } },
|
||||
time: { created: 2 },
|
||||
},
|
||||
parts: [
|
||||
{ type: 'reasoning' },
|
||||
{ type: 'tool', tool: 'bash', state: { status: 'completed', input: { command: 'git status' } } },
|
||||
{ type: 'tool', tool: 'read', state: { input: { filePath: '/x' } } },
|
||||
{ type: 'text', text: 'done' },
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
const calls = await parseAll()
|
||||
expect(calls).toHaveLength(1)
|
||||
const c = calls[0]!
|
||||
expect(c.provider).toBe('opencode')
|
||||
expect(c.model).toBe('gpt-5.3-codex-spark')
|
||||
expect(c.inputTokens).toBe(1200)
|
||||
expect(c.outputTokens).toBe(300)
|
||||
expect(c.reasoningTokens).toBe(100)
|
||||
expect(c.cacheReadInputTokens).toBe(8000)
|
||||
expect(c.cachedInputTokens).toBe(8000)
|
||||
expect(c.tools).toEqual(['Bash', 'Read'])
|
||||
expect(c.bashCommands).toContain('git')
|
||||
expect(c.userMessage).toBe('find the git repos')
|
||||
expect(c.deduplicationKey).toBe('opencode:ses_test1:msg_a')
|
||||
})
|
||||
|
||||
it('extracts skill names and subagent types from skill/task tool parts', async () => {
|
||||
await writeSession({
|
||||
messages: [{
|
||||
id: 'msg_a',
|
||||
data: {
|
||||
role: 'assistant', modelID: 'gpt-5.3-codex-spark', cost: 0,
|
||||
tokens: { input: 100, output: 20, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 1 },
|
||||
},
|
||||
parts: [
|
||||
{ type: 'tool', tool: 'skill', state: { input: { name: 'commit' } } },
|
||||
{ type: 'tool', tool: 'task', state: { input: { description: 'find files', subagent_type: 'explore' } } },
|
||||
{ type: 'text', text: 'done' },
|
||||
],
|
||||
}],
|
||||
})
|
||||
const calls = await parseAll()
|
||||
expect(calls).toHaveLength(1)
|
||||
const c = calls[0]!
|
||||
expect(c.tools).toEqual(['Skill', 'Agent'])
|
||||
expect(c.skills).toEqual(['commit'])
|
||||
expect(c.subagentTypes).toEqual(['explore'])
|
||||
})
|
||||
|
||||
it('leaves skills and subagentTypes empty when no skill/task parts are present', async () => {
|
||||
await writeSession({
|
||||
messages: [{
|
||||
id: 'msg_a',
|
||||
data: {
|
||||
role: 'assistant', modelID: 'gpt-5.3-codex-spark', cost: 0,
|
||||
tokens: { input: 100, output: 20, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 1 },
|
||||
},
|
||||
parts: [{ type: 'tool', tool: 'bash', state: { input: { command: 'ls' } } }],
|
||||
}],
|
||||
})
|
||||
const calls = await parseAll()
|
||||
expect(calls[0]!.skills).toEqual([])
|
||||
expect(calls[0]!.subagentTypes).toEqual([])
|
||||
})
|
||||
|
||||
it('skips an errored or empty assistant turn (all-zero tokens, no parts)', async () => {
|
||||
await writeSession({
|
||||
messages: [{
|
||||
id: 'msg_err',
|
||||
data: {
|
||||
role: 'assistant', modelID: 'gpt-5.3-codex', cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 1 },
|
||||
},
|
||||
}],
|
||||
})
|
||||
expect(await parseAll()).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('falls back to message.cost when the model has no price table', async () => {
|
||||
await writeSession({
|
||||
messages: [{
|
||||
id: 'msg_a',
|
||||
data: {
|
||||
role: 'assistant', modelID: 'totally-unknown-model-xyz', cost: 0.42,
|
||||
tokens: { input: 100, output: 20, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 1 },
|
||||
},
|
||||
parts: [{ type: 'text', text: 'x' }],
|
||||
}],
|
||||
})
|
||||
const calls = await parseAll()
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]!.costUSD).toBeCloseTo(0.42)
|
||||
})
|
||||
|
||||
it('deduplicates across repeated parses', async () => {
|
||||
await writeSession({
|
||||
messages: [{
|
||||
id: 'msg_a',
|
||||
data: {
|
||||
role: 'assistant', modelID: 'gpt-5.3-codex-spark',
|
||||
tokens: { input: 100, output: 20, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 1 },
|
||||
},
|
||||
parts: [{ type: 'text', text: 'x' }],
|
||||
}],
|
||||
})
|
||||
const seen = new Set<string>()
|
||||
expect(await parseAll(seen)).toHaveLength(1)
|
||||
expect(await parseAll(seen)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('reads sessions across multiple project folders', async () => {
|
||||
await writeSession({
|
||||
sessionId: 'ses_one', projectId: 'global', directory: '/Users/test/a',
|
||||
messages: [{ id: 'm1', data: { role: 'assistant', modelID: 'gpt-5.3-codex-spark', tokens: { input: 10, output: 2 }, time: { created: 1 } }, parts: [{ type: 'text', text: 'x' }] }],
|
||||
})
|
||||
await writeSession({
|
||||
sessionId: 'ses_two', projectId: 'proj_hash', directory: '/Users/test/b',
|
||||
messages: [{ id: 'm2', data: { role: 'assistant', modelID: 'gpt-5.3-codex-spark', tokens: { input: 20, output: 4 }, time: { created: 1 } }, parts: [{ type: 'text', text: 'y' }] }],
|
||||
})
|
||||
const calls = await parseAll()
|
||||
expect(calls).toHaveLength(2)
|
||||
expect(calls.map((c) => c.sessionId).sort()).toEqual(['ses_one', 'ses_two'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,901 @@
|
||||
import { mkdtemp, rm, mkdir, writeFile } from 'fs/promises'
|
||||
import { mkdirSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
import { tmpdir } from 'os'
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||
import { isSqliteAvailable } from '../../src/sqlite.js'
|
||||
import { createOpenCodeProvider } from '../../src/providers/opencode.js'
|
||||
import type { ParsedProviderCall } from '../../src/providers/types.js'
|
||||
|
||||
type TestDb = {
|
||||
exec(sql: string): void
|
||||
prepare(sql: string): { run(...params: unknown[]): void }
|
||||
close(): void
|
||||
}
|
||||
|
||||
let tmpDir: string
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await mkdtemp(join(tmpdir(), 'opencode-test-'))
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function createTestDb(dir: string): string {
|
||||
const ocDir = join(dir, 'opencode')
|
||||
mkdirSync(ocDir, { recursive: true })
|
||||
const dbPath = join(ocDir, 'opencode.db')
|
||||
|
||||
const { DatabaseSync: Database } = require('node:sqlite')
|
||||
const db = new Database(dbPath)
|
||||
db.exec(`
|
||||
CREATE TABLE session (
|
||||
id TEXT PRIMARY KEY, project_id TEXT NOT NULL, parent_id TEXT,
|
||||
slug TEXT NOT NULL, directory TEXT NOT NULL, title TEXT NOT NULL,
|
||||
version TEXT NOT NULL, time_created INTEGER, time_updated INTEGER,
|
||||
time_archived INTEGER
|
||||
)
|
||||
`)
|
||||
db.exec(`
|
||||
CREATE TABLE message (
|
||||
id TEXT PRIMARY KEY, session_id TEXT NOT NULL,
|
||||
time_created INTEGER, time_updated INTEGER, data TEXT NOT NULL
|
||||
)
|
||||
`)
|
||||
db.exec(`
|
||||
CREATE TABLE part (
|
||||
id TEXT PRIMARY KEY, message_id TEXT NOT NULL,
|
||||
session_id TEXT NOT NULL, time_created INTEGER,
|
||||
time_updated INTEGER, data TEXT NOT NULL
|
||||
)
|
||||
`)
|
||||
db.close()
|
||||
return dbPath
|
||||
}
|
||||
|
||||
function withTestDb(dbPath: string, fn: (db: TestDb) => void): void {
|
||||
const { DatabaseSync: Database } = require('node:sqlite')
|
||||
const db = new Database(dbPath)
|
||||
fn(db)
|
||||
db.close()
|
||||
}
|
||||
|
||||
function insertSession(
|
||||
db: TestDb,
|
||||
id: string,
|
||||
opts: { directory?: string; title?: string; parentId?: string | null; archived?: number | null } = {},
|
||||
): void {
|
||||
db.prepare(`
|
||||
INSERT INTO session (id, project_id, slug, directory, title, version, time_created, time_archived, parent_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(id, 'proj-1', 'slug-1', opts.directory ?? '/home/user/myproject', opts.title ?? 'My Project', '1.0', 1700000000000, opts.archived ?? null, opts.parentId ?? null)
|
||||
}
|
||||
|
||||
type MessageFixture = {
|
||||
role: string
|
||||
modelID?: string
|
||||
cost?: number
|
||||
tokens?: {
|
||||
input: number
|
||||
output: number
|
||||
reasoning: number
|
||||
cache: { read: number; write: number }
|
||||
}
|
||||
}
|
||||
|
||||
type PartFixture = {
|
||||
type: string
|
||||
text?: string
|
||||
tool?: string
|
||||
state?: { status: string; input: { command?: string } }
|
||||
}
|
||||
|
||||
function insertMessage(db: TestDb, id: string, sessionId: string, timeCreated: number, data: MessageFixture): void {
|
||||
db.prepare(`INSERT INTO message (id, session_id, time_created, data) VALUES (?, ?, ?, ?)`)
|
||||
.run(id, sessionId, timeCreated, JSON.stringify(data))
|
||||
}
|
||||
|
||||
function insertPart(db: TestDb, id: string, messageId: string, sessionId: string, data: PartFixture): void {
|
||||
db.prepare(`INSERT INTO part (id, message_id, session_id, data) VALUES (?, ?, ?, ?)`)
|
||||
.run(id, messageId, sessionId, JSON.stringify(data))
|
||||
}
|
||||
|
||||
async function collectCalls(provider: ReturnType<typeof createOpenCodeProvider>, dbPath: string, sessionId: string, seenKeys?: Set<string>): Promise<ParsedProviderCall[]> {
|
||||
const source = { path: `${dbPath}:${sessionId}`, project: 'myproject', provider: 'opencode' }
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of provider.createSessionParser(source, seenKeys ?? new Set()).parse()) {
|
||||
calls.push(call)
|
||||
}
|
||||
return calls
|
||||
}
|
||||
|
||||
const skipUnlessSqlite = isSqliteAvailable() ? describe : describe.skip
|
||||
|
||||
skipUnlessSqlite('opencode provider - model display names', () => {
|
||||
it('strips provider prefix and delegates to shared lookup', () => {
|
||||
const provider = createOpenCodeProvider()
|
||||
expect(provider.modelDisplayName('claude-opus-4-6-20260205')).toBe('Opus 4.6')
|
||||
})
|
||||
|
||||
it('strips google provider prefix', () => {
|
||||
const provider = createOpenCodeProvider()
|
||||
expect(provider.modelDisplayName('google/gemini-2.5-pro')).toBe('Gemini 2.5 Pro')
|
||||
})
|
||||
|
||||
it('strips openai provider prefix', () => {
|
||||
const provider = createOpenCodeProvider()
|
||||
expect(provider.modelDisplayName('openai/gpt-4o')).toBe('GPT-4o')
|
||||
})
|
||||
|
||||
it('passes through models without prefix unchanged', () => {
|
||||
const provider = createOpenCodeProvider()
|
||||
expect(provider.modelDisplayName('gpt-4o')).toBe('GPT-4o')
|
||||
expect(provider.modelDisplayName('gpt-4o-mini')).toBe('GPT-4o Mini')
|
||||
})
|
||||
|
||||
it('returns unknown models as-is', () => {
|
||||
const provider = createOpenCodeProvider()
|
||||
expect(provider.modelDisplayName('big-pickle')).toBe('big-pickle')
|
||||
})
|
||||
|
||||
it('has correct displayName', () => {
|
||||
const provider = createOpenCodeProvider()
|
||||
expect(provider.displayName).toBe('OpenCode')
|
||||
expect(provider.name).toBe('opencode')
|
||||
})
|
||||
})
|
||||
|
||||
skipUnlessSqlite('opencode provider - tool display names', () => {
|
||||
it('maps opencode builtins', () => {
|
||||
const provider = createOpenCodeProvider()
|
||||
expect(provider.toolDisplayName('bash')).toBe('Bash')
|
||||
expect(provider.toolDisplayName('edit')).toBe('Edit')
|
||||
expect(provider.toolDisplayName('task')).toBe('Agent')
|
||||
expect(provider.toolDisplayName('fetch')).toBe('WebFetch')
|
||||
expect(provider.toolDisplayName('grep')).toBe('Grep')
|
||||
expect(provider.toolDisplayName('write')).toBe('Write')
|
||||
expect(provider.toolDisplayName('skill')).toBe('Skill')
|
||||
})
|
||||
|
||||
it('returns unknown tools as-is', () => {
|
||||
const provider = createOpenCodeProvider()
|
||||
expect(provider.toolDisplayName('github_search_code')).toBe('github_search_code')
|
||||
})
|
||||
})
|
||||
|
||||
skipUnlessSqlite('opencode provider - session discovery', () => {
|
||||
it('discovers sessions with correct path format', async () => {
|
||||
const dbPath = createTestDb(tmpDir)
|
||||
withTestDb(dbPath, (db) => {
|
||||
insertSession(db, 'sess-1')
|
||||
})
|
||||
|
||||
const provider = createOpenCodeProvider(tmpDir)
|
||||
const sessions = await provider.discoverSessions()
|
||||
|
||||
expect(sessions).toHaveLength(1)
|
||||
expect(sessions[0]!.provider).toBe('opencode')
|
||||
expect(sessions[0]!.project).toBe('home-user-myproject')
|
||||
expect(sessions[0]!.path).toBe(`${dbPath}:sess-1`)
|
||||
})
|
||||
|
||||
it('excludes archived sessions', async () => {
|
||||
const dbPath = createTestDb(tmpDir)
|
||||
withTestDb(dbPath, (db) => {
|
||||
insertSession(db, 'sess-archived', { archived: 1700000001000 })
|
||||
})
|
||||
|
||||
const provider = createOpenCodeProvider(tmpDir)
|
||||
const sessions = await provider.discoverSessions()
|
||||
expect(sessions).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('excludes child sessions', async () => {
|
||||
const dbPath = createTestDb(tmpDir)
|
||||
withTestDb(dbPath, (db) => {
|
||||
insertSession(db, 'sess-child', { parentId: 'parent-id' })
|
||||
})
|
||||
|
||||
const provider = createOpenCodeProvider(tmpDir)
|
||||
const sessions = await provider.discoverSessions()
|
||||
expect(sessions).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('returns empty for non-existent path', async () => {
|
||||
const provider = createOpenCodeProvider('/nonexistent/path')
|
||||
const sessions = await provider.discoverSessions()
|
||||
expect(sessions).toEqual([])
|
||||
})
|
||||
|
||||
it('returns empty for empty database', async () => {
|
||||
createTestDb(tmpDir)
|
||||
const provider = createOpenCodeProvider(tmpDir)
|
||||
const sessions = await provider.discoverSessions()
|
||||
expect(sessions).toEqual([])
|
||||
})
|
||||
|
||||
it('discovers sessions across multiple channel databases', async () => {
|
||||
const ocDir = join(tmpDir, 'opencode')
|
||||
await mkdir(ocDir, { recursive: true })
|
||||
|
||||
const { DatabaseSync: Database } = require('node:sqlite')
|
||||
for (const file of ['opencode.db', 'opencode-dev.db']) {
|
||||
const dbPath = join(ocDir, file)
|
||||
const db = new Database(dbPath)
|
||||
db.exec(`
|
||||
CREATE TABLE session (id TEXT PRIMARY KEY, project_id TEXT NOT NULL, parent_id TEXT,
|
||||
slug TEXT NOT NULL, directory TEXT NOT NULL, title TEXT NOT NULL,
|
||||
version TEXT NOT NULL, time_created INTEGER, time_updated INTEGER, time_archived INTEGER)
|
||||
`)
|
||||
db.exec(`CREATE TABLE message (id TEXT PRIMARY KEY, session_id TEXT NOT NULL,
|
||||
time_created INTEGER, time_updated INTEGER, data TEXT NOT NULL)`)
|
||||
db.exec(`CREATE TABLE part (id TEXT PRIMARY KEY, message_id TEXT NOT NULL,
|
||||
session_id TEXT NOT NULL, time_created INTEGER, time_updated INTEGER, data TEXT NOT NULL)`)
|
||||
db.prepare(`INSERT INTO session (id, project_id, slug, directory, title, version, time_created)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`).run(`sess-${file}`, 'proj-1', 'slug-1', '/home/user/myproject', 'My Project', '1.0', 1700000000000)
|
||||
db.close()
|
||||
}
|
||||
|
||||
const provider = createOpenCodeProvider(tmpDir)
|
||||
const sessions = await provider.discoverSessions()
|
||||
|
||||
expect(sessions).toHaveLength(2)
|
||||
expect(sessions.map(s => s.path)).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining('opencode.db:sess-opencode.db'),
|
||||
expect.stringContaining('opencode-dev.db:sess-opencode-dev.db'),
|
||||
]),
|
||||
)
|
||||
expect(sessions.every(s => s.provider === 'opencode')).toBe(true)
|
||||
})
|
||||
|
||||
it('ignores non-opencode db files in the directory', async () => {
|
||||
const dbPath = createTestDb(tmpDir)
|
||||
withTestDb(dbPath, (db) => {
|
||||
insertSession(db, 'sess-1')
|
||||
})
|
||||
await writeFile(join(tmpDir, 'opencode', 'other.db'), '')
|
||||
await writeFile(join(tmpDir, 'opencode', 'opencode.txt'), '')
|
||||
|
||||
const provider = createOpenCodeProvider(tmpDir)
|
||||
const sessions = await provider.discoverSessions()
|
||||
expect(sessions).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('sanitizes title when directory is empty', async () => {
|
||||
const dbPath = createTestDb(tmpDir)
|
||||
withTestDb(dbPath, (db) => {
|
||||
insertSession(db, 'sess-1', { directory: '', title: 'My Session Title' })
|
||||
})
|
||||
|
||||
const provider = createOpenCodeProvider(tmpDir)
|
||||
const sessions = await provider.discoverSessions()
|
||||
expect(sessions[0]!.project).toBe('My Session Title')
|
||||
})
|
||||
|
||||
it('discovers multiple sessions in one database', async () => {
|
||||
const dbPath = createTestDb(tmpDir)
|
||||
withTestDb(dbPath, (db) => {
|
||||
insertSession(db, 'sess-1', { directory: '/home/user/project-a', title: 'A' })
|
||||
insertSession(db, 'sess-2', { directory: '/home/user/project-b', title: 'B' })
|
||||
})
|
||||
|
||||
const provider = createOpenCodeProvider(tmpDir)
|
||||
const sessions = await provider.discoverSessions()
|
||||
expect(sessions).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
skipUnlessSqlite('opencode provider - session parsing', () => {
|
||||
it('parses assistant messages with all fields', async () => {
|
||||
const dbPath = createTestDb(tmpDir)
|
||||
withTestDb(dbPath, (db) => {
|
||||
insertSession(db, 'sess-1')
|
||||
|
||||
insertMessage(db, 'msg-1', 'sess-1', 1700000000000, { role: 'user' })
|
||||
insertPart(db, 'part-1', 'msg-1', 'sess-1', { type: 'text', text: 'fix the login bug' })
|
||||
|
||||
insertMessage(db, 'msg-2', 'sess-1', 1700000001000, {
|
||||
role: 'assistant',
|
||||
modelID: 'claude-opus-4-6',
|
||||
cost: 0.05,
|
||||
tokens: { input: 100, output: 200, reasoning: 50, cache: { read: 500, write: 300 } },
|
||||
})
|
||||
insertPart(db, 'part-2', 'msg-2', 'sess-1', {
|
||||
type: 'tool', tool: 'bash',
|
||||
state: { status: 'completed', input: { command: 'npm test && git push' } },
|
||||
})
|
||||
insertPart(db, 'part-3', 'msg-2', 'sess-1', {
|
||||
type: 'tool', tool: 'edit', state: { status: 'completed', input: {} },
|
||||
})
|
||||
})
|
||||
|
||||
const provider = createOpenCodeProvider(tmpDir)
|
||||
const calls = await collectCalls(provider, dbPath, 'sess-1')
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
const call = calls[0]!
|
||||
expect(call.provider).toBe('opencode')
|
||||
expect(call.model).toBe('claude-opus-4-6')
|
||||
expect(call.inputTokens).toBe(100)
|
||||
expect(call.outputTokens).toBe(200)
|
||||
expect(call.reasoningTokens).toBe(50)
|
||||
expect(call.cacheReadInputTokens).toBe(500)
|
||||
expect(call.cacheCreationInputTokens).toBe(300)
|
||||
expect(call.cachedInputTokens).toBe(500)
|
||||
expect(call.webSearchRequests).toBe(0)
|
||||
expect(call.speed).toBe('standard')
|
||||
expect(call.costUSD).toBeGreaterThan(0)
|
||||
expect(call.tools).toEqual(['Bash', 'Edit'])
|
||||
expect(call.bashCommands).toEqual(['npm', 'git'])
|
||||
expect(call.userMessage).toBe('fix the login bug')
|
||||
expect(call.sessionId).toBe('sess-1')
|
||||
expect(call.timestamp).toBe(new Date(1700000001000).toISOString())
|
||||
expect(call.deduplicationKey).toBe('opencode:sess-1:msg-2')
|
||||
})
|
||||
|
||||
it('normalizes opencode MCP tool names for shared MCP reporting', async () => {
|
||||
const dbPath = createTestDb(tmpDir)
|
||||
withTestDb(dbPath, (db) => {
|
||||
insertSession(db, 'sess-1')
|
||||
|
||||
insertMessage(db, 'msg-1', 'sess-1', 1700000000000, { role: 'user' })
|
||||
insertPart(db, 'part-1', 'msg-1', 'sess-1', { type: 'text', text: 'look up the ClickUp task' })
|
||||
|
||||
insertMessage(db, 'msg-2', 'sess-1', 1700000001000, {
|
||||
role: 'assistant',
|
||||
modelID: 'claude-opus-4-6',
|
||||
cost: 0.05,
|
||||
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
})
|
||||
insertPart(db, 'part-2', 'msg-2', 'sess-1', {
|
||||
type: 'tool',
|
||||
tool: 'clickup_clickup_get_task',
|
||||
state: { status: 'completed', input: {} },
|
||||
})
|
||||
insertPart(db, 'part-3', 'msg-2', 'sess-1', {
|
||||
type: 'tool',
|
||||
tool: 'figma_get_file',
|
||||
state: { status: 'completed', input: {} },
|
||||
})
|
||||
})
|
||||
|
||||
const calls = await collectCalls(createOpenCodeProvider(tmpDir), dbPath, 'sess-1')
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]!.tools).toEqual([
|
||||
'mcp__clickup__clickup_get_task',
|
||||
'mcp__figma__get_file',
|
||||
])
|
||||
})
|
||||
|
||||
it('preserves already-normalized MCP tool names', async () => {
|
||||
const dbPath = createTestDb(tmpDir)
|
||||
withTestDb(dbPath, (db) => {
|
||||
insertSession(db, 'sess-1')
|
||||
insertMessage(db, 'msg-1', 'sess-1', 1700000001000, {
|
||||
role: 'assistant',
|
||||
modelID: 'claude-opus-4-6',
|
||||
cost: 0.05,
|
||||
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
})
|
||||
insertPart(db, 'part-1', 'msg-1', 'sess-1', {
|
||||
type: 'tool',
|
||||
tool: 'mcp__github__search_code',
|
||||
state: { status: 'completed', input: {} },
|
||||
})
|
||||
})
|
||||
|
||||
const calls = await collectCalls(createOpenCodeProvider(tmpDir), dbPath, 'sess-1')
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]!.tools).toEqual(['mcp__github__search_code'])
|
||||
})
|
||||
|
||||
it('keeps extension tool names without a server prefix as regular tools', async () => {
|
||||
const dbPath = createTestDb(tmpDir)
|
||||
withTestDb(dbPath, (db) => {
|
||||
insertSession(db, 'sess-1')
|
||||
insertMessage(db, 'msg-1', 'sess-1', 1700000001000, {
|
||||
role: 'assistant',
|
||||
modelID: 'claude-opus-4-6',
|
||||
cost: 0.05,
|
||||
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
})
|
||||
insertPart(db, 'part-1', 'msg-1', 'sess-1', {
|
||||
type: 'tool',
|
||||
tool: 'customtool',
|
||||
state: { status: 'completed', input: {} },
|
||||
})
|
||||
})
|
||||
|
||||
const calls = await collectCalls(createOpenCodeProvider(tmpDir), dbPath, 'sess-1')
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]!.tools).toEqual(['customtool'])
|
||||
})
|
||||
|
||||
it('keeps malformed server-prefixed tool names as regular tools', async () => {
|
||||
const dbPath = createTestDb(tmpDir)
|
||||
withTestDb(dbPath, (db) => {
|
||||
insertSession(db, 'sess-1')
|
||||
insertMessage(db, 'msg-1', 'sess-1', 1700000001000, {
|
||||
role: 'assistant',
|
||||
modelID: 'claude-opus-4-6',
|
||||
cost: 0.05,
|
||||
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
})
|
||||
insertPart(db, 'part-1', 'msg-1', 'sess-1', {
|
||||
type: 'tool',
|
||||
tool: '_missing_server',
|
||||
state: { status: 'completed', input: {} },
|
||||
})
|
||||
insertPart(db, 'part-2', 'msg-1', 'sess-1', {
|
||||
type: 'tool',
|
||||
tool: 'missing_',
|
||||
state: { status: 'completed', input: {} },
|
||||
})
|
||||
insertPart(db, 'part-3', 'msg-1', 'sess-1', {
|
||||
type: 'tool',
|
||||
tool: '_',
|
||||
state: { status: 'completed', input: {} },
|
||||
})
|
||||
})
|
||||
|
||||
const calls = await collectCalls(createOpenCodeProvider(tmpDir), dbPath, 'sess-1')
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]!.tools).toEqual([
|
||||
'_missing_server',
|
||||
'missing_',
|
||||
'_',
|
||||
])
|
||||
})
|
||||
|
||||
it('skips zero-token messages with zero cost', async () => {
|
||||
const dbPath = createTestDb(tmpDir)
|
||||
withTestDb(dbPath, (db) => {
|
||||
insertSession(db, 'sess-1')
|
||||
insertMessage(db, 'msg-1', 'sess-1', 1700000001000, {
|
||||
role: 'assistant', modelID: 'claude-opus-4-6', cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
})
|
||||
})
|
||||
|
||||
const calls = await collectCalls(createOpenCodeProvider(tmpDir), dbPath, 'sess-1')
|
||||
expect(calls).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('keeps zero-usage assistant messages when router responses contain text', async () => {
|
||||
const dbPath = createTestDb(tmpDir)
|
||||
withTestDb(dbPath, (db) => {
|
||||
insertSession(db, 'sess-1')
|
||||
insertMessage(db, 'msg-u1', 'sess-1', 1700000000000, { role: 'user' })
|
||||
insertPart(db, 'part-u1', 'msg-u1', 'sess-1', { type: 'text', text: 'use the configured router' })
|
||||
insertMessage(db, 'msg-a1', 'sess-1', 1700000001000, {
|
||||
role: 'assistant', modelID: 'edenai/router-model', cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
})
|
||||
insertPart(db, 'part-a1', 'msg-a1', 'sess-1', { type: 'text', text: 'router response text' })
|
||||
})
|
||||
|
||||
const calls = await collectCalls(createOpenCodeProvider(tmpDir), dbPath, 'sess-1')
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]!.model).toBe('edenai/router-model')
|
||||
expect(calls[0]!.inputTokens).toBe(0)
|
||||
expect(calls[0]!.outputTokens).toBe(0)
|
||||
expect(calls[0]!.costUSD).toBe(0)
|
||||
expect(calls[0]!.userMessage).toBe('use the configured router')
|
||||
})
|
||||
|
||||
it('keeps zero-usage assistant messages when router responses contain tool calls', async () => {
|
||||
const dbPath = createTestDb(tmpDir)
|
||||
withTestDb(dbPath, (db) => {
|
||||
insertSession(db, 'sess-1')
|
||||
insertMessage(db, 'msg-a1', 'sess-1', 1700000001000, {
|
||||
role: 'assistant', modelID: 'edenai/router-model', cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
})
|
||||
insertPart(db, 'part-a1', 'msg-a1', 'sess-1', {
|
||||
type: 'tool', tool: 'bash',
|
||||
state: { status: 'completed', input: { command: 'npm test' } },
|
||||
})
|
||||
})
|
||||
|
||||
const calls = await collectCalls(createOpenCodeProvider(tmpDir), dbPath, 'sess-1')
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]!.tools).toEqual(['Bash'])
|
||||
expect(calls[0]!.bashCommands).toEqual(['npm'])
|
||||
expect(calls[0]!.costUSD).toBe(0)
|
||||
})
|
||||
|
||||
it('deduplicates messages across parses', async () => {
|
||||
const dbPath = createTestDb(tmpDir)
|
||||
withTestDb(dbPath, (db) => {
|
||||
insertSession(db, 'sess-1')
|
||||
insertMessage(db, 'msg-1', 'sess-1', 1700000001000, {
|
||||
role: 'assistant', modelID: 'claude-opus-4-6', cost: 0.05,
|
||||
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
})
|
||||
})
|
||||
|
||||
const provider = createOpenCodeProvider(tmpDir)
|
||||
const seenKeys = new Set<string>()
|
||||
const calls1 = await collectCalls(provider, dbPath, 'sess-1', seenKeys)
|
||||
const calls2 = await collectCalls(provider, dbPath, 'sess-1', seenKeys)
|
||||
|
||||
expect(calls1).toHaveLength(1)
|
||||
expect(calls2).toHaveLength(0)
|
||||
expect(seenKeys.has('opencode:sess-1:msg-1')).toBe(true)
|
||||
})
|
||||
|
||||
it('falls back to pre-calculated cost for unknown models', async () => {
|
||||
const dbPath = createTestDb(tmpDir)
|
||||
withTestDb(dbPath, (db) => {
|
||||
insertSession(db, 'sess-1')
|
||||
insertMessage(db, 'msg-1', 'sess-1', 1700000001000, {
|
||||
role: 'assistant', modelID: 'totally-unknown-model-xyz', cost: 0.42,
|
||||
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
})
|
||||
})
|
||||
|
||||
const calls = await collectCalls(createOpenCodeProvider(tmpDir), dbPath, 'sess-1')
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]!.costUSD).toBe(0.42)
|
||||
})
|
||||
|
||||
it('uses calculated cost over pre-calculated for known models', async () => {
|
||||
const dbPath = createTestDb(tmpDir)
|
||||
withTestDb(dbPath, (db) => {
|
||||
insertSession(db, 'sess-1')
|
||||
insertMessage(db, 'msg-1', 'sess-1', 1700000001000, {
|
||||
role: 'assistant', modelID: 'claude-opus-4-6', cost: 999.99,
|
||||
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
})
|
||||
})
|
||||
|
||||
const calls = await collectCalls(createOpenCodeProvider(tmpDir), dbPath, 'sess-1')
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]!.costUSD).toBeGreaterThan(0)
|
||||
expect(calls[0]!.costUSD).not.toBe(999.99)
|
||||
})
|
||||
|
||||
it('handles missing tokens field gracefully', async () => {
|
||||
const dbPath = createTestDb(tmpDir)
|
||||
withTestDb(dbPath, (db) => {
|
||||
insertSession(db, 'sess-1')
|
||||
insertMessage(db, 'msg-1', 'sess-1', 1700000001000, {
|
||||
role: 'assistant', modelID: 'claude-opus-4-6', cost: 0.10,
|
||||
})
|
||||
})
|
||||
|
||||
const calls = await collectCalls(createOpenCodeProvider(tmpDir), dbPath, 'sess-1')
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]!.inputTokens).toBe(0)
|
||||
expect(calls[0]!.outputTokens).toBe(0)
|
||||
expect(calls[0]!.costUSD).toBe(0.10)
|
||||
})
|
||||
|
||||
it('uses "unknown" for missing modelID', async () => {
|
||||
const dbPath = createTestDb(tmpDir)
|
||||
withTestDb(dbPath, (db) => {
|
||||
insertSession(db, 'sess-1')
|
||||
insertMessage(db, 'msg-1', 'sess-1', 1700000001000, {
|
||||
role: 'assistant', cost: 0.05,
|
||||
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
})
|
||||
})
|
||||
|
||||
const calls = await collectCalls(createOpenCodeProvider(tmpDir), dbPath, 'sess-1')
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]!.model).toBe('unknown')
|
||||
})
|
||||
|
||||
it('handles corrupt JSON in message and part data', async () => {
|
||||
const dbPath = createTestDb(tmpDir)
|
||||
withTestDb(dbPath, (db) => {
|
||||
insertSession(db, 'sess-1')
|
||||
|
||||
db.prepare(`INSERT INTO message (id, session_id, time_created, data) VALUES (?, ?, ?, ?)`)
|
||||
.run('msg-corrupt', 'sess-1', 1700000000500, 'not valid json {]')
|
||||
|
||||
insertMessage(db, 'msg-valid', 'sess-1', 1700000001000, {
|
||||
role: 'assistant', modelID: 'claude-opus-4-6', cost: 0.05,
|
||||
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
})
|
||||
|
||||
db.prepare(`INSERT INTO part (id, message_id, session_id, data) VALUES (?, ?, ?, ?)`)
|
||||
.run('part-corrupt', 'msg-valid', 'sess-1', 'corrupt {[}')
|
||||
|
||||
insertPart(db, 'part-valid', 'msg-valid', 'sess-1', {
|
||||
type: 'tool', tool: 'bash', state: { status: 'completed', input: {} },
|
||||
})
|
||||
})
|
||||
|
||||
const calls = await collectCalls(createOpenCodeProvider(tmpDir), dbPath, 'sess-1')
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]!.model).toBe('claude-opus-4-6')
|
||||
expect(calls[0]!.tools).toEqual(['Bash'])
|
||||
})
|
||||
|
||||
it('converts seconds-epoch timestamps to milliseconds', async () => {
|
||||
const dbPath = createTestDb(tmpDir)
|
||||
withTestDb(dbPath, (db) => {
|
||||
insertSession(db, 'sess-1')
|
||||
insertMessage(db, 'msg-1', 'sess-1', 1700000001, {
|
||||
role: 'assistant', modelID: 'claude-opus-4-6', cost: 0.05,
|
||||
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
})
|
||||
})
|
||||
|
||||
const calls = await collectCalls(createOpenCodeProvider(tmpDir), dbPath, 'sess-1')
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]!.timestamp).toBe(new Date(1700000001 * 1000).toISOString())
|
||||
})
|
||||
|
||||
it('skips non-user non-assistant roles', async () => {
|
||||
const dbPath = createTestDb(tmpDir)
|
||||
withTestDb(dbPath, (db) => {
|
||||
insertSession(db, 'sess-1')
|
||||
insertMessage(db, 'msg-1', 'sess-1', 1700000001000, {
|
||||
role: 'system', modelID: 'claude-opus-4-6',
|
||||
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
})
|
||||
})
|
||||
|
||||
const calls = await collectCalls(createOpenCodeProvider(tmpDir), dbPath, 'sess-1')
|
||||
expect(calls).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('returns empty for invalid db path', async () => {
|
||||
const provider = createOpenCodeProvider(tmpDir)
|
||||
const source = { path: '/nonexistent/db.db:sess-1', project: 'test', provider: 'opencode' }
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of provider.createSessionParser(source, new Set()).parse()) calls.push(call)
|
||||
expect(calls).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('tracks user messages per assistant response', async () => {
|
||||
const dbPath = createTestDb(tmpDir)
|
||||
withTestDb(dbPath, (db) => {
|
||||
insertSession(db, 'sess-1')
|
||||
|
||||
insertMessage(db, 'msg-u1', 'sess-1', 1700000000000, { role: 'user' })
|
||||
insertPart(db, 'part-u1', 'msg-u1', 'sess-1', { type: 'text', text: 'first question' })
|
||||
|
||||
insertMessage(db, 'msg-a1', 'sess-1', 1700000001000, {
|
||||
role: 'assistant', modelID: 'claude-opus-4-6', cost: 0.01,
|
||||
tokens: { input: 50, output: 50, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
})
|
||||
|
||||
insertMessage(db, 'msg-u2', 'sess-1', 1700000002000, { role: 'user' })
|
||||
insertPart(db, 'part-u2', 'msg-u2', 'sess-1', { type: 'text', text: 'second question' })
|
||||
|
||||
insertMessage(db, 'msg-a2', 'sess-1', 1700000003000, {
|
||||
role: 'assistant', modelID: 'claude-opus-4-6', cost: 0.02,
|
||||
tokens: { input: 80, output: 80, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
})
|
||||
})
|
||||
|
||||
const calls = await collectCalls(createOpenCodeProvider(tmpDir), dbPath, 'sess-1')
|
||||
expect(calls).toHaveLength(2)
|
||||
expect(calls[0]!.userMessage).toBe('first question')
|
||||
expect(calls[1]!.userMessage).toBe('second question')
|
||||
})
|
||||
|
||||
it('attributes child and grandchild session calls back to the root session', async () => {
|
||||
const dbPath = createTestDb(tmpDir)
|
||||
withTestDb(dbPath, (db) => {
|
||||
insertSession(db, 'root')
|
||||
insertSession(db, 'child', { parentId: 'root' })
|
||||
insertSession(db, 'grandchild', { parentId: 'child' })
|
||||
|
||||
insertMessage(db, 'msg-root-user', 'root', 1700000000000, { role: 'user' })
|
||||
insertPart(db, 'part-root-user', 'msg-root-user', 'root', { type: 'text', text: 'root prompt' })
|
||||
insertMessage(db, 'msg-root-assistant', 'root', 1700000001000, {
|
||||
role: 'assistant',
|
||||
modelID: 'claude-opus-4-6',
|
||||
cost: 0.01,
|
||||
tokens: { input: 10, output: 20, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
})
|
||||
insertPart(db, 'part-root-tool', 'msg-root-assistant', 'root', {
|
||||
type: 'tool',
|
||||
tool: 'read',
|
||||
state: { status: 'completed', input: {} },
|
||||
})
|
||||
|
||||
insertMessage(db, 'msg-child-user', 'child', 1700000002000, { role: 'user' })
|
||||
insertPart(db, 'part-child-user', 'msg-child-user', 'child', { type: 'text', text: 'child prompt' })
|
||||
insertMessage(db, 'msg-child-assistant', 'child', 1700000003000, {
|
||||
role: 'assistant',
|
||||
modelID: 'claude-opus-4-6',
|
||||
cost: 0.02,
|
||||
tokens: { input: 30, output: 40, reasoning: 5, cache: { read: 0, write: 0 } },
|
||||
})
|
||||
insertPart(db, 'part-child-tool', 'msg-child-assistant', 'child', {
|
||||
type: 'tool',
|
||||
tool: 'task',
|
||||
state: { status: 'completed', input: {} },
|
||||
})
|
||||
|
||||
insertMessage(db, 'msg-grand-user', 'grandchild', 1700000004000, { role: 'user' })
|
||||
insertPart(db, 'part-grand-user', 'msg-grand-user', 'grandchild', { type: 'text', text: 'grandchild prompt' })
|
||||
insertMessage(db, 'msg-grand-assistant', 'grandchild', 1700000005000, {
|
||||
role: 'assistant',
|
||||
modelID: 'claude-opus-4-6',
|
||||
cost: 0.03,
|
||||
tokens: { input: 50, output: 60, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
})
|
||||
insertPart(db, 'part-grand-tool', 'msg-grand-assistant', 'grandchild', {
|
||||
type: 'tool',
|
||||
tool: 'bash',
|
||||
state: { status: 'completed', input: { command: 'npm test' } },
|
||||
})
|
||||
})
|
||||
|
||||
const calls = await collectCalls(createOpenCodeProvider(tmpDir), dbPath, 'root')
|
||||
|
||||
expect(calls).toHaveLength(3)
|
||||
expect(calls.map(call => call.sessionId)).toEqual(['root', 'root', 'root'])
|
||||
expect(calls.map(call => call.deduplicationKey)).toEqual([
|
||||
'opencode:root:msg-root-assistant',
|
||||
'opencode:child:msg-child-assistant',
|
||||
'opencode:grandchild:msg-grand-assistant',
|
||||
])
|
||||
expect(calls.map(call => call.userMessage)).toEqual([
|
||||
'root prompt',
|
||||
'child prompt',
|
||||
'grandchild prompt',
|
||||
])
|
||||
expect(calls[0]!.tools).toEqual(['Read'])
|
||||
expect(calls[1]!.tools).toEqual(['Agent'])
|
||||
expect(calls[2]!.tools).toEqual(['Bash'])
|
||||
expect(calls[2]!.bashCommands).toEqual(['npm'])
|
||||
})
|
||||
|
||||
it('does not include archived child sessions in the root subtree', async () => {
|
||||
const dbPath = createTestDb(tmpDir)
|
||||
withTestDb(dbPath, (db) => {
|
||||
insertSession(db, 'root')
|
||||
insertSession(db, 'archived-child', { parentId: 'root', archived: 1700000002500 })
|
||||
|
||||
insertMessage(db, 'msg-root-assistant', 'root', 1700000001000, {
|
||||
role: 'assistant',
|
||||
modelID: 'claude-opus-4-6',
|
||||
cost: 0.01,
|
||||
tokens: { input: 10, output: 20, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
})
|
||||
|
||||
insertMessage(db, 'msg-child-assistant', 'archived-child', 1700000003000, {
|
||||
role: 'assistant',
|
||||
modelID: 'claude-opus-4-6',
|
||||
cost: 0.02,
|
||||
tokens: { input: 30, output: 40, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
})
|
||||
})
|
||||
|
||||
const calls = await collectCalls(createOpenCodeProvider(tmpDir), dbPath, 'root')
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]!.deduplicationKey).toBe('opencode:root:msg-root-assistant')
|
||||
})
|
||||
|
||||
it('joins multiple text parts in user messages', async () => {
|
||||
const dbPath = createTestDb(tmpDir)
|
||||
withTestDb(dbPath, (db) => {
|
||||
insertSession(db, 'sess-1')
|
||||
|
||||
insertMessage(db, 'msg-u1', 'sess-1', 1700000000000, { role: 'user' })
|
||||
insertPart(db, 'part-a', 'msg-u1', 'sess-1', { type: 'text', text: 'hello' })
|
||||
insertPart(db, 'part-b', 'msg-u1', 'sess-1', { type: 'text', text: 'world' })
|
||||
|
||||
insertMessage(db, 'msg-a1', 'sess-1', 1700000001000, {
|
||||
role: 'assistant', modelID: 'claude-opus-4-6', cost: 0.01,
|
||||
tokens: { input: 50, output: 50, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
})
|
||||
})
|
||||
|
||||
const calls = await collectCalls(createOpenCodeProvider(tmpDir), dbPath, 'sess-1')
|
||||
expect(calls[0]!.userMessage).toBe('hello world')
|
||||
})
|
||||
|
||||
it('yields nothing for session with only user messages', async () => {
|
||||
const dbPath = createTestDb(tmpDir)
|
||||
withTestDb(dbPath, (db) => {
|
||||
insertSession(db, 'sess-1')
|
||||
insertMessage(db, 'msg-u1', 'sess-1', 1700000000000, { role: 'user' })
|
||||
insertPart(db, 'part-u1', 'msg-u1', 'sess-1', { type: 'text', text: 'hello?' })
|
||||
})
|
||||
|
||||
const calls = await collectCalls(createOpenCodeProvider(tmpDir), dbPath, 'sess-1')
|
||||
expect(calls).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('falls back to session-level tokens when per-message data yields nothing', async () => {
|
||||
const dbPath = createTestDb(tmpDir)
|
||||
withTestDb(dbPath, (db) => {
|
||||
db.exec(`ALTER TABLE session ADD COLUMN cost REAL`)
|
||||
db.exec(`ALTER TABLE session ADD COLUMN tokens_input INTEGER`)
|
||||
db.exec(`ALTER TABLE session ADD COLUMN tokens_output INTEGER`)
|
||||
db.exec(`ALTER TABLE session ADD COLUMN tokens_reasoning INTEGER`)
|
||||
db.exec(`ALTER TABLE session ADD COLUMN tokens_cache_read INTEGER`)
|
||||
db.exec(`ALTER TABLE session ADD COLUMN tokens_cache_write INTEGER`)
|
||||
db.exec(`ALTER TABLE session ADD COLUMN model_id TEXT`)
|
||||
|
||||
insertSession(db, 'sess-1')
|
||||
db.prepare(`UPDATE session SET cost = 0.15, tokens_input = 5000, tokens_output = 2000, tokens_reasoning = 0, tokens_cache_read = 3000, tokens_cache_write = 1000, model_id = 'claude-sonnet-4-20250514' WHERE id = 'sess-1'`).run()
|
||||
|
||||
insertMessage(db, 'msg-1', 'sess-1', 1700000001000, {
|
||||
role: 'assistant', modelID: 'claude-sonnet-4-20250514',
|
||||
})
|
||||
})
|
||||
|
||||
const calls = await collectCalls(createOpenCodeProvider(tmpDir), dbPath, 'sess-1')
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]!.inputTokens).toBe(5000)
|
||||
expect(calls[0]!.outputTokens).toBe(2000)
|
||||
expect(calls[0]!.cacheReadInputTokens).toBe(3000)
|
||||
expect(calls[0]!.cacheCreationInputTokens).toBe(1000)
|
||||
expect(calls[0]!.costUSD).toBeGreaterThan(0)
|
||||
expect(calls[0]!.model).toBe('claude-sonnet-4-20250514')
|
||||
expect(calls[0]!.deduplicationKey).toBe('opencode:sess-1:session-level')
|
||||
})
|
||||
|
||||
it('accepts role "model" as equivalent to "assistant"', async () => {
|
||||
const dbPath = createTestDb(tmpDir)
|
||||
withTestDb(dbPath, (db) => {
|
||||
insertSession(db, 'sess-1')
|
||||
insertMessage(db, 'msg-1', 'sess-1', 1700000001000, {
|
||||
role: 'model', modelID: 'gemini-2.5-pro', cost: 0.03,
|
||||
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
} as any)
|
||||
})
|
||||
|
||||
const calls = await collectCalls(createOpenCodeProvider(tmpDir), dbPath, 'sess-1')
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]!.model).toBe('gemini-2.5-pro')
|
||||
})
|
||||
|
||||
it('recognizes tool-call and tool_call part types', async () => {
|
||||
const dbPath = createTestDb(tmpDir)
|
||||
withTestDb(dbPath, (db) => {
|
||||
insertSession(db, 'sess-1')
|
||||
insertMessage(db, 'msg-1', 'sess-1', 1700000001000, {
|
||||
role: 'assistant', modelID: 'claude-opus-4-6',
|
||||
})
|
||||
insertPart(db, 'part-1', 'msg-1', 'sess-1', {
|
||||
type: 'tool-call', tool: 'bash',
|
||||
state: { status: 'completed', input: { command: 'ls' } },
|
||||
} as any)
|
||||
insertPart(db, 'part-2', 'msg-1', 'sess-1', {
|
||||
type: 'tool_call', tool: 'edit',
|
||||
state: { status: 'completed', input: {} },
|
||||
} as any)
|
||||
})
|
||||
|
||||
const calls = await collectCalls(createOpenCodeProvider(tmpDir), dbPath, 'sess-1')
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]!.tools).toEqual(['Bash', 'Edit'])
|
||||
})
|
||||
|
||||
it('counts reasoning/file parts as activity even without text or tool parts', async () => {
|
||||
const dbPath = createTestDb(tmpDir)
|
||||
withTestDb(dbPath, (db) => {
|
||||
insertSession(db, 'sess-1')
|
||||
insertMessage(db, 'msg-1', 'sess-1', 1700000001000, {
|
||||
role: 'assistant', modelID: 'claude-opus-4-6',
|
||||
})
|
||||
insertPart(db, 'part-1', 'msg-1', 'sess-1', {
|
||||
type: 'reasoning',
|
||||
} as any)
|
||||
})
|
||||
|
||||
const calls = await collectCalls(createOpenCodeProvider(tmpDir), dbPath, 'sess-1')
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]!.costUSD).toBe(0)
|
||||
expect(calls[0]!.tools).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,514 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||
import { mkdtemp, mkdir, writeFile, rm } from 'fs/promises'
|
||||
import { join } from 'path'
|
||||
import { tmpdir } from 'os'
|
||||
|
||||
import { createPiProvider } from '../../src/providers/pi.js'
|
||||
import type { ParsedProviderCall } from '../../src/providers/types.js'
|
||||
import { classifyTurn } from '../../src/classifier.js'
|
||||
import type { ParsedApiCall, ParsedTurn } from '../../src/types.js'
|
||||
|
||||
// Mirrors src/parser.ts providerCallToTurn so we can assert that a Pi call's
|
||||
// skills survive the classifier into `subCategory`, which is the sole input the
|
||||
// session summary reads to build the "Skills & Agents" breakdown (#588).
|
||||
function turnFromPiCall(call: ParsedProviderCall, userMessage = ''): ParsedTurn {
|
||||
const apiCall: ParsedApiCall = {
|
||||
provider: call.provider,
|
||||
model: call.model,
|
||||
usage: {
|
||||
inputTokens: call.inputTokens,
|
||||
outputTokens: call.outputTokens,
|
||||
cacheCreationInputTokens: call.cacheCreationInputTokens,
|
||||
cacheReadInputTokens: call.cacheReadInputTokens,
|
||||
cachedInputTokens: call.cachedInputTokens,
|
||||
reasoningTokens: call.reasoningTokens,
|
||||
webSearchRequests: call.webSearchRequests,
|
||||
},
|
||||
costUSD: call.costUSD,
|
||||
tools: call.tools,
|
||||
mcpTools: call.tools.filter(t => t.startsWith('mcp__')),
|
||||
skills: call.skills ?? [],
|
||||
subagentTypes: call.subagentTypes ?? [],
|
||||
hasAgentSpawn: call.tools.includes('Agent'),
|
||||
hasPlanMode: call.tools.includes('EnterPlanMode'),
|
||||
speed: call.speed,
|
||||
timestamp: call.timestamp,
|
||||
bashCommands: call.bashCommands,
|
||||
deduplicationKey: call.deduplicationKey,
|
||||
}
|
||||
return { userMessage, assistantCalls: [apiCall], timestamp: call.timestamp, sessionId: call.sessionId }
|
||||
}
|
||||
|
||||
let tmpDir: string
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await mkdtemp(join(tmpdir(), 'pi-test-'))
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function sessionMeta(opts: { id?: string; cwd?: string } = {}) {
|
||||
return JSON.stringify({
|
||||
type: 'session',
|
||||
version: 3,
|
||||
id: opts.id ?? 'sess-001',
|
||||
timestamp: '2026-04-14T10:00:00.000Z',
|
||||
cwd: opts.cwd ?? '/Users/test/myproject',
|
||||
})
|
||||
}
|
||||
|
||||
function userMessage(text: string, timestamp?: string) {
|
||||
return JSON.stringify({
|
||||
type: 'message',
|
||||
id: 'msg-user-1',
|
||||
timestamp: timestamp ?? '2026-04-14T10:00:10.000Z',
|
||||
message: {
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text }],
|
||||
timestamp: 1776023210000,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function assistantMessage(opts: {
|
||||
id?: string
|
||||
responseId?: string
|
||||
timestamp?: string
|
||||
model?: string
|
||||
input?: number
|
||||
output?: number
|
||||
cacheRead?: number
|
||||
cacheWrite?: number
|
||||
tools?: Array<{ name: string; command?: string; path?: string; filePath?: string }>
|
||||
}) {
|
||||
const content = (opts.tools ?? []).map(t => {
|
||||
const args: Record<string, unknown> = {}
|
||||
if (t.command !== undefined) args['command'] = t.command
|
||||
if (t.path !== undefined) args['path'] = t.path
|
||||
if (t.filePath !== undefined) args['file_path'] = t.filePath
|
||||
return {
|
||||
type: 'toolCall',
|
||||
id: `call-${t.name}`,
|
||||
name: t.name,
|
||||
arguments: args,
|
||||
}
|
||||
})
|
||||
|
||||
return JSON.stringify({
|
||||
type: 'message',
|
||||
id: opts.id ?? 'msg-asst-1',
|
||||
timestamp: opts.timestamp ?? '2026-04-14T10:00:30.000Z',
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content,
|
||||
api: 'openai-codex-responses',
|
||||
provider: 'openai-codex',
|
||||
model: opts.model ?? 'gpt-5.4',
|
||||
responseId: opts.responseId ?? 'resp-001',
|
||||
usage: {
|
||||
input: opts.input ?? 1000,
|
||||
output: opts.output ?? 200,
|
||||
cacheRead: opts.cacheRead ?? 0,
|
||||
cacheWrite: opts.cacheWrite ?? 0,
|
||||
totalTokens: (opts.input ?? 1000) + (opts.output ?? 200) + (opts.cacheRead ?? 0),
|
||||
cost: { input: 0.0025, output: 0.003, cacheRead: 0, cacheWrite: 0, total: 0.0055 },
|
||||
},
|
||||
stopReason: 'stop',
|
||||
timestamp: 1776023230000,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function writeSession(projectDir: string, filename: string, lines: string[]) {
|
||||
await mkdir(projectDir, { recursive: true })
|
||||
const filePath = join(projectDir, filename)
|
||||
await writeFile(filePath, lines.join('\n') + '\n')
|
||||
return filePath
|
||||
}
|
||||
|
||||
describe('pi provider - session discovery', () => {
|
||||
it('discovers sessions grouped by project directory', async () => {
|
||||
const projectDir = join(tmpDir, '--Users-test-myproject--')
|
||||
await writeSession(projectDir, '2026-04-14T10-00-00-000Z_sess-001.jsonl', [
|
||||
sessionMeta({ cwd: '/Users/test/myproject' }),
|
||||
assistantMessage({}),
|
||||
])
|
||||
|
||||
const provider = createPiProvider(tmpDir)
|
||||
const sessions = await provider.discoverSessions()
|
||||
|
||||
expect(sessions).toHaveLength(1)
|
||||
expect(sessions[0]!.provider).toBe('pi')
|
||||
expect(sessions[0]!.project).toBe('myproject')
|
||||
expect(sessions[0]!.path).toContain('sess-001.jsonl')
|
||||
})
|
||||
|
||||
it('discovers sessions across multiple project directories', async () => {
|
||||
const dir1 = join(tmpDir, '--Users-test-project-a--')
|
||||
const dir2 = join(tmpDir, '--Users-test-project-b--')
|
||||
await writeSession(dir1, 'session1.jsonl', [sessionMeta({ cwd: '/Users/test/project-a' }), assistantMessage({})])
|
||||
await writeSession(dir2, 'session2.jsonl', [sessionMeta({ cwd: '/Users/test/project-b' }), assistantMessage({})])
|
||||
|
||||
const provider = createPiProvider(tmpDir)
|
||||
const sessions = await provider.discoverSessions()
|
||||
|
||||
expect(sessions).toHaveLength(2)
|
||||
const projects = sessions.map(s => s.project).sort()
|
||||
expect(projects).toEqual(['project-a', 'project-b'])
|
||||
})
|
||||
|
||||
it('returns empty for non-existent directory', async () => {
|
||||
const provider = createPiProvider('/nonexistent/path/that/does/not/exist')
|
||||
const sessions = await provider.discoverSessions()
|
||||
expect(sessions).toEqual([])
|
||||
})
|
||||
|
||||
it('skips files whose first line is not a session entry', async () => {
|
||||
const projectDir = join(tmpDir, '--Users-test-myproject--')
|
||||
await writeSession(projectDir, 'bad.jsonl', [
|
||||
JSON.stringify({ type: 'message', id: 'x' }),
|
||||
])
|
||||
|
||||
const provider = createPiProvider(tmpDir)
|
||||
const sessions = await provider.discoverSessions()
|
||||
expect(sessions).toEqual([])
|
||||
})
|
||||
|
||||
it('skips non-jsonl files', async () => {
|
||||
const projectDir = join(tmpDir, '--Users-test-myproject--')
|
||||
await mkdir(projectDir, { recursive: true })
|
||||
await writeFile(join(projectDir, 'notes.txt'), 'not a session')
|
||||
|
||||
const provider = createPiProvider(tmpDir)
|
||||
const sessions = await provider.discoverSessions()
|
||||
expect(sessions).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('pi provider - JSONL parsing', () => {
|
||||
it('extracts token usage and metadata from an assistant message', async () => {
|
||||
const projectDir = join(tmpDir, '--Users-test-myproject--')
|
||||
const filePath = await writeSession(projectDir, 'session.jsonl', [
|
||||
sessionMeta({ id: 'sess-abc', cwd: '/Users/test/myproject' }),
|
||||
userMessage('implement the feature'),
|
||||
assistantMessage({
|
||||
responseId: 'resp-abc',
|
||||
timestamp: '2026-04-14T10:00:30.000Z',
|
||||
model: 'gpt-5.4',
|
||||
input: 2000,
|
||||
output: 400,
|
||||
cacheRead: 5000,
|
||||
cacheWrite: 100,
|
||||
}),
|
||||
])
|
||||
|
||||
const provider = createPiProvider(tmpDir)
|
||||
const source = { path: filePath, project: 'myproject', provider: 'pi' }
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of provider.createSessionParser(source, new Set()).parse()) {
|
||||
calls.push(call)
|
||||
}
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
const call = calls[0]!
|
||||
expect(call.provider).toBe('pi')
|
||||
expect(call.model).toBe('gpt-5.4')
|
||||
expect(call.inputTokens).toBe(2000)
|
||||
expect(call.outputTokens).toBe(400)
|
||||
expect(call.cacheReadInputTokens).toBe(5000)
|
||||
expect(call.cachedInputTokens).toBe(5000)
|
||||
expect(call.cacheCreationInputTokens).toBe(100)
|
||||
expect(call.sessionId).toBe('sess-abc')
|
||||
expect(call.userMessage).toBe('implement the feature')
|
||||
expect(call.timestamp).toBe('2026-04-14T10:00:30.000Z')
|
||||
expect(call.costUSD).toBeGreaterThan(0)
|
||||
expect(call.deduplicationKey).toContain('pi:')
|
||||
expect(call.deduplicationKey).toContain('resp-abc')
|
||||
})
|
||||
|
||||
it('does not crash when a user message content is a string instead of an array (issue #441)', async () => {
|
||||
const projectDir = join(tmpDir, '--Users-test-myproject--')
|
||||
// Pi legitimately writes string `content` for some (e.g. injected) user turns.
|
||||
// Before the fix this threw `content.filter is not a function`, which aborted
|
||||
// the whole backfill and silently emptied the trend/history.
|
||||
const stringContentUser = JSON.stringify({
|
||||
type: 'message',
|
||||
id: 'msg-user-str',
|
||||
timestamp: '2026-04-14T10:00:10.000Z',
|
||||
message: { role: 'user', content: 'test message from file watcher', timestamp: 1776023210000 },
|
||||
})
|
||||
const filePath = await writeSession(projectDir, 'session.jsonl', [
|
||||
sessionMeta({ id: 'sess-str', cwd: '/Users/test/myproject' }),
|
||||
stringContentUser,
|
||||
assistantMessage({ responseId: 'resp-str', input: 500, output: 80 }),
|
||||
])
|
||||
|
||||
const provider = createPiProvider(tmpDir)
|
||||
const source = { path: filePath, project: 'myproject', provider: 'pi' }
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of provider.createSessionParser(source, new Set()).parse()) {
|
||||
calls.push(call)
|
||||
}
|
||||
|
||||
// The assistant turn is still parsed, and the string user content is paired.
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]!.inputTokens).toBe(500)
|
||||
expect(calls[0]!.userMessage).toBe('test message from file watcher')
|
||||
})
|
||||
|
||||
it('collects tool names from toolCall content items', async () => {
|
||||
const projectDir = join(tmpDir, '--Users-test-myproject--')
|
||||
const filePath = await writeSession(projectDir, 'session.jsonl', [
|
||||
sessionMeta(),
|
||||
assistantMessage({
|
||||
tools: [
|
||||
{ name: 'read' },
|
||||
{ name: 'edit' },
|
||||
{ name: 'bash', command: 'git status' },
|
||||
],
|
||||
}),
|
||||
])
|
||||
|
||||
const provider = createPiProvider(tmpDir)
|
||||
const source = { path: filePath, project: 'myproject', provider: 'pi' }
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of provider.createSessionParser(source, new Set()).parse()) {
|
||||
calls.push(call)
|
||||
}
|
||||
|
||||
expect(calls[0]!.tools).toEqual(['Read', 'Edit', 'Bash'])
|
||||
})
|
||||
|
||||
it('extracts bash commands from bash tool arguments', async () => {
|
||||
const projectDir = join(tmpDir, '--Users-test-myproject--')
|
||||
const filePath = await writeSession(projectDir, 'session.jsonl', [
|
||||
sessionMeta(),
|
||||
assistantMessage({
|
||||
tools: [
|
||||
{ name: 'bash', command: 'git status && bun test' },
|
||||
],
|
||||
}),
|
||||
])
|
||||
|
||||
const provider = createPiProvider(tmpDir)
|
||||
const source = { path: filePath, project: 'myproject', provider: 'pi' }
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of provider.createSessionParser(source, new Set()).parse()) {
|
||||
calls.push(call)
|
||||
}
|
||||
|
||||
expect(calls[0]!.bashCommands).toEqual(['git', 'bun'])
|
||||
})
|
||||
|
||||
it('classifies a SKILL.md read as a skill load, not a Read (#588)', async () => {
|
||||
const projectDir = join(tmpDir, '--Users-test-myproject--')
|
||||
const filePath = await writeSession(projectDir, 'session.jsonl', [
|
||||
sessionMeta(),
|
||||
assistantMessage({
|
||||
tools: [
|
||||
{ name: 'read', path: '/Volumes/T7/repos/cuneiform/.pi/skills/bmad-create-story/SKILL.md' },
|
||||
{ name: 'read', path: '/Volumes/T7/repos/cuneiform/workflow.md' },
|
||||
{ name: 'edit', path: '/Volumes/T7/repos/cuneiform/workflow.md' },
|
||||
],
|
||||
}),
|
||||
])
|
||||
|
||||
const provider = createPiProvider(tmpDir)
|
||||
const source = { path: filePath, project: 'myproject', provider: 'pi' }
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of provider.createSessionParser(source, new Set()).parse()) {
|
||||
calls.push(call)
|
||||
}
|
||||
|
||||
// The SKILL.md read is surfaced as the Skill tool (not Read); the plain
|
||||
// read stays a Read.
|
||||
expect(calls[0]!.skills).toEqual(['bmad-create-story'])
|
||||
expect(calls[0]!.tools).toEqual(['Skill', 'Read', 'Edit'])
|
||||
})
|
||||
|
||||
it('classifies a skill:// read as a skill load (OMP-style URI)', async () => {
|
||||
const projectDir = join(tmpDir, '--Users-test-myproject--')
|
||||
const filePath = await writeSession(projectDir, 'session.jsonl', [
|
||||
sessionMeta(),
|
||||
assistantMessage({
|
||||
tools: [{ name: 'read', path: 'skill://commit-workflow' }],
|
||||
}),
|
||||
])
|
||||
|
||||
const provider = createPiProvider(tmpDir)
|
||||
const source = { path: filePath, project: 'myproject', provider: 'pi' }
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of provider.createSessionParser(source, new Set()).parse()) {
|
||||
calls.push(call)
|
||||
}
|
||||
|
||||
expect(calls[0]!.skills).toEqual(['commit-workflow'])
|
||||
expect(calls[0]!.tools).toEqual(['Skill'])
|
||||
})
|
||||
|
||||
it('reads the file_path key as a fallback for skill loads', async () => {
|
||||
const projectDir = join(tmpDir, '--Users-test-myproject--')
|
||||
const filePath = await writeSession(projectDir, 'session.jsonl', [
|
||||
sessionMeta(),
|
||||
assistantMessage({
|
||||
tools: [{ name: 'read', filePath: '/home/u/.agents/skills/deep-research/SKILL.md' }],
|
||||
}),
|
||||
])
|
||||
|
||||
const provider = createPiProvider(tmpDir)
|
||||
const source = { path: filePath, project: 'myproject', provider: 'pi' }
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of provider.createSessionParser(source, new Set()).parse()) {
|
||||
calls.push(call)
|
||||
}
|
||||
|
||||
expect(calls[0]!.skills).toEqual(['deep-research'])
|
||||
expect(calls[0]!.tools).toEqual(['Skill'])
|
||||
})
|
||||
|
||||
it('leaves a normal read (no SKILL.md) as a Read with no skills', async () => {
|
||||
const projectDir = join(tmpDir, '--Users-test-myproject--')
|
||||
const filePath = await writeSession(projectDir, 'session.jsonl', [
|
||||
sessionMeta(),
|
||||
assistantMessage({
|
||||
tools: [{ name: 'read', path: '/home/u/project/src/skill-loader.ts' }],
|
||||
}),
|
||||
])
|
||||
|
||||
const provider = createPiProvider(tmpDir)
|
||||
const source = { path: filePath, project: 'myproject', provider: 'pi' }
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of provider.createSessionParser(source, new Set()).parse()) {
|
||||
calls.push(call)
|
||||
}
|
||||
|
||||
expect(calls[0]!.skills).toEqual([])
|
||||
expect(calls[0]!.tools).toEqual(['Read'])
|
||||
})
|
||||
|
||||
it('a pure skill-load turn classifies as general with the skill as subCategory (feeds the Skills breakdown, #588)', async () => {
|
||||
const projectDir = join(tmpDir, '--Users-test-myproject--')
|
||||
const filePath = await writeSession(projectDir, 'session.jsonl', [
|
||||
sessionMeta(),
|
||||
assistantMessage({
|
||||
tools: [{ name: 'read', path: '/home/u/.pi/agent/skills/systematic-debugging/SKILL.md' }],
|
||||
}),
|
||||
])
|
||||
|
||||
const provider = createPiProvider(tmpDir)
|
||||
const source = { path: filePath, project: 'myproject', provider: 'pi' }
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of provider.createSessionParser(source, new Set()).parse()) {
|
||||
calls.push(call)
|
||||
}
|
||||
|
||||
// End to end: the parsed skill load must reach `subCategory`, or the
|
||||
// Skills & Agents breakdown stays empty (the second half of #588).
|
||||
const classified = classifyTurn(turnFromPiCall(calls[0]!))
|
||||
expect(classified.category).toBe('general')
|
||||
expect(classified.subCategory).toBe('systematic-debugging')
|
||||
})
|
||||
|
||||
it('skips assistant messages with zero tokens', async () => {
|
||||
const projectDir = join(tmpDir, '--Users-test-myproject--')
|
||||
const filePath = await writeSession(projectDir, 'session.jsonl', [
|
||||
sessionMeta(),
|
||||
assistantMessage({ input: 0, output: 0 }),
|
||||
])
|
||||
|
||||
const provider = createPiProvider(tmpDir)
|
||||
const source = { path: filePath, project: 'myproject', provider: 'pi' }
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of provider.createSessionParser(source, new Set()).parse()) {
|
||||
calls.push(call)
|
||||
}
|
||||
|
||||
expect(calls).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('deduplicates calls seen across multiple parses', async () => {
|
||||
const projectDir = join(tmpDir, '--Users-test-myproject--')
|
||||
const filePath = await writeSession(projectDir, 'session.jsonl', [
|
||||
sessionMeta(),
|
||||
assistantMessage({ responseId: 'resp-dup' }),
|
||||
])
|
||||
|
||||
const provider = createPiProvider(tmpDir)
|
||||
const source = { path: filePath, project: 'myproject', provider: 'pi' }
|
||||
const seenKeys = new Set<string>()
|
||||
|
||||
const firstRun: ParsedProviderCall[] = []
|
||||
for await (const call of provider.createSessionParser(source, seenKeys).parse()) {
|
||||
firstRun.push(call)
|
||||
}
|
||||
|
||||
const secondRun: ParsedProviderCall[] = []
|
||||
for await (const call of provider.createSessionParser(source, seenKeys).parse()) {
|
||||
secondRun.push(call)
|
||||
}
|
||||
|
||||
expect(firstRun).toHaveLength(1)
|
||||
expect(secondRun).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('yields one call per assistant message in a multi-turn session', async () => {
|
||||
const projectDir = join(tmpDir, '--Users-test-myproject--')
|
||||
const filePath = await writeSession(projectDir, 'session.jsonl', [
|
||||
sessionMeta({ id: 'sess-multi' }),
|
||||
userMessage('first question'),
|
||||
assistantMessage({ responseId: 'resp-1', timestamp: '2026-04-14T10:00:30.000Z', input: 500, output: 100 }),
|
||||
userMessage('second question'),
|
||||
assistantMessage({ responseId: 'resp-2', timestamp: '2026-04-14T10:01:00.000Z', input: 600, output: 120 }),
|
||||
])
|
||||
|
||||
const provider = createPiProvider(tmpDir)
|
||||
const source = { path: filePath, project: 'myproject', provider: 'pi' }
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of provider.createSessionParser(source, new Set()).parse()) {
|
||||
calls.push(call)
|
||||
}
|
||||
|
||||
expect(calls).toHaveLength(2)
|
||||
expect(calls[0]!.userMessage).toBe('first question')
|
||||
expect(calls[0]!.inputTokens).toBe(500)
|
||||
expect(calls[1]!.userMessage).toBe('second question')
|
||||
expect(calls[1]!.inputTokens).toBe(600)
|
||||
})
|
||||
|
||||
it('handles missing session file gracefully', async () => {
|
||||
const provider = createPiProvider(tmpDir)
|
||||
const source = { path: '/nonexistent/session.jsonl', project: 'test', provider: 'pi' }
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of provider.createSessionParser(source, new Set()).parse()) {
|
||||
calls.push(call)
|
||||
}
|
||||
expect(calls).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('pi provider - display names', () => {
|
||||
const provider = createPiProvider('/tmp')
|
||||
|
||||
it('has correct name and displayName', () => {
|
||||
expect(provider.name).toBe('pi')
|
||||
expect(provider.displayName).toBe('Pi')
|
||||
})
|
||||
|
||||
it('maps known models to readable names', () => {
|
||||
expect(provider.modelDisplayName('gpt-5.4')).toBe('GPT-5.4')
|
||||
expect(provider.modelDisplayName('gpt-5.4-mini')).toBe('GPT-5.4 Mini')
|
||||
expect(provider.modelDisplayName('gpt-5')).toBe('GPT-5')
|
||||
})
|
||||
|
||||
it('returns raw name for unknown models', () => {
|
||||
expect(provider.modelDisplayName('some-future-model')).toBe('some-future-model')
|
||||
})
|
||||
|
||||
it('normalizes tool names to capitalized form', () => {
|
||||
expect(provider.toolDisplayName('bash')).toBe('Bash')
|
||||
expect(provider.toolDisplayName('read')).toBe('Read')
|
||||
expect(provider.toolDisplayName('unknown_tool')).toBe('unknown_tool')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,247 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||
import { mkdtemp, mkdir, writeFile, rm } from 'fs/promises'
|
||||
import { join } from 'path'
|
||||
import { tmpdir } from 'os'
|
||||
|
||||
import { rooCode, createRooCodeProvider } from '../../src/providers/roo-code.js'
|
||||
import type { ParsedProviderCall } from '../../src/providers/types.js'
|
||||
|
||||
let tmpDir: string
|
||||
|
||||
function makeUiMessages(opts: {
|
||||
tokensIn?: number
|
||||
tokensOut?: number
|
||||
cacheReads?: number
|
||||
cacheWrites?: number
|
||||
cost?: number
|
||||
userMessage?: string
|
||||
ts?: number
|
||||
}): string {
|
||||
const messages: unknown[] = []
|
||||
|
||||
if (opts.userMessage) {
|
||||
messages.push({ type: 'say', say: 'user_feedback', text: opts.userMessage, ts: 1700000000000 })
|
||||
}
|
||||
|
||||
const apiData: Record<string, unknown> = {
|
||||
tokensIn: opts.tokensIn ?? 100,
|
||||
tokensOut: opts.tokensOut ?? 50,
|
||||
cacheReads: opts.cacheReads ?? 0,
|
||||
cacheWrites: opts.cacheWrites ?? 0,
|
||||
}
|
||||
if (opts.cost !== undefined) apiData.cost = opts.cost
|
||||
|
||||
messages.push({
|
||||
type: 'say',
|
||||
say: 'api_req_started',
|
||||
text: JSON.stringify(apiData),
|
||||
ts: opts.ts ?? 1700000001000,
|
||||
})
|
||||
|
||||
return JSON.stringify(messages)
|
||||
}
|
||||
|
||||
function makeApiHistory(opts?: { model?: string }): string {
|
||||
const modelTag = opts?.model ? `<model>${opts.model}</model>` : ''
|
||||
const messages = [
|
||||
{ role: 'user', content: [{ type: 'text', text: `hello\n<environment_details>\n${modelTag}\n</environment_details>` }] },
|
||||
{ role: 'assistant', content: [{ type: 'text', text: 'response' }] },
|
||||
]
|
||||
return JSON.stringify(messages)
|
||||
}
|
||||
|
||||
describe('roo-code provider - parsing', () => {
|
||||
beforeEach(async () => {
|
||||
tmpDir = await mkdtemp(join(tmpdir(), 'roo-code-test-'))
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('parses tokens and cost from ui_messages.json', async () => {
|
||||
const taskDir = join(tmpDir, 'tasks', 'task-001')
|
||||
await mkdir(taskDir, { recursive: true })
|
||||
await writeFile(join(taskDir, 'ui_messages.json'), makeUiMessages({
|
||||
tokensIn: 200,
|
||||
tokensOut: 100,
|
||||
cacheReads: 50,
|
||||
cacheWrites: 30,
|
||||
cost: 0.05,
|
||||
userMessage: 'fix the bug',
|
||||
}))
|
||||
await writeFile(join(taskDir, 'api_conversation_history.json'), makeApiHistory())
|
||||
|
||||
const source = { path: taskDir, project: 'task-001', provider: 'roo-code' }
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of rooCode.createSessionParser(source, new Set()).parse()) calls.push(call)
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
const call = calls[0]!
|
||||
expect(call.provider).toBe('roo-code')
|
||||
expect(call.inputTokens).toBe(200)
|
||||
expect(call.outputTokens).toBe(100)
|
||||
expect(call.cacheReadInputTokens).toBe(50)
|
||||
expect(call.cacheCreationInputTokens).toBe(30)
|
||||
expect(call.costUSD).toBe(0.05)
|
||||
expect(call.userMessage).toBe('fix the bug')
|
||||
expect(call.sessionId).toBe('task-001')
|
||||
})
|
||||
|
||||
it('extracts model from api_conversation_history.json', async () => {
|
||||
const taskDir = join(tmpDir, 'tasks', 'task-002')
|
||||
await mkdir(taskDir, { recursive: true })
|
||||
await writeFile(join(taskDir, 'ui_messages.json'), makeUiMessages({ tokensIn: 100, tokensOut: 50 }))
|
||||
await writeFile(join(taskDir, 'api_conversation_history.json'), makeApiHistory({ model: 'claude-sonnet-4-5' }))
|
||||
|
||||
const source = { path: taskDir, project: 'task-002', provider: 'roo-code' }
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of rooCode.createSessionParser(source, new Set()).parse()) calls.push(call)
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]!.model).toBe('claude-sonnet-4-5')
|
||||
})
|
||||
|
||||
it('falls back to cline-auto when no model indicators', async () => {
|
||||
const taskDir = join(tmpDir, 'tasks', 'task-003')
|
||||
await mkdir(taskDir, { recursive: true })
|
||||
await writeFile(join(taskDir, 'ui_messages.json'), makeUiMessages({ tokensIn: 100, tokensOut: 50 }))
|
||||
await writeFile(join(taskDir, 'api_conversation_history.json'), JSON.stringify([
|
||||
{ role: 'user', content: [{ type: 'text', text: 'hello' }] },
|
||||
{ role: 'assistant', content: [{ type: 'text', text: 'hi' }] },
|
||||
]))
|
||||
|
||||
const source = { path: taskDir, project: 'task-003', provider: 'roo-code' }
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of rooCode.createSessionParser(source, new Set()).parse()) calls.push(call)
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]!.model).toBe('cline-auto')
|
||||
})
|
||||
|
||||
it('deduplicates across parser runs', async () => {
|
||||
const taskDir = join(tmpDir, 'tasks', 'task-004')
|
||||
await mkdir(taskDir, { recursive: true })
|
||||
await writeFile(join(taskDir, 'ui_messages.json'), makeUiMessages({ tokensIn: 100, tokensOut: 50 }))
|
||||
|
||||
const source = { path: taskDir, project: 'task-004', provider: 'roo-code' }
|
||||
const seenKeys = new Set<string>()
|
||||
|
||||
const calls1: ParsedProviderCall[] = []
|
||||
for await (const call of rooCode.createSessionParser(source, seenKeys).parse()) calls1.push(call)
|
||||
|
||||
const calls2: ParsedProviderCall[] = []
|
||||
for await (const call of rooCode.createSessionParser(source, seenKeys).parse()) calls2.push(call)
|
||||
|
||||
expect(calls1).toHaveLength(1)
|
||||
expect(calls2).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('handles missing ui_messages.json gracefully', async () => {
|
||||
const taskDir = join(tmpDir, 'tasks', 'task-005')
|
||||
await mkdir(taskDir, { recursive: true })
|
||||
|
||||
const source = { path: taskDir, project: 'task-005', provider: 'roo-code' }
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of rooCode.createSessionParser(source, new Set()).parse()) calls.push(call)
|
||||
|
||||
expect(calls).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('handles invalid JSON gracefully', async () => {
|
||||
const taskDir = join(tmpDir, 'tasks', 'task-006')
|
||||
await mkdir(taskDir, { recursive: true })
|
||||
await writeFile(join(taskDir, 'ui_messages.json'), 'not valid json')
|
||||
|
||||
const source = { path: taskDir, project: 'task-006', provider: 'roo-code' }
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of rooCode.createSessionParser(source, new Set()).parse()) calls.push(call)
|
||||
|
||||
expect(calls).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('skips entries with zero tokens', async () => {
|
||||
const taskDir = join(tmpDir, 'tasks', 'task-007')
|
||||
await mkdir(taskDir, { recursive: true })
|
||||
await writeFile(join(taskDir, 'ui_messages.json'), JSON.stringify([
|
||||
{ type: 'say', say: 'api_req_started', text: JSON.stringify({ tokensIn: 0, tokensOut: 0 }), ts: 1700000000000 },
|
||||
]))
|
||||
|
||||
const source = { path: taskDir, project: 'task-007', provider: 'roo-code' }
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of rooCode.createSessionParser(source, new Set()).parse()) calls.push(call)
|
||||
|
||||
expect(calls).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('calculates cost from model when cost field missing', async () => {
|
||||
const taskDir = join(tmpDir, 'tasks', 'task-008')
|
||||
await mkdir(taskDir, { recursive: true })
|
||||
await writeFile(join(taskDir, 'ui_messages.json'), makeUiMessages({ tokensIn: 1000, tokensOut: 500 }))
|
||||
await writeFile(join(taskDir, 'api_conversation_history.json'), makeApiHistory())
|
||||
|
||||
const source = { path: taskDir, project: 'task-008', provider: 'roo-code' }
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of rooCode.createSessionParser(source, new Set()).parse()) calls.push(call)
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]!.costUSD).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('roo-code provider - discovery', () => {
|
||||
beforeEach(async () => {
|
||||
tmpDir = await mkdtemp(join(tmpdir(), 'roo-code-test-'))
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('discovers task directories with ui_messages.json', async () => {
|
||||
const task1 = join(tmpDir, 'tasks', 'task-a')
|
||||
const task2 = join(tmpDir, 'tasks', 'task-b')
|
||||
await mkdir(task1, { recursive: true })
|
||||
await mkdir(task2, { recursive: true })
|
||||
await writeFile(join(task1, 'ui_messages.json'), '[]')
|
||||
await writeFile(join(task2, 'ui_messages.json'), '[]')
|
||||
|
||||
const provider = createRooCodeProvider(tmpDir)
|
||||
const sessions = await provider.discoverSessions()
|
||||
|
||||
expect(sessions).toHaveLength(2)
|
||||
expect(sessions.every(s => s.provider === 'roo-code')).toBe(true)
|
||||
})
|
||||
|
||||
it('skips tasks without ui_messages.json', async () => {
|
||||
const task = join(tmpDir, 'tasks', 'task-no-ui')
|
||||
await mkdir(task, { recursive: true })
|
||||
await writeFile(join(task, 'api_conversation_history.json'), '[]')
|
||||
|
||||
const provider = createRooCodeProvider(tmpDir)
|
||||
const sessions = await provider.discoverSessions()
|
||||
|
||||
expect(sessions).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('returns empty for nonexistent directory', async () => {
|
||||
const provider = createRooCodeProvider('/nonexistent/path')
|
||||
const sessions = await provider.discoverSessions()
|
||||
expect(sessions).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('roo-code provider - metadata', () => {
|
||||
it('has correct name and displayName', () => {
|
||||
expect(rooCode.name).toBe('roo-code')
|
||||
expect(rooCode.displayName).toBe('Roo Code')
|
||||
})
|
||||
|
||||
it('passes through model display names', () => {
|
||||
expect(rooCode.modelDisplayName('claude-sonnet-4-5')).toBe('claude-sonnet-4-5')
|
||||
})
|
||||
|
||||
it('passes through tool display names', () => {
|
||||
expect(rooCode.toolDisplayName('read_file')).toBe('read_file')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,117 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { mkdtemp, rm } from 'fs/promises'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import { fetchVercelGatewayReport, vercelGateway } from '../../src/providers/vercel-gateway.js'
|
||||
import { parseAllSessions, clearSessionCache } from '../../src/parser.js'
|
||||
|
||||
describe('vercel-gateway provider', () => {
|
||||
const originalFetch = globalThis.fetch
|
||||
const originalKey = process.env.AI_GATEWAY_API_KEY
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.AI_GATEWAY_API_KEY = 'test-key'
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch
|
||||
if (originalKey === undefined) delete process.env.AI_GATEWAY_API_KEY
|
||||
else process.env.AI_GATEWAY_API_KEY = originalKey
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('discovers a session when API key is set', async () => {
|
||||
const sessions = await vercelGateway.discoverSessions()
|
||||
expect(sessions).toHaveLength(1)
|
||||
expect(sessions[0]?.provider).toBe('vercel-gateway')
|
||||
})
|
||||
|
||||
it('returns empty discovery without API key', async () => {
|
||||
delete process.env.AI_GATEWAY_API_KEY
|
||||
delete process.env.VERCEL_OIDC_TOKEN
|
||||
const sessions = await vercelGateway.discoverSessions()
|
||||
expect(sessions).toEqual([])
|
||||
})
|
||||
|
||||
it('maps report rows to parsed calls', async () => {
|
||||
globalThis.fetch = vi.fn(async () => ({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
results: [{
|
||||
day: '2026-06-01',
|
||||
model: 'anthropic/claude-sonnet-4.6',
|
||||
total_cost: 1.25,
|
||||
input_tokens: 1000,
|
||||
output_tokens: 200,
|
||||
request_count: 3,
|
||||
}],
|
||||
}),
|
||||
})) as typeof fetch
|
||||
|
||||
const range = {
|
||||
start: new Date('2026-06-01T00:00:00.000Z'),
|
||||
end: new Date('2026-06-02T23:59:59.999Z'),
|
||||
}
|
||||
const rows = await fetchVercelGatewayReport(range)
|
||||
expect(rows).toHaveLength(1)
|
||||
|
||||
const source = { path: 'vercel-ai-gateway:report', project: 'Vercel AI Gateway', provider: 'vercel-gateway' }
|
||||
const seen = new Set<string>()
|
||||
const calls = []
|
||||
for await (const call of vercelGateway.createSessionParser(source, seen, range).parse()) {
|
||||
calls.push(call)
|
||||
}
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]?.costUSD).toBe(1.25)
|
||||
expect(calls[0]?.model).toBe('anthropic/claude-sonnet-4.6')
|
||||
})
|
||||
})
|
||||
|
||||
describe('vercel-gateway end-to-end (parseAllSessions network path)', () => {
|
||||
const originalFetch = globalThis.fetch
|
||||
const originalKey = process.env.AI_GATEWAY_API_KEY
|
||||
const originalCacheDir = process.env.CODEBURN_CACHE_DIR
|
||||
let cacheDir: string
|
||||
|
||||
beforeEach(async () => {
|
||||
cacheDir = await mkdtemp(join(tmpdir(), 'cb-vercel-cache-'))
|
||||
process.env.CODEBURN_CACHE_DIR = cacheDir
|
||||
process.env.AI_GATEWAY_API_KEY = 'test-key'
|
||||
clearSessionCache()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
globalThis.fetch = originalFetch
|
||||
if (originalKey === undefined) delete process.env.AI_GATEWAY_API_KEY
|
||||
else process.env.AI_GATEWAY_API_KEY = originalKey
|
||||
if (originalCacheDir === undefined) delete process.env.CODEBURN_CACHE_DIR
|
||||
else process.env.CODEBURN_CACHE_DIR = originalCacheDir
|
||||
clearSessionCache()
|
||||
vi.restoreAllMocks()
|
||||
await rm(cacheDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
// Regression: the synthetic source path `vercel-ai-gateway:report` has no file
|
||||
// on disk, so it was dropped by the fingerprintFile gate in parseProviderSources
|
||||
// and the provider always reported $0. Network providers must survive that gate
|
||||
// and contribute their fetched cost through the real aggregation pipeline.
|
||||
it('network source survives the fingerprint gate and contributes cost', async () => {
|
||||
globalThis.fetch = vi.fn(async () => ({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
results: [
|
||||
{ day: '2026-06-01', model: 'openai/gpt-4o', total_cost: 12.34, input_tokens: 1000, output_tokens: 500, request_count: 3 },
|
||||
],
|
||||
}),
|
||||
})) as typeof fetch
|
||||
|
||||
const range = {
|
||||
start: new Date('2026-05-01T00:00:00.000Z'),
|
||||
end: new Date('2026-06-09T23:59:59.999Z'),
|
||||
}
|
||||
const projects = await parseAllSessions(range, 'vercel-gateway')
|
||||
const total = projects.reduce((sum, p) => sum + p.totalCostUSD, 0)
|
||||
|
||||
expect(total).toBeCloseTo(12.34, 2)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||
import { mkdtemp, mkdir, writeFile, rm } from 'fs/promises'
|
||||
import { join, posix, win32 } from 'path'
|
||||
import { tmpdir } from 'os'
|
||||
|
||||
import { discoverClineTasks, getVSCodeGlobalStoragePaths } from '../../src/providers/vscode-cline-parser.js'
|
||||
|
||||
let tmpDir: string
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await mkdtemp(join(tmpdir(), 'vscode-cline-parser-test-'))
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
async function writeTask(baseDir: string, taskId: string): Promise<void> {
|
||||
const taskDir = join(baseDir, 'tasks', taskId)
|
||||
await mkdir(taskDir, { recursive: true })
|
||||
await writeFile(join(taskDir, 'ui_messages.json'), '[]')
|
||||
}
|
||||
|
||||
describe('VS Code Cline-family storage discovery', () => {
|
||||
it('includes VSCodium globalStorage paths on all supported platforms', () => {
|
||||
const extensionId = 'example.extension'
|
||||
|
||||
expect(getVSCodeGlobalStoragePaths(extensionId, '/Users/test', 'darwin')).toContain(
|
||||
posix.join('/Users/test', 'Library', 'Application Support', 'VSCodium', 'User', 'globalStorage', extensionId),
|
||||
)
|
||||
expect(getVSCodeGlobalStoragePaths(extensionId, 'C:\\Users\\test', 'win32')).toContain(
|
||||
win32.join('C:\\Users\\test', 'AppData', 'Roaming', 'VSCodium', 'User', 'globalStorage', extensionId),
|
||||
)
|
||||
expect(getVSCodeGlobalStoragePaths(extensionId, '/home/test', 'linux')).toContain(
|
||||
posix.join('/home/test', '.config', 'VSCodium', 'User', 'globalStorage', extensionId),
|
||||
)
|
||||
})
|
||||
|
||||
it('discovers tasks across multiple VS Code-compatible storage roots', async () => {
|
||||
const codeRoot = join(tmpDir, 'Code', 'User', 'globalStorage', 'example.extension')
|
||||
const codiumRoot = join(tmpDir, 'VSCodium', 'User', 'globalStorage', 'example.extension')
|
||||
await writeTask(codeRoot, 'task-code')
|
||||
await writeTask(codiumRoot, 'task-codium')
|
||||
|
||||
const sessions = await discoverClineTasks(
|
||||
'example.extension',
|
||||
'example-provider',
|
||||
'Example Provider',
|
||||
[codeRoot, codiumRoot],
|
||||
)
|
||||
|
||||
expect(sessions).toHaveLength(2)
|
||||
expect(sessions.map(s => s.path).sort()).toEqual([
|
||||
join(codeRoot, 'tasks', 'task-code'),
|
||||
join(codiumRoot, 'tasks', 'task-codium'),
|
||||
].sort())
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,358 @@
|
||||
import { mkdtemp, rm } from 'fs/promises'
|
||||
import { mkdirSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
import { tmpdir } from 'os'
|
||||
import { createRequire } from 'node:module'
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { createWarpProvider } from '../../src/providers/warp.js'
|
||||
import { isSqliteAvailable } from '../../src/sqlite.js'
|
||||
import type { ParsedProviderCall } from '../../src/providers/types.js'
|
||||
|
||||
const requireForTest = createRequire(import.meta.url)
|
||||
|
||||
type TestDb = {
|
||||
exec(sql: string): void
|
||||
prepare(sql: string): { run(...params: unknown[]): void }
|
||||
close(): void
|
||||
}
|
||||
|
||||
type QueryFixture = {
|
||||
exchangeId: string
|
||||
conversationId: string
|
||||
startTs: string
|
||||
input: string
|
||||
outputStatus?: string
|
||||
modelId?: string
|
||||
workingDirectory?: string | null
|
||||
}
|
||||
|
||||
type BlockFixture = {
|
||||
blockId: string
|
||||
conversationId: string
|
||||
startTs: string
|
||||
completedTs: string
|
||||
exitCode: number
|
||||
command: string
|
||||
}
|
||||
|
||||
let tmpDir: string
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await mkdtemp(join(tmpdir(), 'warp-provider-test-'))
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function createWarpDb(dir: string): string {
|
||||
mkdirSync(dir, { recursive: true })
|
||||
const dbPath = join(dir, 'warp.sqlite')
|
||||
const { DatabaseSync: Database } = requireForTest('node:sqlite')
|
||||
const db = new Database(dbPath)
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS agent_conversations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
conversation_id TEXT NOT NULL,
|
||||
conversation_data TEXT NOT NULL,
|
||||
last_modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`)
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS ai_queries (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
exchange_id TEXT NOT NULL,
|
||||
conversation_id TEXT NOT NULL,
|
||||
start_ts DATETIME NOT NULL,
|
||||
input TEXT NOT NULL,
|
||||
working_directory TEXT,
|
||||
output_status TEXT NOT NULL,
|
||||
model_id TEXT NOT NULL DEFAULT '',
|
||||
planning_model_id TEXT NOT NULL DEFAULT '',
|
||||
coding_model_id TEXT NOT NULL DEFAULT ''
|
||||
)
|
||||
`)
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS blocks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
pane_leaf_uuid BLOB NOT NULL,
|
||||
stylized_command BLOB NOT NULL,
|
||||
stylized_output BLOB NOT NULL,
|
||||
pwd TEXT,
|
||||
git_branch TEXT,
|
||||
virtual_env TEXT,
|
||||
conda_env TEXT,
|
||||
exit_code INTEGER NOT NULL,
|
||||
did_execute BOOLEAN NOT NULL,
|
||||
completed_ts DATETIME,
|
||||
start_ts DATETIME,
|
||||
ps1 TEXT,
|
||||
honor_ps1 BOOLEAN NOT NULL DEFAULT 0,
|
||||
shell TEXT,
|
||||
user TEXT,
|
||||
host TEXT,
|
||||
is_background BOOLEAN NOT NULL DEFAULT 0,
|
||||
rprompt TEXT,
|
||||
prompt_snapshot TEXT,
|
||||
block_id TEXT NOT NULL DEFAULT '',
|
||||
ai_metadata TEXT,
|
||||
is_local BOOLEAN,
|
||||
agent_view_visibility TEXT,
|
||||
git_branch_name 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()
|
||||
}
|
||||
}
|
||||
|
||||
function insertConversation(
|
||||
db: TestDb,
|
||||
conversationId: string,
|
||||
conversationData: unknown,
|
||||
lastModifiedAt = '2026-05-18 10:10:00',
|
||||
): void {
|
||||
db.prepare(
|
||||
'INSERT INTO agent_conversations (conversation_id, conversation_data, last_modified_at) VALUES (?, ?, ?)',
|
||||
).run(conversationId, JSON.stringify(conversationData), lastModifiedAt)
|
||||
}
|
||||
|
||||
function insertQuery(db: TestDb, q: QueryFixture): void {
|
||||
db.prepare(
|
||||
`INSERT INTO ai_queries (
|
||||
exchange_id, conversation_id, start_ts, input, working_directory, output_status, model_id, planning_model_id, coding_model_id
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, '', '')`,
|
||||
).run(
|
||||
q.exchangeId,
|
||||
q.conversationId,
|
||||
q.startTs,
|
||||
q.input,
|
||||
q.workingDirectory ?? null,
|
||||
q.outputStatus ?? '"Completed"',
|
||||
q.modelId ?? 'auto-efficient',
|
||||
)
|
||||
}
|
||||
|
||||
function insertBlock(db: TestDb, b: BlockFixture): void {
|
||||
db.prepare(
|
||||
`INSERT INTO blocks (
|
||||
pane_leaf_uuid, stylized_command, stylized_output, exit_code, did_execute,
|
||||
completed_ts, start_ts, block_id, ai_metadata
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
).run(
|
||||
Buffer.from([0]),
|
||||
b.command,
|
||||
'',
|
||||
b.exitCode,
|
||||
1,
|
||||
b.completedTs,
|
||||
b.startTs,
|
||||
b.blockId,
|
||||
JSON.stringify({
|
||||
requested_command_action_id: `call-${b.blockId}`,
|
||||
conversation_id: b.conversationId,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
async function collectCalls(
|
||||
dbPath: string,
|
||||
conversationId: string,
|
||||
seenKeys = new Set<string>(),
|
||||
): Promise<ParsedProviderCall[]> {
|
||||
const provider = createWarpProvider(dbPath)
|
||||
const source = {
|
||||
path: `${dbPath}:${conversationId}`,
|
||||
project: 'warp',
|
||||
provider: 'warp',
|
||||
}
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of provider.createSessionParser(source, seenKeys).parse()) {
|
||||
calls.push(call)
|
||||
}
|
||||
return calls
|
||||
}
|
||||
|
||||
const skipUnlessSqlite = isSqliteAvailable() ? describe : describe.skip
|
||||
|
||||
skipUnlessSqlite('warp provider', () => {
|
||||
it('discovers sessions and sanitizes project names from working_directory', async () => {
|
||||
const dbPath = createWarpDb(tmpDir)
|
||||
withTestDb(dbPath, (db) => {
|
||||
insertConversation(db, 'conv-1', { conversation_usage_metadata: { token_usage: [] } })
|
||||
insertQuery(db, {
|
||||
exchangeId: 'ex-1',
|
||||
conversationId: 'conv-1',
|
||||
startTs: '2026-05-18 10:00:00.000000',
|
||||
input: '[]',
|
||||
workingDirectory: '/Users/test/project-a',
|
||||
})
|
||||
})
|
||||
|
||||
const provider = createWarpProvider(dbPath)
|
||||
const sessions = await provider.discoverSessions()
|
||||
expect(sessions).toHaveLength(1)
|
||||
expect(sessions[0]!.provider).toBe('warp')
|
||||
expect(sessions[0]!.path).toBe(`${dbPath}:conv-1`)
|
||||
expect(sessions[0]!.project).toBe('Users-test-project-a')
|
||||
})
|
||||
|
||||
it('parses one call per completed exchange and estimates tokens from primary-agent totals', async () => {
|
||||
const dbPath = createWarpDb(tmpDir)
|
||||
withTestDb(dbPath, (db) => {
|
||||
insertConversation(db, 'conv-1', {
|
||||
conversation_usage_metadata: {
|
||||
token_usage: [
|
||||
{
|
||||
model_id: 'GPT-5.3 Codex (medium reasoning)',
|
||||
warp_tokens: 300,
|
||||
byok_tokens: 0,
|
||||
warp_token_usage_by_category: { primary_agent: 300 },
|
||||
byok_token_usage_by_category: {},
|
||||
},
|
||||
{
|
||||
model_id: 'Claude Haiku 4.5',
|
||||
warp_tokens: 90,
|
||||
byok_tokens: 0,
|
||||
warp_token_usage_by_category: { full_terminal_use: 90 },
|
||||
byok_token_usage_by_category: {},
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
insertQuery(db, {
|
||||
exchangeId: 'ex-1',
|
||||
conversationId: 'conv-1',
|
||||
startTs: '2026-05-18 10:00:00.000000',
|
||||
input: JSON.stringify([{ Query: { text: 'short prompt' } }]),
|
||||
modelId: 'auto-efficient',
|
||||
workingDirectory: '/Users/test/project-a',
|
||||
})
|
||||
insertQuery(db, {
|
||||
exchangeId: 'ex-2',
|
||||
conversationId: 'conv-1',
|
||||
startTs: '2026-05-18 10:03:00.000000',
|
||||
input: JSON.stringify([{ Query: { text: 'longer prompt with substantially more detail for weighting' } }]),
|
||||
modelId: 'auto-efficient',
|
||||
workingDirectory: '/Users/test/project-a',
|
||||
})
|
||||
})
|
||||
|
||||
const calls = await collectCalls(dbPath, 'conv-1')
|
||||
expect(calls).toHaveLength(2)
|
||||
expect(calls.map(call => call.deduplicationKey)).toEqual([
|
||||
'warp:conv-1:ex-1',
|
||||
'warp:conv-1:ex-2',
|
||||
])
|
||||
expect(calls.map(call => call.userMessage)).toEqual([
|
||||
'short prompt',
|
||||
'longer prompt with substantially more detail for weighting',
|
||||
])
|
||||
expect(calls.every(call => call.model === 'gpt-5.3-codex')).toBe(true)
|
||||
expect(calls.every(call => call.costIsEstimated === true)).toBe(true)
|
||||
expect(calls.reduce((sum, call) => sum + call.inputTokens, 0)).toBe(300)
|
||||
expect(calls[1]!.inputTokens).toBeGreaterThan(calls[0]!.inputTokens)
|
||||
expect(calls[0]!.projectPath).toBe('/Users/test/project-a')
|
||||
expect(calls[0]!.project).toBe('Users-test-project-a')
|
||||
})
|
||||
|
||||
it('attributes command blocks to the nearest preceding exchange and extracts Bash commands', async () => {
|
||||
const dbPath = createWarpDb(tmpDir)
|
||||
withTestDb(dbPath, (db) => {
|
||||
insertConversation(db, 'conv-2', {
|
||||
conversation_usage_metadata: {
|
||||
token_usage: [
|
||||
{
|
||||
model_id: 'GPT-5.3 Codex (medium reasoning)',
|
||||
warp_tokens: 120,
|
||||
byok_tokens: 0,
|
||||
warp_token_usage_by_category: { primary_agent: 120 },
|
||||
byok_token_usage_by_category: {},
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
insertQuery(db, {
|
||||
exchangeId: 'ex-a',
|
||||
conversationId: 'conv-2',
|
||||
startTs: '2026-05-18 11:00:00.000000',
|
||||
input: JSON.stringify([{ Query: { text: 'run tests' } }]),
|
||||
})
|
||||
insertQuery(db, {
|
||||
exchangeId: 'ex-b',
|
||||
conversationId: 'conv-2',
|
||||
startTs: '2026-05-18 11:05:00.000000',
|
||||
input: JSON.stringify([{ Query: { text: 'summarize results' } }]),
|
||||
})
|
||||
insertBlock(db, {
|
||||
blockId: 'block-1',
|
||||
conversationId: 'conv-2',
|
||||
startTs: '2026-05-18 11:01:00.000000',
|
||||
completedTs: '2026-05-18 11:01:04.000000',
|
||||
exitCode: 0,
|
||||
command: 'npm test && git status',
|
||||
})
|
||||
})
|
||||
|
||||
const calls = await collectCalls(dbPath, 'conv-2')
|
||||
expect(calls).toHaveLength(2)
|
||||
expect(calls[0]!.tools).toEqual(['Bash'])
|
||||
expect(calls[0]!.bashCommands).toEqual(['npm', 'git'])
|
||||
expect(calls[1]!.tools).toEqual([])
|
||||
expect(calls[1]!.bashCommands).toEqual([])
|
||||
})
|
||||
|
||||
it('skips pending or invalid exchanges and does not poison seenKeys for skipped rows', async () => {
|
||||
const dbPath = createWarpDb(tmpDir)
|
||||
withTestDb(dbPath, (db) => {
|
||||
insertConversation(db, 'conv-3', {
|
||||
conversation_usage_metadata: {
|
||||
token_usage: [
|
||||
{
|
||||
model_id: 'GPT-5.3 Codex (medium reasoning)',
|
||||
warp_tokens: 42,
|
||||
byok_tokens: 0,
|
||||
warp_token_usage_by_category: { primary_agent: 42 },
|
||||
byok_token_usage_by_category: {},
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
insertQuery(db, {
|
||||
exchangeId: 'ex-skip-seen',
|
||||
conversationId: 'conv-3',
|
||||
startTs: '2026-05-18 12:00:00.000000',
|
||||
input: JSON.stringify([{ Query: { text: 'already seen' } }]),
|
||||
})
|
||||
insertQuery(db, {
|
||||
exchangeId: 'ex-pending',
|
||||
conversationId: 'conv-3',
|
||||
startTs: '2026-05-18 12:01:00.000000',
|
||||
input: JSON.stringify([{ Query: { text: 'still running' } }]),
|
||||
outputStatus: '"Pending"',
|
||||
})
|
||||
insertQuery(db, {
|
||||
exchangeId: 'ex-invalid-ts',
|
||||
conversationId: 'conv-3',
|
||||
startTs: 'not-a-timestamp',
|
||||
input: JSON.stringify([{ Query: { text: 'bad timestamp' } }]),
|
||||
})
|
||||
})
|
||||
|
||||
const seen = new Set<string>(['warp:conv-3:ex-skip-seen'])
|
||||
const calls = await collectCalls(dbPath, 'conv-3', seen)
|
||||
expect(calls).toEqual([])
|
||||
expect(seen.has('warp:conv-3:ex-invalid-ts')).toBe(false)
|
||||
expect(seen.has('warp:conv-3:ex-pending')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,141 @@
|
||||
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 { isSqliteAvailable } from '../../src/sqlite.js'
|
||||
import { createZcodeProvider } from '../../src/providers/zcode.js'
|
||||
import type { ParsedProviderCall } from '../../src/providers/types.js'
|
||||
|
||||
const requireForTest = createRequire(import.meta.url)
|
||||
|
||||
let tmpRoot: string
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpRoot = await mkdtemp(join(tmpdir(), 'zcode-test-'))
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tmpRoot, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
// Minimal subset of the real ZCode schema (db v0.14.8) covering only the
|
||||
// columns the provider reads.
|
||||
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
|
||||
}
|
||||
|
||||
// Seeds one session with a single GLM-5.2 request whose 9125 input tokens
|
||||
// include 8064 cached, plus two tool calls in the same turn.
|
||||
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, 0, 0, 8064, 1781981181862, 1781981202412)
|
||||
db.prepare(
|
||||
'INSERT INTO tool_usage (id, session_id, turn_id, tool_name, started_at) VALUES (?, ?, ?, ?, ?)',
|
||||
).run('tu-1', 'sess-1', 'turn-1', 'Bash', 1781981299176)
|
||||
db.prepare(
|
||||
'INSERT INTO tool_usage (id, session_id, turn_id, tool_name, started_at) VALUES (?, ?, ?, ?, ?)',
|
||||
).run('tu-2', 'sess-1', 'turn-1', 'Read', 1781981315829)
|
||||
} finally {
|
||||
db.close()
|
||||
}
|
||||
}
|
||||
|
||||
async function collect(parser: { parse(): AsyncGenerator<ParsedProviderCall> }): Promise<ParsedProviderCall[]> {
|
||||
const out: ParsedProviderCall[] = []
|
||||
for await (const call of parser.parse()) out.push(call)
|
||||
return out
|
||||
}
|
||||
|
||||
describe('zcode provider', () => {
|
||||
it('discovers sessions that have usage', async () => {
|
||||
if (!isSqliteAvailable()) return
|
||||
const dbPath = createZcodeDb(tmpRoot)
|
||||
seed(dbPath)
|
||||
|
||||
const provider = createZcodeProvider(dbPath)
|
||||
const sessions = await provider.discoverSessions()
|
||||
|
||||
expect(sessions).toHaveLength(1)
|
||||
expect(sessions[0]?.provider).toBe('zcode')
|
||||
expect(sessions[0]?.project).toBe('Users-me-proj')
|
||||
})
|
||||
|
||||
it('splits cached tokens out of input and prices via the GLM-5.2 alias', async () => {
|
||||
if (!isSqliteAvailable()) return
|
||||
const dbPath = createZcodeDb(tmpRoot)
|
||||
seed(dbPath)
|
||||
|
||||
const provider = createZcodeProvider(dbPath)
|
||||
const [source] = await provider.discoverSessions()
|
||||
const calls = await collect(provider.createSessionParser(source!, new Set<string>()))
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
const call = calls[0]!
|
||||
expect(call.model).toBe('GLM-5.2')
|
||||
expect(call.inputTokens).toBe(1061) // 9125 - 8064 cached
|
||||
expect(call.cacheReadInputTokens).toBe(8064)
|
||||
expect(call.outputTokens).toBe(27)
|
||||
expect(call.tools).toEqual(['Bash', 'Read'])
|
||||
expect(call.costUSD).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('does not re-emit rows already in the seen set', async () => {
|
||||
if (!isSqliteAvailable()) return
|
||||
const dbPath = createZcodeDb(tmpRoot)
|
||||
seed(dbPath)
|
||||
|
||||
const provider = createZcodeProvider(dbPath)
|
||||
const [source] = await provider.discoverSessions()
|
||||
const seen = new Set<string>()
|
||||
|
||||
const first = await collect(provider.createSessionParser(source!, seen))
|
||||
const second = await collect(provider.createSessionParser(source!, seen))
|
||||
|
||||
expect(first).toHaveLength(1)
|
||||
expect(second).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,236 @@
|
||||
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 type { ParsedProviderCall } from '../../src/providers/types.js'
|
||||
|
||||
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-test-'))
|
||||
})
|
||||
|
||||
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,
|
||||
)
|
||||
}
|
||||
|
||||
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 provider (#480)', () => {
|
||||
it('emits one call per request with exact Anthropic-shaped token fields', async () => {
|
||||
const dbPath = 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 },
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
const calls = await collectCalls(dbPath)
|
||||
expect(calls.length).toBe(2)
|
||||
const first = calls.find(c => c.deduplicationKey === 'zed:thread-1:req-1')
|
||||
expect(first).toBeDefined()
|
||||
expect(first!.inputTokens).toBe(1200)
|
||||
expect(first!.outputTokens).toBe(300)
|
||||
expect(first!.cacheCreationInputTokens).toBe(5000)
|
||||
expect(first!.cacheReadInputTokens).toBe(90000)
|
||||
expect(first!.model).toBe('claude-opus-4-8')
|
||||
expect(first!.costUSD).toBeGreaterThan(0)
|
||||
expect(first!.sessionId).toBe('thread-1')
|
||||
expect(first!.userMessage).toBe('refactor the parser')
|
||||
expect(first!.timestamp).toBe('2026-06-21T09:30:00.000Z')
|
||||
// Cumulative must not be counted on top of per-request entries.
|
||||
expect(calls.reduce((s, c) => s + c.inputTokens, 0)).toBe(2000)
|
||||
})
|
||||
|
||||
it('falls back to cumulative_token_usage when the per-request map is empty', async () => {
|
||||
const dbPath = buildDb((db) => {
|
||||
insertThread(db, {
|
||||
id: 'thread-2',
|
||||
thread: {
|
||||
model: { provider: 'anthropic', model: 'claude-sonnet-4-6' },
|
||||
request_token_usage: {},
|
||||
cumulative_token_usage: { input_tokens: 5400, output_tokens: 900, cache_read_input_tokens: 20000 },
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
const calls = await collectCalls(dbPath)
|
||||
expect(calls.length).toBe(1)
|
||||
expect(calls[0]!.deduplicationKey).toBe('zed:thread-2:cumulative-remainder')
|
||||
expect(calls[0]!.inputTokens).toBe(5400)
|
||||
expect(calls[0]!.cacheReadInputTokens).toBe(20000)
|
||||
})
|
||||
|
||||
it('tops threads up to the cumulative counter when the per-request map undercounts', async () => {
|
||||
// Mirrors a real thread: the map (keyed by user message) covered only some
|
||||
// requests while cumulative carried ~3x the tokens.
|
||||
const dbPath = buildDb((db) => {
|
||||
insertThread(db, {
|
||||
id: 'thread-real',
|
||||
thread: {
|
||||
model: { provider: 'zed.dev', model: 'gpt-5.4' },
|
||||
request_token_usage: {
|
||||
'msg-1': { input_tokens: 9514, output_tokens: 19 },
|
||||
'msg-2': { input_tokens: 2757, output_tokens: 310, cache_read_input_tokens: 33408 },
|
||||
},
|
||||
cumulative_token_usage: { input_tokens: 35464, output_tokens: 1868, cache_read_input_tokens: 140288 },
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
const calls = await collectCalls(dbPath)
|
||||
expect(calls.length).toBe(3)
|
||||
const remainder = calls.find(c => c.deduplicationKey === 'zed:thread-real:cumulative-remainder')
|
||||
expect(remainder).toBeDefined()
|
||||
expect(calls.reduce((s, c) => s + c.inputTokens, 0)).toBe(35464)
|
||||
expect(calls.reduce((s, c) => s + c.outputTokens, 0)).toBe(1868)
|
||||
expect(calls.reduce((s, c) => s + c.cacheReadInputTokens, 0)).toBe(140288)
|
||||
})
|
||||
|
||||
it('skips non-zstd rows and malformed blobs without dropping healthy threads', async () => {
|
||||
const dbPath = buildDb((db) => {
|
||||
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: 'good',
|
||||
thread: {
|
||||
model: { model: 'claude-opus-4-8' },
|
||||
request_token_usage: { 'req-1': { input_tokens: 10, output_tokens: 5 } },
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
const calls = await collectCalls(dbPath)
|
||||
expect(calls.length).toBe(1)
|
||||
expect(calls[0]!.sessionId).toBe('good')
|
||||
})
|
||||
|
||||
it('reads legacy uncompressed json rows alongside zstd rows', async () => {
|
||||
const dbPath = buildDb((db) => {
|
||||
insertThread(db, {
|
||||
id: 'legacy',
|
||||
dataType: 'json',
|
||||
rawData: Buffer.from(JSON.stringify({
|
||||
model: { model: 'claude-sonnet-4-6' },
|
||||
request_token_usage: { 'req-1': { input_tokens: 40, output_tokens: 8 } },
|
||||
})),
|
||||
})
|
||||
})
|
||||
|
||||
const calls = await collectCalls(dbPath)
|
||||
expect(calls.length).toBe(1)
|
||||
expect(calls[0]!.inputTokens).toBe(40)
|
||||
expect(calls[0]!.model).toBe('claude-sonnet-4-6')
|
||||
})
|
||||
|
||||
it('dedupes across repeat parses via the shared seenKeys set', async () => {
|
||||
const dbPath = buildDb((db) => {
|
||||
insertThread(db, {
|
||||
id: 'thread-3',
|
||||
thread: {
|
||||
model: { model: 'claude-opus-4-8' },
|
||||
request_token_usage: { 'req-1': { input_tokens: 100, output_tokens: 50 } },
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
const seen = new Set<string>()
|
||||
expect((await collectCalls(dbPath, seen)).length).toBe(1)
|
||||
expect((await collectCalls(dbPath, seen)).length).toBe(0)
|
||||
})
|
||||
|
||||
it('skips threads whose usage is entirely zero instead of emitting empty calls', async () => {
|
||||
const dbPath = buildDb((db) => {
|
||||
insertThread(db, {
|
||||
id: 'thread-4',
|
||||
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 },
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
expect((await collectCalls(dbPath)).length).toBe(0)
|
||||
})
|
||||
|
||||
it('discovers nothing when the database does not exist', async () => {
|
||||
const provider = createZedProvider(join(tmpDir, 'missing.db'))
|
||||
expect(await provider.discoverSessions()).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,142 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||
import { mkdtemp, writeFile, rm } from 'fs/promises'
|
||||
import { join } from 'path'
|
||||
import { tmpdir } from 'os'
|
||||
|
||||
import { createZerostackProvider } from '../../src/providers/zerostack.js'
|
||||
import type { ParsedProviderCall } from '../../src/providers/types.js'
|
||||
|
||||
let tmpDir: string
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await mkdtemp(join(tmpdir(), 'zerostack-test-'))
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
// Mirrors the real on-disk format: one JSON file per session with cumulative
|
||||
// token totals (see src/session/mod.rs in zerostack).
|
||||
function session(opts: {
|
||||
id?: string
|
||||
model?: string
|
||||
provider?: string
|
||||
workingDir?: string
|
||||
input?: number
|
||||
output?: number
|
||||
updatedAt?: string
|
||||
} = {}) {
|
||||
return JSON.stringify({
|
||||
id: opts.id ?? 'sess-001',
|
||||
name: '',
|
||||
messages: [
|
||||
{ role: 'user', content: 'hello, what is this repo about?', estimated_tokens: 7 },
|
||||
{ role: 'assistant', content: 'It is a minimal coding agent in Rust.', estimated_tokens: 92 },
|
||||
],
|
||||
compactions: [],
|
||||
created_at: '2026-06-19T11:33:34.022836+00:00',
|
||||
updated_at: opts.updatedAt ?? '2026-06-19T11:34:14.140631+00:00',
|
||||
total_input_tokens: opts.input ?? 34119,
|
||||
total_output_tokens: opts.output ?? 961,
|
||||
total_cost: 0.015677835,
|
||||
total_estimated_tokens: 446,
|
||||
model: opts.model ?? 'deepseek/deepseek-v4-pro',
|
||||
provider: opts.provider ?? 'openrouter',
|
||||
working_dir: opts.workingDir ?? '/Users/test/myproject',
|
||||
permission_allowlist: [],
|
||||
})
|
||||
}
|
||||
|
||||
async function write(filename: string, content: string) {
|
||||
const path = join(tmpDir, filename)
|
||||
await writeFile(path, content)
|
||||
return path
|
||||
}
|
||||
|
||||
describe('zerostack provider - discovery', () => {
|
||||
it('discovers each session json and derives project from working_dir', async () => {
|
||||
await write('sess-001.json', session({ workingDir: '/Users/test/myproject' }))
|
||||
const sessions = await createZerostackProvider(tmpDir).discoverSessions()
|
||||
expect(sessions).toHaveLength(1)
|
||||
expect(sessions[0]!.provider).toBe('zerostack')
|
||||
expect(sessions[0]!.project).toBe('myproject')
|
||||
})
|
||||
|
||||
it('skips non-json files and unparseable files', async () => {
|
||||
await write('notes.txt', 'not a session')
|
||||
await write('broken.json', '{ not valid json')
|
||||
const sessions = await createZerostackProvider(tmpDir).discoverSessions()
|
||||
expect(sessions).toEqual([])
|
||||
})
|
||||
|
||||
it('returns empty for a non-existent directory', async () => {
|
||||
const sessions = await createZerostackProvider('/nope/does/not/exist').discoverSessions()
|
||||
expect(sessions).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('zerostack provider - parsing', () => {
|
||||
async function parse(path: string, seen = new Set<string>()) {
|
||||
const provider = createZerostackProvider(tmpDir)
|
||||
const source = { path, project: 'myproject', provider: 'zerostack' }
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of provider.createSessionParser(source, seen).parse()) {
|
||||
calls.push(call)
|
||||
}
|
||||
return calls
|
||||
}
|
||||
|
||||
it('emits one cumulative call per session with a resolved cost', async () => {
|
||||
const path = await write('sess-abc.json', session({ id: 'sess-abc' }))
|
||||
const calls = await parse(path)
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
const call = calls[0]!
|
||||
expect(call.model).toBe('deepseek/deepseek-v4-pro')
|
||||
expect(call.inputTokens).toBe(34119)
|
||||
expect(call.outputTokens).toBe(961)
|
||||
expect(call.sessionId).toBe('sess-abc')
|
||||
expect(call.userMessage).toBe('hello, what is this repo about?')
|
||||
expect(call.timestamp).toBe('2026-06-19T11:34:14.140631+00:00')
|
||||
expect(call.costUSD).toBeGreaterThan(0)
|
||||
expect(call.deduplicationKey).toContain('zerostack:')
|
||||
})
|
||||
|
||||
it('skips sessions with zero tokens', async () => {
|
||||
const path = await write('empty.json', session({ input: 0, output: 0 }))
|
||||
expect(await parse(path)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('deduplicates across repeated parses', async () => {
|
||||
const path = await write('dup.json', session())
|
||||
const seen = new Set<string>()
|
||||
expect(await parse(path, seen)).toHaveLength(1)
|
||||
expect(await parse(path, seen)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('prices unknown local models at zero without throwing', async () => {
|
||||
const path = await write('local.json', session({ model: 'my-local-model', provider: 'ollama' }))
|
||||
const calls = await parse(path)
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]!.costUSD).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('zerostack provider - display names', () => {
|
||||
const provider = createZerostackProvider('/tmp')
|
||||
|
||||
it('has correct name and displayName', () => {
|
||||
expect(provider.name).toBe('zerostack')
|
||||
expect(provider.displayName).toBe('Zerostack')
|
||||
})
|
||||
|
||||
it('strips the openrouter route prefix from model ids', () => {
|
||||
expect(provider.modelDisplayName('deepseek/deepseek-v4-pro')).toBe('DeepSeek v4 Pro')
|
||||
})
|
||||
|
||||
it('normalizes tool names', () => {
|
||||
expect(provider.toolDisplayName('bash')).toBe('Bash')
|
||||
expect(provider.toolDisplayName('unknown_tool')).toBe('unknown_tool')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user