chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:26:02 +08:00
commit a0a48b5e45
437 changed files with 100767 additions and 0 deletions
+608
View File
@@ -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 })
}
})
})
+377
View File
@@ -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)
})
})
+139
View File
@@ -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')
})
})
+480
View 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')
})
})
+593
View File
@@ -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
+317
View File
@@ -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')
})
})
+329
View File
@@ -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')
})
})
+176
View File
@@ -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)
})
})
+129
View File
@@ -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)
})
})
+412
View File
@@ -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']))
})
})
+167
View File
@@ -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')
},
)
})
+816
View File
@@ -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([])
})
})
+148
View File
@@ -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)
})
})
+275
View File
@@ -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')
})
})
+193
View File
@@ -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)
})
})
+187
View File
@@ -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')
})
})
+546
View File
@@ -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')
})
})
+164
View File
@@ -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')
})
})
+62
View File
@@ -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')
})
})
+192
View File
@@ -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',
})
})
})
+948
View File
@@ -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)
})
})
+207
View File
@@ -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')
})
})
+314
View File
@@ -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')
})
})
+404
View File
@@ -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')
})
})
+225
View File
@@ -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)
})
})
+155
View File
@@ -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')
})
})
+192
View File
@@ -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 })
})
})
+243
View File
@@ -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'])
})
})
+901
View File
@@ -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([])
})
})
+514
View File
@@ -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')
})
})
+247
View File
@@ -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')
})
})
+117
View 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())
})
})
+358
View File
@@ -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)
})
})
+141
View File
@@ -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)
})
})
+236
View File
@@ -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([])
})
})
+142
View File
@@ -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')
})
})