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:
AgentSeal 2026-04-16 12:56:53 -07:00
commit 21444df2bf
8 changed files with 661 additions and 12 deletions

View file

@ -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) => {

View file

@ -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) {

View file

@ -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
View 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()

View file

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

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

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