diff --git a/src/providers/copilot.ts b/src/providers/copilot.ts index 365c1d4..9cb7983 100644 --- a/src/providers/copilot.ts +++ b/src/providers/copilot.ts @@ -27,7 +27,7 @@ const toolNameMap: Record = { write_file: 'Edit', edit_file: 'Edit', create_file: 'Write', - delete_file: 'Edit', + delete_file: 'Delete', search_files: 'Grep', find_files: 'Glob', list_directory: 'LS', @@ -39,6 +39,7 @@ const toolNameMap: Record = { // 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 @@ -71,6 +72,16 @@ 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): SessionParser { return { async *parse(): AsyncGenerator { @@ -83,7 +94,7 @@ function createParser(source: SessionSource, seenKeys: Set): SessionPars const sessionId = basename(dirname(source.path)) const lines = content.split('\n').filter(l => l.trim()) - let currentModel = 'gpt-4.1' + let currentModel = '' let pendingUserMessage = '' for (const line of lines) { @@ -107,6 +118,8 @@ function createParser(source: SessionSource, seenKeys: Set): SessionPars 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 @@ -117,6 +130,8 @@ function createParser(source: SessionSource, seenKeys: Set): SessionPars .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 { @@ -164,8 +179,8 @@ async function discoverSessionsInDir(sessionStateDir: string): Promise { 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', () => { @@ -175,6 +192,19 @@ describe('copilot provider - discoverSessions', () => { 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() @@ -214,4 +244,10 @@ describe('copilot provider - metadata', () => { 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') + }) })