mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-05-16 19:44:14 +00:00
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:
parent
ce78ac52c1
commit
ec2de6a642
13 changed files with 1034 additions and 7 deletions
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()])
|
||||
|
|
|
|||
29
src/providers/kilo-code.ts
Normal file
29
src/providers/kilo-code.ts
Normal 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
282
src/providers/openclaw.ts
Normal 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
29
src/providers/roo-code.ts
Normal 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()
|
||||
163
src/providers/vscode-cline-parser.ts
Normal file
163
src/providers/vscode-cline-parser.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -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 () => {
|
||||
|
|
|
|||
62
tests/providers/kilo-code.test.ts
Normal file
62
tests/providers/kilo-code.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
192
tests/providers/openclaw.test.ts
Normal file
192
tests/providers/openclaw.test.ts
Normal 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 })
|
||||
})
|
||||
})
|
||||
247
tests/providers/roo-code.test.ts
Normal file
247
tests/providers/roo-code.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue