codeburn/tests/providers/droid.test.ts
Dunccan de Weerdt 26ebe75aa1 Add Droid CLI provider
Discovers and parses sessions from ~/.factory/sessions/, reading JSONL
message logs and companion settings.json files for token usage tracking.

- Discovers sessions by scanning per-cwd subdirectories
- Skips internal .factory housekeeping sessions
- Extracts tools, bash commands, and user messages from JSONL
- Distributes session-level cumulative token counts across calls
- Normalizes Droid model wrappers before existing pricing lookup
- Derives clean project names from cwd paths
- Adds menubar provider filtering for Droid
2026-04-28 20:16:45 +02:00

148 lines
6.6 KiB
TypeScript

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