mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-05-17 03:56:45 +00:00
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
This commit is contained in:
parent
607cb463c9
commit
26ebe75aa1
6 changed files with 555 additions and 2 deletions
|
|
@ -230,6 +230,7 @@ enum ProviderFilter: String, CaseIterable, Identifiable {
|
|||
case codex = "Codex"
|
||||
case cursor = "Cursor"
|
||||
case copilot = "Copilot"
|
||||
case droid = "Droid"
|
||||
case gemini = "Gemini"
|
||||
case kiro = "Kiro"
|
||||
case kiloCode = "KiloCode"
|
||||
|
|
@ -259,6 +260,7 @@ enum ProviderFilter: String, CaseIterable, Identifiable {
|
|||
case .codex: "codex"
|
||||
case .cursor: "cursor"
|
||||
case .copilot: "copilot"
|
||||
case .droid: "droid"
|
||||
case .gemini: "gemini"
|
||||
case .kiloCode: "kilo-code"
|
||||
case .kiro: "kiro"
|
||||
|
|
|
|||
|
|
@ -92,6 +92,7 @@ extension ProviderFilter {
|
|||
case .codex: return Theme.categoricalCodex
|
||||
case .cursor: return Theme.categoricalCursor
|
||||
case .copilot: return Color(red: 0x6D/255.0, green: 0x8F/255.0, blue: 0xA6/255.0)
|
||||
case .droid: return Color(red: 0x7C/255.0, green: 0x3A/255.0, blue: 0xED/255.0)
|
||||
case .gemini: return Color(red: 0x44/255.0, green: 0x85/255.0, blue: 0xF4/255.0)
|
||||
case .kiloCode: return Color(red: 0x00/255.0, green: 0x96/255.0, blue: 0x88/255.0)
|
||||
case .kiro: return Color(red: 0x4A/255.0, green: 0x9E/255.0, blue: 0xC4/255.0)
|
||||
|
|
|
|||
401
src/providers/droid.ts
Normal file
401
src/providers/droid.ts
Normal file
|
|
@ -0,0 +1,401 @@
|
|||
import { readdir, stat, readFile } from 'fs/promises'
|
||||
import { join } from 'path'
|
||||
import { homedir } from 'os'
|
||||
|
||||
import { readSessionFile, readSessionLines } from '../fs-utils.js'
|
||||
import { calculateCost, getShortModelName } from '../models.js'
|
||||
import { extractBashCommands } from '../bash-utils.js'
|
||||
import type {
|
||||
Provider,
|
||||
SessionSource,
|
||||
SessionParser,
|
||||
ParsedProviderCall,
|
||||
} from './types.js'
|
||||
|
||||
const toolNameMap: Record<string, string> = {
|
||||
Read: 'Read',
|
||||
Create: 'Create',
|
||||
Edit: 'Edit',
|
||||
MultiEdit: 'MultiEdit',
|
||||
LS: 'LS',
|
||||
Glob: 'Glob',
|
||||
Grep: 'Grep',
|
||||
Execute: 'Bash',
|
||||
AskUser: 'AskUser',
|
||||
TodoWrite: 'TodoWrite',
|
||||
Skill: 'Skill',
|
||||
Task: 'Agent',
|
||||
WebSearch: 'WebSearch',
|
||||
FetchUrl: 'FetchUrl',
|
||||
GenerateDroid: 'GenerateDroid',
|
||||
ExitSpecMode: 'ExitSpecMode',
|
||||
}
|
||||
|
||||
type DroidSettings = {
|
||||
model?: string
|
||||
tokenUsage?: {
|
||||
inputTokens: number
|
||||
outputTokens: number
|
||||
cacheCreationTokens: number
|
||||
cacheReadTokens: number
|
||||
thinkingTokens: number
|
||||
}
|
||||
}
|
||||
|
||||
type DroidContent = {
|
||||
type: string
|
||||
text?: string
|
||||
name?: string
|
||||
input?: Record<string, unknown>
|
||||
}
|
||||
|
||||
type DroidMessage = {
|
||||
role: string
|
||||
content?: DroidContent[]
|
||||
}
|
||||
|
||||
type DroidJsonlEntry = {
|
||||
type: string
|
||||
id?: string
|
||||
timestamp?: string
|
||||
message?: DroidMessage
|
||||
title?: string
|
||||
cwd?: string
|
||||
}
|
||||
|
||||
function getFactoryDir(): string {
|
||||
return process.env['FACTORY_DIR'] ?? join(homedir(), '.factory')
|
||||
}
|
||||
|
||||
|
||||
// Strip Droid-specific wrapper to get the model's display name.
|
||||
// e.g. "custom:GLM-5.1-[Proxy]-0" -> "GLM-5.1"
|
||||
// Cost lookup is handled by codeburn's existing calculateCost/getCanonicalName
|
||||
// which normalizes case and strips date suffixes automatically.
|
||||
function stripModelPrefix(raw: string): string {
|
||||
return raw
|
||||
.replace(/^custom:/, '')
|
||||
.replace(/\[.*?\]/g, '')
|
||||
.replace(/-\d+$/, '')
|
||||
.replace(/-+$/, '')
|
||||
.replace(/^-/, '')
|
||||
}
|
||||
|
||||
function parseModelForDisplay(raw: string): string {
|
||||
const stripped = stripModelPrefix(raw)
|
||||
const lower = stripped.toLowerCase()
|
||||
|
||||
if (lower.includes('opus')) return getShortModelName(stripped)
|
||||
if (lower.includes('sonnet')) return getShortModelName(stripped)
|
||||
if (lower.includes('haiku')) return getShortModelName(stripped)
|
||||
if (lower.startsWith('gpt-')) return getShortModelName(stripped)
|
||||
if (lower.startsWith('o3') || lower.startsWith('o4')) return getShortModelName(stripped)
|
||||
if (lower.startsWith('gemini')) return getShortModelName(stripped)
|
||||
|
||||
return stripped
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract meaningful shell command names from a Droid Execute call.
|
||||
* Droid frequently passes multi-line scripts (python -c "...", heredocs, etc.)
|
||||
* where splitting on ;/&&/| produces noise tokens like '}', 'await', 'import'.
|
||||
* Instead, extract only the primary command from each logical line.
|
||||
*/
|
||||
function extractDroidBashCommands(command: string): string[] {
|
||||
if (!command || !command.trim()) return []
|
||||
|
||||
const firstLine = command.split('\n')[0]!.trim()
|
||||
return extractBashCommands(firstLine)
|
||||
}
|
||||
|
||||
function createParser(
|
||||
source: SessionSource,
|
||||
seenKeys: Set<string>,
|
||||
): SessionParser {
|
||||
return {
|
||||
async *parse(): AsyncGenerator<ParsedProviderCall> {
|
||||
const content = await readSessionFile(source.path)
|
||||
if (content === null) return
|
||||
|
||||
// Read the companion settings file for token usage
|
||||
const settingsPath = source.path.replace(/\.jsonl$/, '.settings.json')
|
||||
let settings: DroidSettings = {}
|
||||
try {
|
||||
const raw = await readFile(settingsPath, 'utf-8')
|
||||
settings = JSON.parse(raw) as DroidSettings
|
||||
} catch {
|
||||
// No settings file or parse error
|
||||
}
|
||||
|
||||
const lines = content.split('\n').filter(l => l.trim())
|
||||
let sessionId = ''
|
||||
let sessionModelDisplay = settings.model ? stripModelPrefix(settings.model) : 'unknown'
|
||||
let currentUserMessage = ''
|
||||
|
||||
// Collect all assistant messages with their tools
|
||||
const assistantCalls: Array<{
|
||||
id: string
|
||||
timestamp: string
|
||||
tools: string[]
|
||||
bashCommands: string[]
|
||||
}> = []
|
||||
|
||||
let pendingTools: string[] = []
|
||||
let pendingBashCommands: string[] = []
|
||||
|
||||
for (const line of lines) {
|
||||
let entry: DroidJsonlEntry
|
||||
try {
|
||||
entry = JSON.parse(line) as DroidJsonlEntry
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
|
||||
if (entry.type === 'session_start') {
|
||||
sessionId = entry.id ?? ''
|
||||
continue
|
||||
}
|
||||
|
||||
if (entry.type !== 'message' || !entry.message) continue
|
||||
|
||||
const msg = entry.message
|
||||
|
||||
if (msg.role === 'user') {
|
||||
// Extract user text from content
|
||||
const texts = (msg.content ?? [])
|
||||
.filter(c => c.type === 'text' && c.text)
|
||||
.map(c => c.text!)
|
||||
.filter(Boolean)
|
||||
// Skip system-reminder-only messages
|
||||
const nonSystemTexts = texts.filter(t => !t.startsWith('<system-reminder>'))
|
||||
if (nonSystemTexts.length > 0) {
|
||||
currentUserMessage = nonSystemTexts.join(' ').slice(0, 500)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (msg.role === 'assistant') {
|
||||
const toolUses = (msg.content ?? []).filter(c => c.type === 'tool_use')
|
||||
|
||||
for (const tu of toolUses) {
|
||||
const toolName = tu.name ?? ''
|
||||
pendingTools.push(toolNameMap[toolName] ?? toolName)
|
||||
|
||||
if (toolName === 'Execute' && tu.input && typeof tu.input['command'] === 'string') {
|
||||
pendingBashCommands.push(...extractDroidBashCommands(tu.input['command'] as string))
|
||||
}
|
||||
}
|
||||
|
||||
// Check if this assistant message has any text content (non-thinking)
|
||||
const hasText = (msg.content ?? []).some(c => c.type === 'text' && c.text)
|
||||
|
||||
// Only emit a call entry if there are tools or substantial text
|
||||
if (pendingTools.length > 0 || hasText) {
|
||||
assistantCalls.push({
|
||||
id: entry.id ?? `msg-${assistantCalls.length}`,
|
||||
timestamp: entry.timestamp ?? '',
|
||||
tools: [...pendingTools],
|
||||
bashCommands: [...pendingBashCommands],
|
||||
})
|
||||
pendingTools = []
|
||||
pendingBashCommands = []
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if (assistantCalls.length === 0) return
|
||||
|
||||
// Distribute session-level token usage across calls
|
||||
const totalTokens = settings.tokenUsage
|
||||
if (!totalTokens) return
|
||||
|
||||
const totalInput = totalTokens.inputTokens ?? 0
|
||||
const totalOutput = totalTokens.outputTokens ?? 0
|
||||
const totalCacheCreation = totalTokens.cacheCreationTokens ?? 0
|
||||
const totalCacheRead = totalTokens.cacheReadTokens ?? 0
|
||||
const totalThinking = totalTokens.thinkingTokens ?? 0
|
||||
const numCalls = assistantCalls.length
|
||||
|
||||
// Distribute evenly across calls
|
||||
const inputPerCall = Math.floor(totalInput / numCalls)
|
||||
const outputPerCall = Math.floor(totalOutput / numCalls)
|
||||
const cacheCreationPerCall = Math.floor(totalCacheCreation / numCalls)
|
||||
const cacheReadPerCall = Math.floor(totalCacheRead / numCalls)
|
||||
const thinkingPerCall = Math.floor(totalThinking / numCalls)
|
||||
|
||||
for (let i = 0; i < assistantCalls.length; i++) {
|
||||
const call = assistantCalls[i]
|
||||
|
||||
// Assign remainder to the last call
|
||||
const isLast = i === assistantCalls.length - 1
|
||||
const inputTokens = isLast
|
||||
? totalInput - inputPerCall * (numCalls - 1)
|
||||
: inputPerCall
|
||||
const outputTokens = isLast
|
||||
? totalOutput - outputPerCall * (numCalls - 1)
|
||||
: outputPerCall
|
||||
const cacheCreationTokens = isLast
|
||||
? totalCacheCreation - cacheCreationPerCall * (numCalls - 1)
|
||||
: cacheCreationPerCall
|
||||
const cacheReadTokens = isLast
|
||||
? totalCacheRead - cacheReadPerCall * (numCalls - 1)
|
||||
: cacheReadPerCall
|
||||
const thinkingTokens = isLast
|
||||
? totalThinking - thinkingPerCall * (numCalls - 1)
|
||||
: thinkingPerCall
|
||||
|
||||
const dedupKey = `droid:${sessionId}:${call.id}`
|
||||
if (seenKeys.has(dedupKey)) continue
|
||||
seenKeys.add(dedupKey)
|
||||
|
||||
const costUSD = calculateCost(
|
||||
sessionModelDisplay.toLowerCase(),
|
||||
inputTokens,
|
||||
outputTokens + thinkingTokens,
|
||||
cacheCreationTokens,
|
||||
cacheReadTokens,
|
||||
0,
|
||||
)
|
||||
|
||||
// Use the call's timestamp, or session_start timestamp
|
||||
const timestamp = call.timestamp || ''
|
||||
|
||||
yield {
|
||||
provider: 'droid',
|
||||
model: sessionModelDisplay,
|
||||
inputTokens,
|
||||
outputTokens,
|
||||
cacheCreationInputTokens: cacheCreationTokens,
|
||||
cacheReadInputTokens: cacheReadTokens,
|
||||
cachedInputTokens: cacheReadTokens,
|
||||
reasoningTokens: thinkingTokens,
|
||||
webSearchRequests: 0,
|
||||
costUSD,
|
||||
tools: call.tools,
|
||||
bashCommands: call.bashCommands,
|
||||
timestamp,
|
||||
speed: 'standard',
|
||||
deduplicationKey: dedupKey,
|
||||
userMessage: i === 0 ? currentUserMessage : '',
|
||||
sessionId,
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function isInternalSession(cwd: string, factoryDir: string): boolean {
|
||||
// Skip sessions whose cwd is the .factory directory itself (internal housekeeping)
|
||||
const normalized = cwd.replace(/\/+$/, '')
|
||||
return normalized === factoryDir
|
||||
}
|
||||
|
||||
function deriveProjectName(cwd: string): string {
|
||||
const normalized = cwd.replace(/\/+$/, '')
|
||||
const home = homedir()
|
||||
|
||||
// Strip home directory prefix
|
||||
let relative = normalized.startsWith(home)
|
||||
? normalized.slice(home.length).replace(/^\/+/, '')
|
||||
: normalized.replace(/^\/+/, '')
|
||||
|
||||
if (!relative) relative = '~'
|
||||
|
||||
// Walk from the right: use the "projects/<name>" segment if present,
|
||||
// otherwise the last meaningful path component.
|
||||
const parts = relative.split('/')
|
||||
const projectsIdx = parts.lastIndexOf('projects')
|
||||
if (projectsIdx !== -1 && projectsIdx + 1 < parts.length) {
|
||||
return parts.slice(projectsIdx + 1).join('/')
|
||||
}
|
||||
|
||||
return parts.join('/')
|
||||
}
|
||||
|
||||
async function readFirstJsonlLine(filePath: string): Promise<string | null> {
|
||||
for await (const line of readSessionLines(filePath)) {
|
||||
return line
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async function discoverSessionsInDir(
|
||||
sessionsDir: string,
|
||||
factoryDir: string,
|
||||
): Promise<SessionSource[]> {
|
||||
const sources: SessionSource[] = []
|
||||
|
||||
let entries: string[]
|
||||
try {
|
||||
entries = await readdir(sessionsDir)
|
||||
} catch {
|
||||
return sources
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
const subDir = join(sessionsDir, entry)
|
||||
const s = await stat(subDir).catch(() => null)
|
||||
if (!s?.isDirectory()) continue
|
||||
|
||||
const files = await readdir(subDir).catch(() => [] as string[])
|
||||
for (const file of files) {
|
||||
if (!file.endsWith('.jsonl')) continue
|
||||
const filePath = join(subDir, file)
|
||||
|
||||
const firstLine = await readFirstJsonlLine(filePath)
|
||||
if (!firstLine?.trim()) continue
|
||||
|
||||
let startEntry: DroidJsonlEntry
|
||||
try {
|
||||
startEntry = JSON.parse(firstLine) as DroidJsonlEntry
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
|
||||
if (startEntry.type !== 'session_start') continue
|
||||
|
||||
const cwd = startEntry.cwd ?? entry
|
||||
if (isInternalSession(cwd, factoryDir)) continue
|
||||
|
||||
sources.push({
|
||||
path: filePath,
|
||||
project: deriveProjectName(cwd),
|
||||
provider: 'droid',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return sources
|
||||
}
|
||||
|
||||
export function createDroidProvider(factoryDir?: string): Provider {
|
||||
const base = factoryDir ?? getFactoryDir()
|
||||
const sessionsDir = join(base, 'sessions')
|
||||
|
||||
return {
|
||||
name: 'droid',
|
||||
displayName: 'Droid',
|
||||
|
||||
modelDisplayName(model: string): string {
|
||||
return parseModelForDisplay(model)
|
||||
},
|
||||
|
||||
toolDisplayName(rawTool: string): string {
|
||||
return toolNameMap[rawTool] ?? rawTool
|
||||
},
|
||||
|
||||
async discoverSessions(): Promise<SessionSource[]> {
|
||||
return discoverSessionsInDir(sessionsDir, base)
|
||||
},
|
||||
|
||||
createSessionParser(
|
||||
source: SessionSource,
|
||||
seenKeys: Set<string>,
|
||||
): SessionParser {
|
||||
return createParser(source, seenKeys)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export const droid = createDroidProvider()
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { claude } from './claude.js'
|
||||
import { codex } from './codex.js'
|
||||
import { copilot } from './copilot.js'
|
||||
import { droid } from './droid.js'
|
||||
import { gemini } from './gemini.js'
|
||||
import { kiloCode } from './kilo-code.js'
|
||||
import { kiro } from './kiro.js'
|
||||
|
|
@ -55,7 +56,7 @@ async function loadCursorAgent(): Promise<Provider | null> {
|
|||
}
|
||||
}
|
||||
|
||||
const coreProviders: Provider[] = [claude, codex, copilot, gemini, kiloCode, kiro, openclaw, pi, omp, qwen, rooCode]
|
||||
const coreProviders: Provider[] = [claude, codex, copilot, droid, gemini, kiloCode, kiro, openclaw, pi, omp, qwen, rooCode]
|
||||
|
||||
export async function getAllProviders(): Promise<Provider[]> {
|
||||
const [cursor, opencode, cursorAgent] = await Promise.all([loadCursor(), loadOpenCode(), loadCursorAgent()])
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { providers, getAllProviders } from '../src/providers/index.js'
|
|||
|
||||
describe('provider registry', () => {
|
||||
it('has core providers registered synchronously', () => {
|
||||
expect(providers.map(p => p.name)).toEqual(['claude', 'codex', 'copilot', 'gemini', 'kilo-code', 'kiro', 'openclaw', 'pi', 'omp', 'qwen', 'roo-code'])
|
||||
expect(providers.map(p => p.name)).toEqual(['claude', 'codex', 'copilot', 'droid', 'gemini', 'kilo-code', 'kiro', 'openclaw', 'pi', 'omp', 'qwen', 'roo-code'])
|
||||
})
|
||||
|
||||
it('includes sqlite providers after async load', async () => {
|
||||
|
|
|
|||
148
tests/providers/droid.test.ts
Normal file
148
tests/providers/droid.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue