Add OpenClaw, Roo Code, and KiloCode providers (#175)

- OpenClaw: JSONL parser with multi-path discovery, tool extraction
  (toolCall + tool_use block types), model tracking via model_change
  and custom model-snapshot events
- Roo Code + KiloCode: shared Cline-family parser extracts model from
  <model> tags in api_conversation_history.json, strips provider
  prefixes from model names
- Add cline-auto and openclaw-auto aliases and display names
- Add menubar provider filters and tab colors for all three
- Show cached data instantly instead of blocking on CLI refresh
This commit is contained in:
Resham Joshi 2026-04-28 09:24:14 -07:00 committed by GitHub
parent ce78ac52c1
commit ec2de6a642
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 1034 additions and 7 deletions

View file

@ -65,13 +65,13 @@ final class AppStore {
/// Switch to a period. Always fetches fresh data so the user never sees stale numbers.
func switchTo(period: Period) async {
selectedPeriod = period
await refresh(includeOptimize: true)
await refresh(includeOptimize: true, force: true)
}
/// Switch to a provider filter. Always fetches fresh data so the user never sees stale numbers.
func switchTo(provider: ProviderFilter) async {
selectedProvider = provider
await refresh(includeOptimize: true)
await refresh(includeOptimize: true, force: true)
}
private var inFlightKeys: Set<PayloadCacheKey> = []
@ -79,11 +79,15 @@ final class AppStore {
/// Refresh the currently selected (period, provider) combination. Guards against concurrent
/// fetches for the same key so a slow initial request can't overwrite a newer one that
/// finished first (which would show stale numbers the user has already moved past).
func refresh(includeOptimize: Bool) async {
/// When `force` is false (background timer), skips the CLI call if the cache is still fresh.
func refresh(includeOptimize: Bool, force: Bool = false) async {
let key = currentKey
if !force, cache[key]?.isFresh == true { return }
guard !inFlightKeys.contains(key) else { return }
inFlightKeys.insert(key)
isLoading = true
if cache[key] == nil {
isLoading = true
}
defer {
inFlightKeys.remove(key)
isLoading = false
@ -228,15 +232,21 @@ enum ProviderFilter: String, CaseIterable, Identifiable {
case copilot = "Copilot"
case gemini = "Gemini"
case kiro = "Kiro"
case kiloCode = "KiloCode"
case openclaw = "OpenClaw"
case opencode = "OpenCode"
case pi = "Pi"
case omp = "OMP"
case rooCode = "Roo Code"
var id: String { rawValue }
var providerKeys: [String] {
switch self {
case .cursor: ["cursor", "cursor agent"]
case .rooCode: ["roo-code"]
case .kiloCode: ["kilo-code"]
case .openclaw: ["openclaw"]
default: [rawValue.lowercased()]
}
}
@ -249,10 +259,13 @@ enum ProviderFilter: String, CaseIterable, Identifiable {
case .cursor: "cursor"
case .copilot: "copilot"
case .gemini: "gemini"
case .kiloCode: "kilo-code"
case .kiro: "kiro"
case .openclaw: "openclaw"
case .opencode: "opencode"
case .pi: "pi"
case .omp: "omp"
case .rooCode: "roo-code"
}
}
}

View file

@ -93,10 +93,13 @@ extension ProviderFilter {
case .cursor: return Theme.categoricalCursor
case .copilot: return Color(red: 0x6D/255.0, green: 0x8F/255.0, blue: 0xA6/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)
case .openclaw: return Color(red: 0xDA/255.0, green: 0x70/255.0, blue: 0x56/255.0)
case .opencode: return Color(red: 0x5B/255.0, green: 0x83/255.0, blue: 0x5B/255.0)
case .pi: return Color(red: 0xB2/255.0, green: 0x6B/255.0, blue: 0x3D/255.0)
case .omp: return Color(red: 0x8B/255.0, green: 0x5C/255.0, blue: 0xB0/255.0)
case .rooCode: return Color(red: 0x4C/255.0, green: 0xAF/255.0, blue: 0x50/255.0)
}
}
}

View file

@ -397,7 +397,7 @@ struct FooterBar: View {
.fixedSize()
Button {
Task { await store.refresh(includeOptimize: true) }
Task { await store.refresh(includeOptimize: true, force: true) }
} label: {
Image(systemName: store.isLoading ? "arrow.triangle.2.circlepath" : "arrow.clockwise")
.font(.system(size: 11, weight: .medium))

View file

@ -143,6 +143,8 @@ const BUILTIN_ALIASES: Record<string, string> = {
'cursor-agent-auto': 'claude-sonnet-4-5',
'copilot-auto': 'claude-sonnet-4-5',
'kiro-auto': 'claude-sonnet-4-5',
'cline-auto': 'claude-sonnet-4-5',
'openclaw-auto': 'claude-sonnet-4-5',
// Cursor emits dot-version tier-last names
'claude-4.6-sonnet': 'claude-sonnet-4-6',
'claude-4.5-sonnet-thinking': 'claude-sonnet-4-5',
@ -222,6 +224,8 @@ const autoModelNames: Record<string, string> = {
'cursor-agent-auto': 'Cursor (auto)',
'copilot-auto': 'Copilot (auto)',
'kiro-auto': 'Kiro (auto)',
'cline-auto': 'Cline (auto)',
'openclaw-auto': 'OpenClaw (auto)',
}
export function getShortModelName(model: string): string {

View file

@ -2,8 +2,11 @@ import { claude } from './claude.js'
import { codex } from './codex.js'
import { copilot } from './copilot.js'
import { gemini } from './gemini.js'
import { kiloCode } from './kilo-code.js'
import { kiro } from './kiro.js'
import { openclaw } from './openclaw.js'
import { pi, omp } from './pi.js'
import { rooCode } from './roo-code.js'
import type { Provider, SessionSource } from './types.js'
let cursorProvider: Provider | null = null
@ -51,7 +54,7 @@ async function loadCursorAgent(): Promise<Provider | null> {
}
}
const coreProviders: Provider[] = [claude, codex, copilot, gemini, kiro, pi, omp]
const coreProviders: Provider[] = [claude, codex, copilot, gemini, kiloCode, kiro, openclaw, pi, omp, rooCode]
export async function getAllProviders(): Promise<Provider[]> {
const [cursor, opencode, cursorAgent] = await Promise.all([loadCursor(), loadOpenCode(), loadCursorAgent()])

View file

@ -0,0 +1,29 @@
import { discoverClineTasks, createClineParser } from './vscode-cline-parser.js'
import type { Provider, SessionSource, SessionParser } from './types.js'
const EXTENSION_ID = 'kilocode.kilo-code'
export function createKiloCodeProvider(overrideDir?: string): Provider {
return {
name: 'kilo-code',
displayName: 'KiloCode',
modelDisplayName(model: string): string {
return model
},
toolDisplayName(rawTool: string): string {
return rawTool
},
async discoverSessions(): Promise<SessionSource[]> {
return discoverClineTasks(EXTENSION_ID, 'kilo-code', 'KiloCode', overrideDir)
},
createSessionParser(source: SessionSource, seenKeys: Set<string>): SessionParser {
return createClineParser(source, seenKeys, 'kilo-code')
},
}
}
export const kiloCode = createKiloCodeProvider()

282
src/providers/openclaw.ts Normal file
View file

@ -0,0 +1,282 @@
import { readdir, readFile } from 'fs/promises'
import { basename, join } from 'path'
import { homedir } from 'os'
import { readSessionFile } from '../fs-utils.js'
import { calculateCost } from '../models.js'
import type { Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js'
const toolNameMap: Record<string, string> = {
bash: 'Bash',
exec: 'Bash',
read: 'Read',
edit: 'Edit',
write: 'Write',
glob: 'Glob',
grep: 'Grep',
task: 'Agent',
dispatch_agent: 'Agent',
fetch: 'WebFetch',
search: 'WebSearch',
todo: 'TodoWrite',
patch: 'Patch',
}
type OpenClawUsage = {
input: number
output: number
cacheRead: number
cacheWrite: number
totalTokens?: number
cost?: {
total?: number
}
}
type OpenClawEntry = {
type: string
customType?: string
id?: string
timestamp?: string
provider?: string
modelId?: string
data?: {
provider?: string
modelId?: string
}
message?: {
role?: string
content?: Array<{ type?: string; text?: string; name?: string; arguments?: Record<string, unknown> }>
model?: string
provider?: string
usage?: OpenClawUsage
}
}
type SessionIndex = Record<string, {
sessionId: string
sessionFile?: string
}>
function getOpenClawDirs(): string[] {
const home = homedir()
return [
join(home, '.openclaw', 'agents'),
join(home, '.clawdbot', 'agents'),
join(home, '.moltbot', 'agents'),
join(home, '.moldbot', 'agents'),
]
}
function extractTools(content: Array<{ type?: string; name?: string; arguments?: Record<string, unknown> }> | undefined): { tools: string[]; bashCommands: string[] } {
const tools: string[] = []
const bashCommands: string[] = []
if (!content) return { tools, bashCommands }
for (const block of content) {
if ((block.type === 'tool_use' || block.type === 'toolCall') && block.name) {
const mapped = toolNameMap[block.name] ?? block.name
tools.push(mapped)
if (mapped === 'Bash' && block.arguments && typeof block.arguments.command === 'string') {
const cmd = block.arguments.command.split(/\s+/)[0] ?? ''
if (cmd) bashCommands.push(cmd)
}
}
}
return { tools, bashCommands }
}
function createParser(source: SessionSource, seenKeys: Set<string>): SessionParser {
return {
async *parse(): AsyncGenerator<ParsedProviderCall> {
const raw = await readSessionFile(source.path)
if (raw === null) return
const lines = raw.split('\n').filter(l => l.trim())
let sessionId = ''
let sessionTimestamp = ''
let currentModel = ''
const calls: {
model: string
usage: OpenClawUsage
tools: string[]
bashCommands: string[]
timestamp: string
userMessage: string
dedupId: string
}[] = []
let pendingUserMessage = ''
for (const line of lines) {
let entry: OpenClawEntry
try {
entry = JSON.parse(line)
} catch {
continue
}
if (entry.type === 'session') {
sessionId = entry.id ?? basename(source.path, '.jsonl')
sessionTimestamp = entry.timestamp ?? ''
continue
}
if (entry.type === 'model_change') {
currentModel = entry.modelId ?? currentModel
continue
}
if (entry.type === 'custom' && entry.customType === 'model-snapshot') {
currentModel = entry.data?.modelId ?? currentModel
continue
}
if (entry.type !== 'message' || !entry.message) continue
const msg = entry.message
if (msg.role === 'user') {
if (!pendingUserMessage && Array.isArray(msg.content)) {
const textBlock = msg.content.find(c => c.type === 'text' && c.text)
pendingUserMessage = (textBlock?.text ?? '').slice(0, 500)
}
continue
}
if (msg.role !== 'assistant') continue
const model = msg.model ?? currentModel
if (msg.usage) {
const { tools, bashCommands } = extractTools(msg.content)
calls.push({
model,
usage: msg.usage,
tools,
bashCommands,
timestamp: entry.timestamp ?? sessionTimestamp,
userMessage: pendingUserMessage,
dedupId: entry.id ?? '',
})
pendingUserMessage = ''
}
}
if (!sessionId) sessionId = basename(source.path, '.jsonl')
for (let i = 0; i < calls.length; i++) {
const call = calls[i]
const dedupKey = `openclaw:${sessionId}:${call.dedupId || i}`
if (seenKeys.has(dedupKey)) continue
seenKeys.add(dedupKey)
const u = call.usage
const costFromProvider = u.cost?.total ?? 0
const costUSD = costFromProvider > 0
? costFromProvider
: calculateCost(call.model, u.input, u.output, u.cacheWrite, u.cacheRead, 0)
const ts = new Date(call.timestamp)
if (isNaN(ts.getTime()) || ts.getTime() < 1_000_000_000_000) continue
yield {
provider: 'openclaw',
model: call.model || 'openclaw-auto',
inputTokens: u.input,
outputTokens: u.output,
cacheCreationInputTokens: u.cacheWrite,
cacheReadInputTokens: u.cacheRead,
cachedInputTokens: u.cacheRead,
reasoningTokens: 0,
webSearchRequests: 0,
costUSD,
tools: [...new Set(call.tools)],
bashCommands: [...new Set(call.bashCommands)],
timestamp: ts.toISOString(),
speed: 'standard',
deduplicationKey: dedupKey,
userMessage: call.userMessage,
sessionId,
}
}
},
}
}
async function discoverInDir(agentsDir: string): Promise<SessionSource[]> {
const sources: SessionSource[] = []
let agentDirs: string[]
try {
const entries = await readdir(agentsDir, { withFileTypes: true })
agentDirs = entries.filter(e => e.isDirectory()).map(e => e.name)
} catch {
return sources
}
for (const agent of agentDirs) {
const sessionsDir = join(agentsDir, agent, 'sessions')
let indexData: SessionIndex = {}
try {
const indexRaw = await readFile(join(sessionsDir, 'sessions.json'), 'utf-8')
indexData = JSON.parse(indexRaw)
} catch { /* no index, fall back to directory scan */ }
const seenFiles = new Set<string>()
for (const entry of Object.values(indexData)) {
if (entry.sessionFile) {
seenFiles.add(entry.sessionFile)
sources.push({ path: entry.sessionFile, project: agent, provider: 'openclaw' })
} else if (entry.sessionId) {
const filePath = join(sessionsDir, `${entry.sessionId}.jsonl`)
seenFiles.add(filePath)
sources.push({ path: filePath, project: agent, provider: 'openclaw' })
}
}
try {
const files = await readdir(sessionsDir)
for (const f of files) {
if (!f.endsWith('.jsonl')) continue
const filePath = join(sessionsDir, f)
if (seenFiles.has(filePath)) continue
sources.push({ path: filePath, project: agent, provider: 'openclaw' })
}
} catch { /* directory may not exist */ }
}
return sources
}
export function createOpenClawProvider(overrideDir?: string): Provider {
return {
name: 'openclaw',
displayName: 'OpenClaw',
modelDisplayName(model: string): string {
return model
},
toolDisplayName(rawTool: string): string {
return toolNameMap[rawTool] ?? rawTool
},
async discoverSessions(): Promise<SessionSource[]> {
if (overrideDir) return discoverInDir(overrideDir)
const all: SessionSource[] = []
for (const dir of getOpenClawDirs()) {
const sessions = await discoverInDir(dir)
all.push(...sessions)
}
return all
},
createSessionParser(source: SessionSource, seenKeys: Set<string>): SessionParser {
return createParser(source, seenKeys)
},
}
}
export const openclaw = createOpenClawProvider()

29
src/providers/roo-code.ts Normal file
View file

@ -0,0 +1,29 @@
import { discoverClineTasks, createClineParser } from './vscode-cline-parser.js'
import type { Provider, SessionSource, SessionParser } from './types.js'
const EXTENSION_ID = 'rooveterinaryinc.roo-cline'
export function createRooCodeProvider(overrideDir?: string): Provider {
return {
name: 'roo-code',
displayName: 'Roo Code',
modelDisplayName(model: string): string {
return model
},
toolDisplayName(rawTool: string): string {
return rawTool
},
async discoverSessions(): Promise<SessionSource[]> {
return discoverClineTasks(EXTENSION_ID, 'roo-code', 'Roo Code', overrideDir)
},
createSessionParser(source: SessionSource, seenKeys: Set<string>): SessionParser {
return createClineParser(source, seenKeys, 'roo-code')
},
}
}
export const rooCode = createRooCodeProvider()

View file

@ -0,0 +1,163 @@
import { readdir, readFile, stat } from 'fs/promises'
import { basename, join } from 'path'
import { homedir } from 'os'
import { calculateCost } from '../models.js'
import type { SessionSource, SessionParser, ParsedProviderCall } from './types.js'
type UiMessage = {
type?: string
say?: string
text?: string
ts?: number
}
export function getVSCodeGlobalStoragePath(extensionId: string): string {
if (process.platform === 'darwin') {
return join(homedir(), 'Library', 'Application Support', 'Code', 'User', 'globalStorage', extensionId)
}
if (process.platform === 'win32') {
return join(homedir(), 'AppData', 'Roaming', 'Code', 'User', 'globalStorage', extensionId)
}
return join(homedir(), '.config', 'Code', 'User', 'globalStorage', extensionId)
}
export async function discoverClineTasks(extensionId: string, providerName: string, displayName: string, overrideDir?: string): Promise<SessionSource[]> {
const baseDir = overrideDir ?? getVSCodeGlobalStoragePath(extensionId)
const tasksDir = join(baseDir, 'tasks')
const sources: SessionSource[] = []
let taskDirs: string[]
try {
taskDirs = await readdir(tasksDir)
} catch {
return sources
}
for (const taskId of taskDirs) {
const taskDir = join(tasksDir, taskId)
const dirStat = await stat(taskDir).catch(() => null)
if (!dirStat?.isDirectory()) continue
const uiPath = join(taskDir, 'ui_messages.json')
const uiStat = await stat(uiPath).catch(() => null)
if (!uiStat?.isFile()) continue
sources.push({ path: taskDir, project: displayName, provider: providerName })
}
return sources
}
const MODEL_TAG_RE = /<model>([^<]+)<\/model>/
function extractModelFromHistory(taskDir: string): Promise<string> {
return readFile(join(taskDir, 'api_conversation_history.json'), 'utf-8')
.then(raw => {
const msgs = JSON.parse(raw) as Array<{ role?: string; content?: Array<{ text?: string }> }>
if (!Array.isArray(msgs)) return 'cline-auto'
for (const msg of msgs) {
if (msg.role !== 'user' || !Array.isArray(msg.content)) continue
for (const block of msg.content) {
const match = typeof block.text === 'string' && MODEL_TAG_RE.exec(block.text)
if (match) {
const raw = match[1]
return raw.includes('/') ? raw.split('/').pop()! : raw
}
}
}
return 'cline-auto'
})
.catch(() => 'cline-auto')
}
export function createClineParser(source: SessionSource, seenKeys: Set<string>, providerName: string): SessionParser {
return {
async *parse(): AsyncGenerator<ParsedProviderCall> {
const taskDir = source.path
const taskId = basename(taskDir)
let uiRaw: string
try {
uiRaw = await readFile(join(taskDir, 'ui_messages.json'), 'utf-8')
} catch {
return
}
let uiMessages: UiMessage[]
try {
uiMessages = JSON.parse(uiRaw)
} catch {
return
}
if (!Array.isArray(uiMessages)) return
const model = await extractModelFromHistory(taskDir)
let userMessage = ''
for (const msg of uiMessages) {
if (msg.type === 'say' && (msg.say === 'user_feedback' || msg.say === 'text')) {
userMessage = (msg.text ?? '').slice(0, 500)
break
}
}
const apiReqEntries = uiMessages.filter(m => m.type === 'say' && m.say === 'api_req_started')
for (const [index, entry] of apiReqEntries.entries()) {
const dedupKey = `${providerName}:${taskId}:${index}`
if (seenKeys.has(dedupKey)) continue
seenKeys.add(dedupKey)
let tokensIn = 0
let tokensOut = 0
let cacheReads = 0
let cacheWrites = 0
let cost: number | undefined
if (entry.text) {
try {
const parsed = JSON.parse(entry.text) as {
tokensIn?: number
tokensOut?: number
cacheReads?: number
cacheWrites?: number
cost?: number
}
tokensIn = parsed.tokensIn ?? 0
tokensOut = parsed.tokensOut ?? 0
cacheReads = parsed.cacheReads ?? 0
cacheWrites = parsed.cacheWrites ?? 0
cost = parsed.cost
} catch {}
}
if (tokensIn === 0 && tokensOut === 0) continue
const timestamp = entry.ts ? new Date(entry.ts).toISOString() : ''
const costUSD = cost ?? calculateCost(model, tokensIn, tokensOut, cacheWrites, cacheReads, 0)
yield {
provider: providerName,
model,
inputTokens: tokensIn,
outputTokens: tokensOut,
cacheCreationInputTokens: cacheWrites,
cacheReadInputTokens: cacheReads,
cachedInputTokens: cacheReads,
reasoningTokens: 0,
webSearchRequests: 0,
costUSD,
tools: [],
bashCommands: [],
timestamp,
speed: 'standard',
deduplicationKey: dedupKey,
userMessage: index === 0 ? userMessage : '',
sessionId: taskId,
}
}
},
}
}

View file

@ -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', 'kiro', 'pi', 'omp'])
expect(providers.map(p => p.name)).toEqual(['claude', 'codex', 'copilot', 'gemini', 'kilo-code', 'kiro', 'openclaw', 'pi', 'omp', 'roo-code'])
})
it('includes sqlite providers after async load', async () => {

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()
expect(sessions).toHaveLength(1)
expect(sessions[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', async () => {
const kiloProvider = createKiloCodeProvider('/tmp/kilo-test')
const sessions = await kiloProvider.discoverSessions()
expect(sessions).toHaveLength(0)
})
})

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

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