mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-08-12 10:04:49 +00:00
merge main into feat/optimize
- resolve dashboard.tsx conflicts: keep optimize view + context budget column alongside main's all-time period and TopSessions panel - ProjectBreakdown: add avg/s column from main plus overhead column from optimize, widths 30/40 - StatusBar: 1-5 periods including all-time, plus o-optimize when findings exist - DashboardContent: all-time period handling and TopSessions panel preserved Copilot provider and its 253 tests from main merged cleanly as additions.
This commit is contained in:
commit
21444df2bf
8 changed files with 661 additions and 12 deletions
|
|
@ -51,10 +51,11 @@ function getDateRange(period: string): { range: DateRange; label: string } {
|
|||
}
|
||||
}
|
||||
|
||||
function toPeriod(s: string): 'today' | 'week' | '30days' | 'month' {
|
||||
function toPeriod(s: string): 'today' | 'week' | '30days' | 'month' | 'all' {
|
||||
if (s === 'today') return 'today'
|
||||
if (s === 'month') return 'month'
|
||||
if (s === '30days') return '30days'
|
||||
if (s === 'all') return 'all'
|
||||
return 'week'
|
||||
}
|
||||
|
||||
|
|
@ -70,7 +71,7 @@ program.hook('preAction', async () => {
|
|||
program
|
||||
.command('report', { isDefault: true })
|
||||
.description('Interactive usage dashboard')
|
||||
.option('-p, --period <period>', 'Starting period: today, week, 30days, month', 'week')
|
||||
.option('-p, --period <period>', 'Starting period: today, week, 30days, month, all', 'week')
|
||||
.option('--provider <provider>', 'Filter by provider: all, claude, codex, cursor', 'all')
|
||||
.option('--refresh <seconds>', 'Auto-refresh interval in seconds', parseInt)
|
||||
.action(async (opts) => {
|
||||
|
|
|
|||
|
|
@ -11,15 +11,16 @@ import { scanAndDetect, type WasteFinding, type WasteAction, type OptimizeResult
|
|||
import { estimateContextBudget, discoverProjectCwd, type ContextBudget } from './context-budget.js'
|
||||
import { join } from 'path'
|
||||
|
||||
type Period = 'today' | 'week' | '30days' | 'month'
|
||||
type Period = 'today' | 'week' | '30days' | 'month' | 'all'
|
||||
type View = 'dashboard' | 'optimize'
|
||||
|
||||
const PERIODS: Period[] = ['today', 'week', '30days', 'month']
|
||||
const PERIODS: Period[] = ['today', 'week', '30days', 'month', 'all']
|
||||
const PERIOD_LABELS: Record<Period, string> = {
|
||||
today: 'Today',
|
||||
week: '7 Days',
|
||||
'30days': '30 Days',
|
||||
month: 'This Month',
|
||||
all: 'All Time',
|
||||
}
|
||||
|
||||
const MIN_WIDE = 90
|
||||
|
|
@ -41,6 +42,7 @@ const PANEL_COLORS = {
|
|||
overview: '#FF8C42',
|
||||
daily: '#5B9EF5',
|
||||
project: '#5BF5A0',
|
||||
sessions: '#FF6B6B',
|
||||
model: '#E05BF5',
|
||||
activity: '#F5C85B',
|
||||
tools: '#5BF5E0',
|
||||
|
|
@ -104,6 +106,7 @@ function getDateRange(period: Period): { start: Date; end: Date } {
|
|||
case 'week': return { start: new Date(now.getFullYear(), now.getMonth(), now.getDate() - 7), end }
|
||||
case '30days': return { start: new Date(now.getFullYear(), now.getMonth(), now.getDate() - 30), end }
|
||||
case 'month': return { start: new Date(now.getFullYear(), now.getMonth(), 1), end }
|
||||
case 'all': return { start: new Date(0), end }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -196,7 +199,7 @@ function DailyActivity({ projects, days = 14, pw, bw }: { projects: ProjectSumma
|
|||
}
|
||||
}
|
||||
}
|
||||
const sortedDays = Object.keys(dailyCosts).sort().slice(-days)
|
||||
const sortedDays = days !== undefined ? Object.keys(dailyCosts).sort().slice(-days) : Object.keys(dailyCosts).sort()
|
||||
const maxCost = Math.max(...sortedDays.map(d => dailyCosts[d] ?? 0))
|
||||
|
||||
return (
|
||||
|
|
@ -228,24 +231,30 @@ function shortProject(encoded: string): string {
|
|||
return parts.slice(-3).join('/')
|
||||
}
|
||||
|
||||
const PROJECT_COL_AVG = 7
|
||||
const PROJECT_COL_BASE_WIDTH = 30
|
||||
const PROJECT_COL_WITH_OVERHEAD_WIDTH = 40
|
||||
|
||||
function ProjectBreakdown({ projects, pw, bw, budgets }: { projects: ProjectSummary[]; pw: number; bw: number; budgets?: Map<string, ContextBudget> }) {
|
||||
const maxCost = Math.max(...projects.map(p => p.totalCostUSD))
|
||||
const hasBudgets = budgets && budgets.size > 0
|
||||
const PROJECT_COL_BASE_WIDTH = 23
|
||||
const PROJECT_COL_WITH_OVERHEAD_WIDTH = 33
|
||||
const nw = Math.max(8, pw - bw - (hasBudgets ? PROJECT_COL_WITH_OVERHEAD_WIDTH : PROJECT_COL_BASE_WIDTH))
|
||||
return (
|
||||
<Panel title="By Project" color={PANEL_COLORS.project} width={pw}>
|
||||
<Text dimColor wrap="truncate-end">
|
||||
{''.padEnd(bw + 1 + nw)}{'cost'.padStart(8)}{'sess'.padStart(6)}{hasBudgets ? 'overhead'.padStart(10) : ''}
|
||||
{''.padEnd(bw + 1 + nw)}{'cost'.padStart(8)}{'avg/s'.padStart(PROJECT_COL_AVG)}{'sess'.padStart(6)}{hasBudgets ? 'overhead'.padStart(10) : ''}
|
||||
</Text>
|
||||
{projects.slice(0, 8).map((project, i) => {
|
||||
const budget = budgets?.get(project.project)
|
||||
const avgCost = project.sessions.length > 0
|
||||
? formatCost(project.totalCostUSD / project.sessions.length)
|
||||
: '-'
|
||||
return (
|
||||
<Text key={`${project.project}-${i}`} wrap="truncate-end">
|
||||
<HBar value={project.totalCostUSD} max={maxCost} width={bw} />
|
||||
<Text dimColor> {fit(shortProject(project.project), nw)}</Text>
|
||||
<Text color={GOLD}>{formatCost(project.totalCostUSD).padStart(8)}</Text>
|
||||
<Text color={GOLD}>{avgCost.padStart(PROJECT_COL_AVG)}</Text>
|
||||
<Text>{String(project.sessions.length).padStart(6)}</Text>
|
||||
{hasBudgets && <Text color="#7B9EF5">{(budget ? formatTokens(budget.total) : '-').padStart(10)}</Text>}
|
||||
</Text>
|
||||
|
|
@ -363,6 +372,44 @@ function ToolBreakdown({ projects, pw, bw, title, filterPrefix }: { projects: Pr
|
|||
)
|
||||
}
|
||||
|
||||
const TOP_SESSIONS_DATE_LEN = 10
|
||||
const TOP_SESSIONS_COST_COL = 8
|
||||
const TOP_SESSIONS_CALLS_COL = 6
|
||||
|
||||
function TopSessions({ projects, pw, bw }: { projects: ProjectSummary[]; pw: number; bw: number }) {
|
||||
const allSessions = projects.flatMap(p =>
|
||||
p.sessions.map(s => ({ ...s, projectName: p.project }))
|
||||
)
|
||||
const top = [...allSessions].sort((a, b) => b.totalCostUSD - a.totalCostUSD).slice(0, 5)
|
||||
|
||||
if (top.length === 0) {
|
||||
return <Panel title="Top Sessions" color={PANEL_COLORS.sessions} width={pw}><Text dimColor>No sessions</Text></Panel>
|
||||
}
|
||||
|
||||
const maxCost = top[0].totalCostUSD
|
||||
const nw = Math.max(8, pw - bw - TOP_SESSIONS_COST_COL - TOP_SESSIONS_CALLS_COL - 1)
|
||||
|
||||
return (
|
||||
<Panel title="Top Sessions" color={PANEL_COLORS.sessions} width={pw}>
|
||||
<Text dimColor wrap="truncate-end">{''.padEnd(bw + 1 + nw)}{'cost'.padStart(TOP_SESSIONS_COST_COL)}{'calls'.padStart(TOP_SESSIONS_CALLS_COL)}</Text>
|
||||
{top.map((session, i) => {
|
||||
const date = session.firstTimestamp
|
||||
? session.firstTimestamp.slice(0, TOP_SESSIONS_DATE_LEN)
|
||||
: '----------'
|
||||
const label = `${date} ${shortProject(session.projectName)}`
|
||||
return (
|
||||
<Text key={`${session.sessionId}-${i}`} wrap="truncate-end">
|
||||
<HBar value={session.totalCostUSD} max={maxCost} width={bw} />
|
||||
<Text dimColor> {fit(label, nw - 1)}</Text>
|
||||
<Text color={GOLD}>{formatCost(session.totalCostUSD).padStart(TOP_SESSIONS_COST_COL)}</Text>
|
||||
<Text>{String(session.apiCalls).padStart(TOP_SESSIONS_CALLS_COL)}</Text>
|
||||
</Text>
|
||||
)
|
||||
})}
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
|
||||
function McpBreakdown({ projects, pw, bw }: { projects: ProjectSummary[]; pw: number; bw: number }) {
|
||||
const mcpTotals: Record<string, number> = {}
|
||||
for (const project of projects) { for (const session of project.sessions) { for (const [server, data] of Object.entries(session.mcpBreakdown)) { mcpTotals[server] = (mcpTotals[server] ?? 0) + data.calls } } }
|
||||
|
|
@ -488,7 +535,8 @@ function StatusBar({ width, showProvider, view, findingCount, optimizeAvailable
|
|||
<Text color={ORANGE} bold>1</Text><Text dimColor> today </Text>
|
||||
<Text color={ORANGE} bold>2</Text><Text dimColor> week </Text>
|
||||
<Text color={ORANGE} bold>3</Text><Text dimColor> 30 days </Text>
|
||||
<Text color={ORANGE} bold>4</Text><Text dimColor> month</Text>
|
||||
<Text color={ORANGE} bold>4</Text><Text dimColor> month </Text>
|
||||
<Text color={ORANGE} bold>5</Text><Text dimColor> all time</Text>
|
||||
{!isOptimize && optimizeAvailable && findingCount != null && findingCount > 0 && (
|
||||
<><Text dimColor> </Text><Text color={ORANGE} bold>o</Text><Text dimColor> optimize</Text><Text color="#F55B5B"> ({findingCount})</Text></>
|
||||
)}
|
||||
|
|
@ -508,11 +556,12 @@ function DashboardContent({ projects, period, columns, activeProvider, budgets }
|
|||
const isCursor = activeProvider === 'cursor'
|
||||
if (projects.length === 0) return <Panel title="CodeBurn" color={ORANGE} width={dashWidth}><Text dimColor>No usage data found for {PERIOD_LABELS[period]}.</Text></Panel>
|
||||
const pw = wide ? halfWidth : dashWidth
|
||||
const days = period === 'month' || period === '30days' ? 31 : 14
|
||||
const days = period === 'all' ? undefined : (period === 'month' || period === '30days' ? 31 : 14)
|
||||
return (
|
||||
<Box flexDirection="column" width={dashWidth}>
|
||||
<Overview projects={projects} label={PERIOD_LABELS[period]} width={dashWidth} />
|
||||
<Row wide={wide} width={dashWidth}><DailyActivity projects={projects} days={days} pw={pw} bw={barWidth} /><ProjectBreakdown projects={projects} pw={pw} bw={barWidth} budgets={budgets} /></Row>
|
||||
<TopSessions projects={projects} pw={dashWidth} bw={barWidth} />
|
||||
<Row wide={wide} width={dashWidth}><ActivityBreakdown projects={projects} pw={pw} bw={barWidth} /><ModelBreakdown projects={projects} pw={pw} bw={barWidth} /></Row>
|
||||
{isCursor ? (
|
||||
<ToolBreakdown projects={projects} pw={dashWidth} bw={barWidth} title="Languages" filterPrefix="lang:" />
|
||||
|
|
@ -624,6 +673,7 @@ function InteractiveDashboard({ initialProjects, initialPeriod, initialProvider,
|
|||
else if (input === '2') switchPeriodImmediate('week')
|
||||
else if (input === '3') switchPeriodImmediate('30days')
|
||||
else if (input === '4') switchPeriodImmediate('month')
|
||||
else if (input === '5') switchPeriodImmediate('all')
|
||||
})
|
||||
|
||||
if (loading) {
|
||||
|
|
|
|||
|
|
@ -42,6 +42,12 @@ const FALLBACK_PRICING: Record<string, ModelCosts> = {
|
|||
'gpt-5.4': { inputCostPerToken: 2.5e-6, outputCostPerToken: 10e-6, cacheWriteCostPerToken: 2.5e-6, cacheReadCostPerToken: 1.25e-6, webSearchCostPerRequest: WEB_SEARCH_COST, fastMultiplier: 1 },
|
||||
'gpt-5.4-mini': { inputCostPerToken: 0.4e-6, outputCostPerToken: 1.6e-6, cacheWriteCostPerToken: 0.4e-6, cacheReadCostPerToken: 0.2e-6, webSearchCostPerRequest: WEB_SEARCH_COST, fastMultiplier: 1 },
|
||||
'gpt-5': { inputCostPerToken: 2.5e-6, outputCostPerToken: 10e-6, cacheWriteCostPerToken: 2.5e-6, cacheReadCostPerToken: 1.25e-6, webSearchCostPerRequest: WEB_SEARCH_COST, fastMultiplier: 1 },
|
||||
'gpt-5-mini': { inputCostPerToken: 0.4e-6, outputCostPerToken: 1.6e-6, cacheWriteCostPerToken: 0.4e-6, cacheReadCostPerToken: 0.2e-6, webSearchCostPerRequest: WEB_SEARCH_COST, fastMultiplier: 1 },
|
||||
'gpt-4.1': { inputCostPerToken: 2e-6, outputCostPerToken: 8e-6, cacheWriteCostPerToken: 2e-6, cacheReadCostPerToken: 0.5e-6, webSearchCostPerRequest: WEB_SEARCH_COST, fastMultiplier: 1 },
|
||||
'gpt-4.1-mini': { inputCostPerToken: 0.4e-6, outputCostPerToken: 1.6e-6, cacheWriteCostPerToken: 0.4e-6, cacheReadCostPerToken: 0.1e-6, webSearchCostPerRequest: WEB_SEARCH_COST, fastMultiplier: 1 },
|
||||
'gpt-4.1-nano': { inputCostPerToken: 0.1e-6, outputCostPerToken: 0.4e-6, cacheWriteCostPerToken: 0.1e-6, cacheReadCostPerToken: 0.025e-6, webSearchCostPerRequest: WEB_SEARCH_COST, fastMultiplier: 1 },
|
||||
'o3': { inputCostPerToken: 10e-6, outputCostPerToken: 40e-6, cacheWriteCostPerToken: 10e-6, cacheReadCostPerToken: 2.5e-6, webSearchCostPerRequest: WEB_SEARCH_COST, fastMultiplier: 1 },
|
||||
'o4-mini': { inputCostPerToken: 1.1e-6, outputCostPerToken: 4.4e-6, cacheWriteCostPerToken: 1.1e-6, cacheReadCostPerToken: 0.275e-6, webSearchCostPerRequest: WEB_SEARCH_COST, fastMultiplier: 1 },
|
||||
}
|
||||
|
||||
let pricingCache: Map<string, ModelCosts> | null = null
|
||||
|
|
@ -182,11 +188,17 @@ export function getShortModelName(model: string): string {
|
|||
'claude-3-5-haiku': 'Haiku 3.5',
|
||||
'gpt-4o-mini': 'GPT-4o Mini',
|
||||
'gpt-4o': 'GPT-4o',
|
||||
'gpt-4.1-nano': 'GPT-4.1 Nano',
|
||||
'gpt-4.1-mini': 'GPT-4.1 Mini',
|
||||
'gpt-4.1': 'GPT-4.1',
|
||||
'gpt-5.4-mini': 'GPT-5.4 Mini',
|
||||
'gpt-5.4': 'GPT-5.4',
|
||||
'gpt-5.3-codex': 'GPT-5.3 Codex',
|
||||
'gpt-5-mini': 'GPT-5 Mini',
|
||||
'gpt-5': 'GPT-5',
|
||||
'gemini-2.5-pro': 'Gemini 2.5 Pro',
|
||||
'o4-mini': 'o4-mini',
|
||||
'o3': 'o3',
|
||||
}
|
||||
for (const [key, name] of Object.entries(shortNames)) {
|
||||
if (canonical.startsWith(key)) return name
|
||||
|
|
|
|||
220
src/providers/copilot.ts
Normal file
220
src/providers/copilot.ts
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
import { readdir, readFile, stat } from 'fs/promises'
|
||||
import { basename, dirname, join } from 'path'
|
||||
import { homedir } from 'os'
|
||||
|
||||
import { calculateCost } from '../models.js'
|
||||
import type { Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js'
|
||||
|
||||
const modelDisplayNames: Record<string, string> = {
|
||||
'gpt-4.1-nano': 'GPT-4.1 Nano',
|
||||
'gpt-4.1-mini': 'GPT-4.1 Mini',
|
||||
'gpt-4.1': 'GPT-4.1',
|
||||
'gpt-4o-mini': 'GPT-4o Mini',
|
||||
'gpt-4o': 'GPT-4o',
|
||||
'gpt-5-mini': 'GPT-5 Mini',
|
||||
'gpt-5': 'GPT-5',
|
||||
'claude-sonnet-4-5': 'Sonnet 4.5',
|
||||
'claude-sonnet-4': 'Sonnet 4',
|
||||
'claude-3-7-sonnet': 'Sonnet 3.7',
|
||||
'claude-3-5-sonnet': 'Sonnet 3.5',
|
||||
'o4-mini': 'o4-mini',
|
||||
'o3': 'o3',
|
||||
}
|
||||
|
||||
const toolNameMap: Record<string, string> = {
|
||||
bash: 'Bash',
|
||||
read_file: 'Read',
|
||||
write_file: 'Edit',
|
||||
edit_file: 'Edit',
|
||||
create_file: 'Write',
|
||||
delete_file: 'Delete',
|
||||
search_files: 'Grep',
|
||||
find_files: 'Glob',
|
||||
list_directory: 'LS',
|
||||
web_search: 'WebSearch',
|
||||
fetch_webpage: 'WebFetch',
|
||||
github_repo: 'GitHub',
|
||||
}
|
||||
|
||||
// Pre-sorted by key length descending so longer/more-specific keys match first
|
||||
const modelDisplayEntries = Object.entries(modelDisplayNames).sort((a, b) => b[0].length - a[0].length)
|
||||
|
||||
// Fields marked optional document the on-disk schema; they are not read by the parser
|
||||
type ToolRequest = {
|
||||
name?: string
|
||||
toolCallId?: string
|
||||
type?: string
|
||||
}
|
||||
|
||||
type ModelChangeData = {
|
||||
newModel: string
|
||||
previousModel?: string
|
||||
}
|
||||
|
||||
type UserMessageData = {
|
||||
content: string
|
||||
interactionId?: string
|
||||
}
|
||||
|
||||
type AssistantMessageData = {
|
||||
messageId: string
|
||||
outputTokens: number
|
||||
interactionId?: string
|
||||
toolRequests?: ToolRequest[]
|
||||
}
|
||||
|
||||
type CopilotEvent =
|
||||
| { type: 'session.model_change'; timestamp?: string; data: ModelChangeData }
|
||||
| { type: 'user.message'; timestamp?: string; data: UserMessageData }
|
||||
| { type: 'assistant.message'; timestamp?: string; data: AssistantMessageData }
|
||||
|
||||
function getCopilotSessionStateDir(override?: string): string {
|
||||
return override ?? join(homedir(), '.copilot', 'session-state')
|
||||
}
|
||||
|
||||
function parseCwd(yaml: string): string | null {
|
||||
const match = yaml.match(/^cwd:\s*(.+)$/m)
|
||||
if (!match?.[1]) return null
|
||||
const raw = match[1]
|
||||
.replace(/\s*#.*$/, '') // strip trailing comment
|
||||
.replace(/^['"]|['"]$/g, '') // strip surrounding quotes
|
||||
.trim()
|
||||
return raw || null
|
||||
}
|
||||
|
||||
function createParser(source: SessionSource, seenKeys: Set<string>): SessionParser {
|
||||
return {
|
||||
async *parse(): AsyncGenerator<ParsedProviderCall> {
|
||||
let content: string
|
||||
try {
|
||||
content = await readFile(source.path, 'utf-8')
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
const sessionId = basename(dirname(source.path))
|
||||
const lines = content.split('\n').filter(l => l.trim())
|
||||
let currentModel = ''
|
||||
let pendingUserMessage = ''
|
||||
|
||||
for (const line of lines) {
|
||||
let event: CopilotEvent
|
||||
try {
|
||||
event = JSON.parse(line) as CopilotEvent
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
|
||||
if (event.type === 'session.model_change') {
|
||||
currentModel = event.data.newModel ?? currentModel
|
||||
continue
|
||||
}
|
||||
|
||||
if (event.type === 'user.message') {
|
||||
pendingUserMessage = event.data.content ?? ''
|
||||
continue
|
||||
}
|
||||
|
||||
if (event.type === 'assistant.message') {
|
||||
const { messageId, outputTokens, toolRequests = [] } = event.data
|
||||
if (outputTokens === 0) continue
|
||||
// Skip if no model has been identified yet - avoids silent misattribution
|
||||
if (!currentModel) continue
|
||||
|
||||
const dedupKey = `copilot:${sessionId}:${messageId}`
|
||||
if (seenKeys.has(dedupKey)) continue
|
||||
seenKeys.add(dedupKey)
|
||||
|
||||
const tools = toolRequests
|
||||
.map(t => t.name ?? '')
|
||||
.filter(Boolean)
|
||||
.map(n => toolNameMap[n] ?? n)
|
||||
|
||||
// Copilot only logs outputTokens; inputTokens are not available in session logs.
|
||||
// Cost will be lower than actual API cost.
|
||||
const costUSD = calculateCost(currentModel, 0, outputTokens, 0, 0, 0)
|
||||
|
||||
yield {
|
||||
provider: 'copilot',
|
||||
model: currentModel,
|
||||
inputTokens: 0,
|
||||
outputTokens,
|
||||
cacheCreationInputTokens: 0,
|
||||
cacheReadInputTokens: 0,
|
||||
cachedInputTokens: 0,
|
||||
reasoningTokens: 0,
|
||||
webSearchRequests: 0,
|
||||
costUSD,
|
||||
tools,
|
||||
bashCommands: [],
|
||||
timestamp: event.timestamp ?? '',
|
||||
speed: 'standard',
|
||||
deduplicationKey: dedupKey,
|
||||
userMessage: pendingUserMessage,
|
||||
sessionId,
|
||||
}
|
||||
|
||||
pendingUserMessage = ''
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function discoverSessionsInDir(sessionStateDir: string): Promise<SessionSource[]> {
|
||||
const sources: SessionSource[] = []
|
||||
|
||||
let sessionDirs: string[]
|
||||
try {
|
||||
sessionDirs = await readdir(sessionStateDir)
|
||||
} catch {
|
||||
return sources
|
||||
}
|
||||
|
||||
for (const sessionId of sessionDirs) {
|
||||
const eventsPath = join(sessionStateDir, sessionId, 'events.jsonl')
|
||||
const s = await stat(eventsPath).catch(() => null)
|
||||
if (!s?.isFile()) continue
|
||||
|
||||
let project = sessionId
|
||||
try {
|
||||
const yaml = await readFile(join(sessionStateDir, sessionId, 'workspace.yaml'), 'utf-8')
|
||||
const cwd = parseCwd(yaml)
|
||||
if (cwd) project = basename(cwd)
|
||||
} catch {}
|
||||
|
||||
sources.push({ path: eventsPath, project, provider: 'copilot' })
|
||||
}
|
||||
|
||||
return sources
|
||||
}
|
||||
|
||||
export function createCopilotProvider(sessionStateDir?: string): Provider {
|
||||
const dir = getCopilotSessionStateDir(sessionStateDir)
|
||||
|
||||
return {
|
||||
name: 'copilot',
|
||||
displayName: 'Copilot',
|
||||
|
||||
modelDisplayName(model: string): string {
|
||||
for (const [key, name] of modelDisplayEntries) {
|
||||
if (model === key || model.startsWith(key + '-')) return name
|
||||
}
|
||||
return model
|
||||
},
|
||||
|
||||
toolDisplayName(rawTool: string): string {
|
||||
return toolNameMap[rawTool] ?? rawTool
|
||||
},
|
||||
|
||||
async discoverSessions(): Promise<SessionSource[]> {
|
||||
return discoverSessionsInDir(dir)
|
||||
},
|
||||
|
||||
createSessionParser(source: SessionSource, seenKeys: Set<string>): SessionParser {
|
||||
return createParser(source, seenKeys)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export const copilot = createCopilotProvider()
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import { claude } from './claude.js'
|
||||
import { codex } from './codex.js'
|
||||
import { copilot } from './copilot.js'
|
||||
import { pi } from './pi.js'
|
||||
import type { Provider, SessionSource } from './types.js'
|
||||
|
||||
|
|
@ -33,7 +34,7 @@ async function loadOpenCode(): Promise<Provider | null> {
|
|||
}
|
||||
}
|
||||
|
||||
const coreProviders: Provider[] = [claude, codex, pi]
|
||||
const coreProviders: Provider[] = [claude, codex, copilot, pi]
|
||||
|
||||
export async function getAllProviders(): Promise<Provider[]> {
|
||||
const [cursor, opencode] = await Promise.all([loadCursor(), loadOpenCode()])
|
||||
|
|
|
|||
112
tests/dashboard.test.ts
Normal file
112
tests/dashboard.test.ts
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
import { describe, it, expect } from 'vitest'
|
||||
|
||||
import { formatCost } from '../src/format.js'
|
||||
import type { ProjectSummary, SessionSummary } from '../src/types.js'
|
||||
|
||||
const EMPTY_CATEGORY_BREAKDOWN = {
|
||||
coding: { turns: 0, costUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 },
|
||||
debugging: { turns: 0, costUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 },
|
||||
feature: { turns: 0, costUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 },
|
||||
refactoring: { turns: 0, costUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 },
|
||||
testing: { turns: 0, costUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 },
|
||||
exploration: { turns: 0, costUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 },
|
||||
planning: { turns: 0, costUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 },
|
||||
delegation: { turns: 0, costUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 },
|
||||
git: { turns: 0, costUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 },
|
||||
'build/deploy': { turns: 0, costUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 },
|
||||
conversation: { turns: 0, costUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 },
|
||||
brainstorming: { turns: 0, costUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 },
|
||||
general: { turns: 0, costUSD: 0, retries: 0, editTurns: 0, oneShotTurns: 0 },
|
||||
} satisfies SessionSummary['categoryBreakdown']
|
||||
|
||||
function makeSession(id: string, cost: number, timestamp = '2026-04-14T10:00:00Z'): SessionSummary {
|
||||
return {
|
||||
sessionId: id,
|
||||
project: 'test-project',
|
||||
firstTimestamp: timestamp,
|
||||
lastTimestamp: timestamp,
|
||||
totalCostUSD: cost,
|
||||
totalInputTokens: 0,
|
||||
totalOutputTokens: 0,
|
||||
totalCacheReadTokens: 0,
|
||||
totalCacheWriteTokens: 0,
|
||||
apiCalls: 1,
|
||||
turns: [],
|
||||
modelBreakdown: {},
|
||||
toolBreakdown: {},
|
||||
mcpBreakdown: {},
|
||||
bashBreakdown: {},
|
||||
categoryBreakdown: { ...EMPTY_CATEGORY_BREAKDOWN },
|
||||
}
|
||||
}
|
||||
|
||||
function makeProject(name: string, sessions: SessionSummary[]): ProjectSummary {
|
||||
return {
|
||||
project: name,
|
||||
projectPath: name,
|
||||
sessions,
|
||||
totalCostUSD: sessions.reduce((s, x) => s + x.totalCostUSD, 0),
|
||||
totalApiCalls: sessions.reduce((s, x) => s + x.apiCalls, 0),
|
||||
}
|
||||
}
|
||||
|
||||
// Logic replicated from TopSessions component
|
||||
function getTopSessions(projects: ProjectSummary[], n = 5) {
|
||||
const all = projects.flatMap(p => p.sessions.map(s => ({ ...s, projectName: p.project })))
|
||||
return [...all].sort((a, b) => b.totalCostUSD - a.totalCostUSD).slice(0, n)
|
||||
}
|
||||
|
||||
// Logic replicated from ProjectBreakdown component
|
||||
function avgCostLabel(project: ProjectSummary): string {
|
||||
return project.sessions.length > 0
|
||||
? formatCost(project.totalCostUSD / project.sessions.length)
|
||||
: '-'
|
||||
}
|
||||
|
||||
describe('TopSessions - top-5 selection', () => {
|
||||
it('returns all sessions when fewer than 5 exist', () => {
|
||||
const project = makeProject('proj', [
|
||||
makeSession('s1', 1.0),
|
||||
makeSession('s2', 2.0),
|
||||
])
|
||||
const top = getTopSessions([project])
|
||||
expect(top).toHaveLength(2)
|
||||
expect(top[0].totalCostUSD).toBe(2.0)
|
||||
expect(top[1].totalCostUSD).toBe(1.0)
|
||||
})
|
||||
|
||||
it('returns exactly 5 when more than 5 sessions exist', () => {
|
||||
const sessions = [0.1, 0.5, 3.0, 1.0, 0.8, 2.0].map((cost, i) =>
|
||||
makeSession(`s${i}`, cost)
|
||||
)
|
||||
const project = makeProject('proj', sessions)
|
||||
const top = getTopSessions([project])
|
||||
expect(top).toHaveLength(5)
|
||||
expect(top[0].totalCostUSD).toBe(3.0)
|
||||
expect(top[4].totalCostUSD).toBe(0.5)
|
||||
})
|
||||
|
||||
it('is stable on tied costs - preserves input order for equal values', () => {
|
||||
const sessions = [
|
||||
makeSession('s1', 1.0),
|
||||
makeSession('s2', 1.0),
|
||||
makeSession('s3', 1.0),
|
||||
]
|
||||
const project = makeProject('proj', sessions)
|
||||
const top = getTopSessions([project])
|
||||
expect(top.map(s => s.sessionId)).toEqual(['s1', 's2', 's3'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('avg/s in ProjectBreakdown', () => {
|
||||
it('returns dash for a project with no sessions', () => {
|
||||
const project = makeProject('proj', [])
|
||||
expect(avgCostLabel(project)).toBe('-')
|
||||
})
|
||||
|
||||
it('returns formatted average cost across sessions', () => {
|
||||
const sessions = [makeSession('s1', 2.0), makeSession('s2', 4.0)]
|
||||
const project = makeProject('proj', sessions)
|
||||
expect(avgCostLabel(project)).toBe(formatCost(3.0))
|
||||
})
|
||||
})
|
||||
|
|
@ -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', 'pi'])
|
||||
expect(providers.map(p => p.name)).toEqual(['claude', 'codex', 'copilot', 'pi'])
|
||||
})
|
||||
|
||||
it('includes sqlite providers after async load', async () => {
|
||||
|
|
|
|||
253
tests/providers/copilot.test.ts
Normal file
253
tests/providers/copilot.test.ts
Normal file
|
|
@ -0,0 +1,253 @@
|
|||
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 { copilot, createCopilotProvider } from '../../src/providers/copilot.js'
|
||||
import type { ParsedProviderCall } from '../../src/providers/types.js'
|
||||
|
||||
let tmpDir: string
|
||||
|
||||
async function createSessionDir(sessionId: string, lines: string[], cwd = '/home/user/myproject') {
|
||||
const sessionDir = join(tmpDir, sessionId)
|
||||
await mkdir(sessionDir, { recursive: true })
|
||||
await writeFile(join(sessionDir, 'workspace.yaml'), `id: ${sessionId}\ncwd: ${cwd}\n`)
|
||||
await writeFile(join(sessionDir, 'events.jsonl'), lines.join('\n') + '\n')
|
||||
return join(sessionDir, 'events.jsonl')
|
||||
}
|
||||
|
||||
function modelChange(newModel: string, previousModel?: string) {
|
||||
return JSON.stringify({ type: 'session.model_change', timestamp: '2026-04-15T10:00:01Z', data: { newModel, previousModel } })
|
||||
}
|
||||
|
||||
function userMessage(content: string) {
|
||||
return JSON.stringify({ type: 'user.message', timestamp: '2026-04-15T10:00:10Z', data: { content, interactionId: 'int-1' } })
|
||||
}
|
||||
|
||||
function assistantMessage(opts: { messageId: string; outputTokens: number; tools?: string[]; timestamp?: string }) {
|
||||
return JSON.stringify({
|
||||
type: 'assistant.message',
|
||||
timestamp: opts.timestamp ?? '2026-04-15T10:00:15Z',
|
||||
data: {
|
||||
messageId: opts.messageId,
|
||||
outputTokens: opts.outputTokens,
|
||||
interactionId: 'int-1',
|
||||
toolRequests: (opts.tools ?? []).map(name => ({ name, toolCallId: `call-${name}`, type: 'function' })),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('copilot provider - JSONL parsing', () => {
|
||||
beforeEach(async () => {
|
||||
tmpDir = await mkdtemp(join(tmpdir(), 'copilot-test-'))
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('parses a basic assistant message', async () => {
|
||||
const eventsPath = await createSessionDir('sess-001', [
|
||||
modelChange('gpt-4.1'),
|
||||
userMessage('write a function'),
|
||||
assistantMessage({ messageId: 'msg-1', outputTokens: 150 }),
|
||||
])
|
||||
|
||||
const source = { path: eventsPath, project: 'myproject', provider: 'copilot' }
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of copilot.createSessionParser(source, new Set()).parse()) calls.push(call)
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
const call = calls[0]!
|
||||
expect(call.provider).toBe('copilot')
|
||||
expect(call.model).toBe('gpt-4.1')
|
||||
expect(call.outputTokens).toBe(150)
|
||||
expect(call.inputTokens).toBe(0)
|
||||
expect(call.userMessage).toBe('write a function')
|
||||
expect(call.sessionId).toBe('sess-001')
|
||||
expect(call.bashCommands).toEqual([])
|
||||
expect(call.costUSD).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('tracks model changes mid-session', async () => {
|
||||
const eventsPath = await createSessionDir('sess-002', [
|
||||
modelChange('gpt-5-mini'),
|
||||
userMessage('first'),
|
||||
assistantMessage({ messageId: 'msg-1', outputTokens: 50, timestamp: '2026-04-15T10:00:10Z' }),
|
||||
modelChange('gpt-4.1', 'gpt-5-mini'),
|
||||
userMessage('second'),
|
||||
assistantMessage({ messageId: 'msg-2', outputTokens: 80, timestamp: '2026-04-15T10:01:00Z' }),
|
||||
])
|
||||
|
||||
const source = { path: eventsPath, project: 'test', provider: 'copilot' }
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of copilot.createSessionParser(source, new Set()).parse()) calls.push(call)
|
||||
|
||||
expect(calls).toHaveLength(2)
|
||||
expect(calls[0]!.model).toBe('gpt-5-mini')
|
||||
expect(calls[1]!.model).toBe('gpt-4.1')
|
||||
})
|
||||
|
||||
it('extracts tool names from toolRequests', async () => {
|
||||
const eventsPath = await createSessionDir('sess-003', [
|
||||
modelChange('gpt-4.1'),
|
||||
userMessage('run tests'),
|
||||
assistantMessage({ messageId: 'msg-1', outputTokens: 60, tools: ['bash', 'read_file', 'write_file'] }),
|
||||
])
|
||||
|
||||
const source = { path: eventsPath, project: 'test', provider: 'copilot' }
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of copilot.createSessionParser(source, new Set()).parse()) calls.push(call)
|
||||
|
||||
expect(calls[0]!.tools).toEqual(['Bash', 'Read', 'Edit'])
|
||||
})
|
||||
|
||||
it('skips assistant messages with zero outputTokens', async () => {
|
||||
const eventsPath = await createSessionDir('sess-004', [
|
||||
modelChange('gpt-4.1'),
|
||||
assistantMessage({ messageId: 'msg-empty', outputTokens: 0 }),
|
||||
assistantMessage({ messageId: 'msg-real', outputTokens: 42 }),
|
||||
])
|
||||
|
||||
const source = { path: eventsPath, project: 'test', provider: 'copilot' }
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of copilot.createSessionParser(source, new Set()).parse()) calls.push(call)
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]!.outputTokens).toBe(42)
|
||||
})
|
||||
|
||||
it('deduplicates messages across parser runs', async () => {
|
||||
const eventsPath = await createSessionDir('sess-005', [
|
||||
modelChange('gpt-4.1'),
|
||||
assistantMessage({ messageId: 'msg-dup', outputTokens: 100 }),
|
||||
])
|
||||
|
||||
const source = { path: eventsPath, project: 'test', provider: 'copilot' }
|
||||
const seenKeys = new Set<string>()
|
||||
|
||||
const calls1: ParsedProviderCall[] = []
|
||||
for await (const call of copilot.createSessionParser(source, seenKeys).parse()) calls1.push(call)
|
||||
|
||||
const calls2: ParsedProviderCall[] = []
|
||||
for await (const call of copilot.createSessionParser(source, seenKeys).parse()) calls2.push(call)
|
||||
|
||||
expect(calls1).toHaveLength(1)
|
||||
expect(calls2).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('returns empty for missing file', async () => {
|
||||
const source = { path: '/nonexistent/events.jsonl', project: 'test', provider: 'copilot' }
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of copilot.createSessionParser(source, new Set()).parse()) calls.push(call)
|
||||
expect(calls).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('skips assistant messages before the first model_change event', async () => {
|
||||
const eventsPath = await createSessionDir('sess-no-model', [
|
||||
assistantMessage({ messageId: 'msg-early', outputTokens: 50 }),
|
||||
modelChange('gpt-4.1'),
|
||||
assistantMessage({ messageId: 'msg-after', outputTokens: 80 }),
|
||||
])
|
||||
|
||||
const source = { path: eventsPath, project: 'test', provider: 'copilot' }
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of copilot.createSessionParser(source, new Set()).parse()) calls.push(call)
|
||||
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]!.messageId).toBeUndefined()
|
||||
expect(calls[0]!.outputTokens).toBe(80)
|
||||
expect(calls[0]!.model).toBe('gpt-4.1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('copilot provider - discoverSessions', () => {
|
||||
beforeEach(async () => {
|
||||
tmpDir = await mkdtemp(join(tmpdir(), 'copilot-test-'))
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('discovers sessions from directory', async () => {
|
||||
await createSessionDir('sess-disc-001', [modelChange('gpt-4.1')])
|
||||
await createSessionDir('sess-disc-002', [modelChange('gpt-4.1')])
|
||||
|
||||
const provider = createCopilotProvider(tmpDir)
|
||||
const sessions = await provider.discoverSessions()
|
||||
|
||||
expect(sessions).toHaveLength(2)
|
||||
expect(sessions.every(s => s.provider === 'copilot')).toBe(true)
|
||||
expect(sessions.every(s => s.path.endsWith('events.jsonl'))).toBe(true)
|
||||
})
|
||||
|
||||
it('reads project name from workspace.yaml cwd', async () => {
|
||||
await createSessionDir('sess-disc-003', [modelChange('gpt-4.1')], '/home/user/myapp')
|
||||
|
||||
const provider = createCopilotProvider(tmpDir)
|
||||
const sessions = await provider.discoverSessions()
|
||||
|
||||
expect(sessions).toHaveLength(1)
|
||||
expect(sessions[0]!.project).toBe('myapp')
|
||||
})
|
||||
|
||||
it('strips quotes and trailing comments from workspace.yaml cwd', async () => {
|
||||
const sessionDir = join(tmpDir, 'sess-quoted')
|
||||
await mkdir(sessionDir, { recursive: true })
|
||||
await writeFile(join(sessionDir, 'workspace.yaml'), 'cwd: "/home/user/myapp" # project root\n')
|
||||
await writeFile(join(sessionDir, 'events.jsonl'), '\n')
|
||||
|
||||
const provider = createCopilotProvider(tmpDir)
|
||||
const sessions = await provider.discoverSessions()
|
||||
|
||||
expect(sessions).toHaveLength(1)
|
||||
expect(sessions[0]!.project).toBe('myapp')
|
||||
})
|
||||
|
||||
it('returns empty when directory does not exist', async () => {
|
||||
const provider = createCopilotProvider('/nonexistent/path')
|
||||
const sessions = await provider.discoverSessions()
|
||||
expect(sessions).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('skips entries without events.jsonl', async () => {
|
||||
const emptyDir = join(tmpDir, 'empty-session')
|
||||
await mkdir(emptyDir, { recursive: true })
|
||||
|
||||
const provider = createCopilotProvider(tmpDir)
|
||||
const sessions = await provider.discoverSessions()
|
||||
expect(sessions).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('copilot provider - metadata', () => {
|
||||
it('has correct name and displayName', () => {
|
||||
expect(copilot.name).toBe('copilot')
|
||||
expect(copilot.displayName).toBe('Copilot')
|
||||
})
|
||||
|
||||
it('normalizes tool display names', () => {
|
||||
expect(copilot.toolDisplayName('bash')).toBe('Bash')
|
||||
expect(copilot.toolDisplayName('read_file')).toBe('Read')
|
||||
expect(copilot.toolDisplayName('write_file')).toBe('Edit')
|
||||
expect(copilot.toolDisplayName('web_search')).toBe('WebSearch')
|
||||
expect(copilot.toolDisplayName('unknown_tool')).toBe('unknown_tool')
|
||||
})
|
||||
|
||||
it('normalizes model display names', () => {
|
||||
expect(copilot.modelDisplayName('gpt-4.1')).toBe('GPT-4.1')
|
||||
expect(copilot.modelDisplayName('gpt-4.1-mini')).toBe('GPT-4.1 Mini')
|
||||
expect(copilot.modelDisplayName('gpt-4.1-nano')).toBe('GPT-4.1 Nano')
|
||||
expect(copilot.modelDisplayName('gpt-5-mini')).toBe('GPT-5 Mini')
|
||||
expect(copilot.modelDisplayName('o3')).toBe('o3')
|
||||
expect(copilot.modelDisplayName('o4-mini')).toBe('o4-mini')
|
||||
expect(copilot.modelDisplayName('unknown-model-xyz')).toBe('unknown-model-xyz')
|
||||
})
|
||||
|
||||
it('longest-prefix match wins for versioned model IDs', () => {
|
||||
// gpt-5-mini-2026-01-01 must match gpt-5-mini, not gpt-5
|
||||
expect(copilot.modelDisplayName('gpt-5-mini-2026-01-01')).toBe('GPT-5 Mini')
|
||||
expect(copilot.modelDisplayName('gpt-4.1-mini-2026-01-01')).toBe('GPT-4.1 Mini')
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue