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 { createCodebuffProvider } from '../../src/providers/codebuff.js' import type { ParsedProviderCall } from '../../src/providers/types.js' let tmpDir: string beforeEach(async () => { tmpDir = await mkdtemp(join(tmpdir(), 'codebuff-test-')) }) afterEach(async () => { await rm(tmpDir, { recursive: true, force: true }) }) type ToolBlock = { type: 'tool' toolName: string input?: Record } type TextBlock = { type: 'text'; content: string } type Block = ToolBlock | TextBlock type AiOpts = { id?: string credits?: number timestamp?: string blocks?: Block[] metadata?: Record } function aiMessage(opts: AiOpts = {}) { const m: Record = { id: opts.id ?? 'msg-ai-1', variant: 'ai', content: '', timestamp: opts.timestamp ?? '2026-04-14T10:00:30.000Z', } if (opts.blocks !== undefined) m['blocks'] = opts.blocks if (opts.credits !== undefined) m['credits'] = opts.credits if (opts.metadata !== undefined) m['metadata'] = opts.metadata return m } function userMessage(content: string, timestamp?: string) { return { id: 'msg-user-1', variant: 'user', content, timestamp: timestamp ?? '2026-04-14T10:00:10.000Z', } } async function writeChat( baseDir: string, projectName: string, chatId: string, messages: unknown[], runState?: unknown, ): Promise { const chatDir = join(baseDir, 'projects', projectName, 'chats', chatId) await mkdir(chatDir, { recursive: true }) await writeFile(join(chatDir, 'chat-messages.json'), JSON.stringify(messages)) if (runState !== undefined) { await writeFile(join(chatDir, 'run-state.json'), JSON.stringify(runState)) } return chatDir } describe('codebuff provider - session discovery', () => { it('discovers sessions under projects//chats//', async () => { await writeChat( tmpDir, 'myproject', '2026-04-14T10-00-00.000Z', [userMessage('hi'), aiMessage({ credits: 10 })], { sessionState: { projectContext: { cwd: '/Users/test/myproject' } } }, ) const provider = createCodebuffProvider(tmpDir) const sessions = await provider.discoverSessions() expect(sessions).toHaveLength(1) expect(sessions[0]!.provider).toBe('codebuff') expect(sessions[0]!.project).toBe('myproject') expect(sessions[0]!.path).toContain('2026-04-14T10-00-00.000Z') }) it('uses the cwd basename from run-state.json when present', async () => { await writeChat( tmpDir, 'sanitized-folder', '2026-04-14T11-00-00.000Z', [aiMessage({ credits: 5 })], { sessionState: { projectContext: { cwd: '/Users/test/real-project' } } }, ) const provider = createCodebuffProvider(tmpDir) const sessions = await provider.discoverSessions() expect(sessions).toHaveLength(1) expect(sessions[0]!.project).toBe('real-project') }) it('falls back to the folder name when run-state.json is missing', async () => { await writeChat(tmpDir, 'fallback-project', '2026-04-14T12-00-00.000Z', [ aiMessage({ credits: 3 }), ]) const provider = createCodebuffProvider(tmpDir) const sessions = await provider.discoverSessions() expect(sessions).toHaveLength(1) expect(sessions[0]!.project).toBe('fallback-project') }) it('discovers sessions across multiple projects', async () => { await writeChat(tmpDir, 'proj-a', '2026-04-14T10-00-00.000Z', [aiMessage({ credits: 1 })]) await writeChat(tmpDir, 'proj-b', '2026-04-14T10-30-00.000Z', [aiMessage({ credits: 2 })]) const provider = createCodebuffProvider(tmpDir) const sessions = await provider.discoverSessions() expect(sessions).toHaveLength(2) const projects = sessions.map(s => s.project).sort() expect(projects).toEqual(['proj-a', 'proj-b']) }) it('returns empty for a non-existent directory', async () => { const provider = createCodebuffProvider('/nonexistent/codebuff-path') const sessions = await provider.discoverSessions() expect(sessions).toEqual([]) }) it('skips chat folders without chat-messages.json', async () => { const chatDir = join(tmpDir, 'projects', 'proj', 'chats', '2026-04-14T10-00-00.000Z') await mkdir(chatDir, { recursive: true }) // No chat-messages.json created. const provider = createCodebuffProvider(tmpDir) const sessions = await provider.discoverSessions() expect(sessions).toEqual([]) }) }) describe('codebuff provider - JSONL parsing', () => { it('yields one call per assistant message with credits, mapping codebuff tools to canonical names', async () => { const chatDir = await writeChat( tmpDir, 'proj', '2026-04-14T10-00-00.000Z', [ userMessage('implement the feature'), aiMessage({ credits: 42, metadata: { runState: { sessionState: { mainAgentState: { agentType: 'base2' } } }, }, blocks: [ { type: 'tool', toolName: 'read_files', input: {} }, { type: 'tool', toolName: 'str_replace', input: {} }, { type: 'tool', toolName: 'run_terminal_command', input: { command: 'npm test' } }, { type: 'tool', toolName: 'suggest_followups', input: {} }, ], }), ], ) const provider = createCodebuffProvider(tmpDir) const source = { path: chatDir, project: 'proj', provider: 'codebuff' } const calls: ParsedProviderCall[] = [] for await (const call of provider.createSessionParser(source, new Set()).parse()) { calls.push(call) } expect(calls).toHaveLength(1) const call = calls[0]! expect(call.provider).toBe('codebuff') expect(call.model).toBe('codebuff-base2') expect(call.userMessage).toBe('implement the feature') // `suggest_followups` is intentionally dropped from the tool breakdown. expect(call.tools).toEqual(['Read', 'Edit', 'Bash']) expect(call.bashCommands).toContain('npm') // Credits × $0.01 = $0.42 when token counts are absent. expect(call.costUSD).toBeCloseTo(0.42, 6) expect(call.inputTokens).toBe(0) expect(call.outputTokens).toBe(0) }) it('prefers direct metadata.usage tokens when available and still records credits', async () => { const chatDir = await writeChat(tmpDir, 'proj', '2026-04-14T10-00-00.000Z', [ aiMessage({ credits: 10, metadata: { model: 'claude-haiku-4-5-20251001', usage: { inputTokens: 5000, outputTokens: 2000, cacheCreationInputTokens: 1000, cacheReadInputTokens: 500, }, }, }), ]) const provider = createCodebuffProvider(tmpDir) const source = { path: chatDir, project: 'proj', provider: 'codebuff' } const calls: ParsedProviderCall[] = [] for await (const call of provider.createSessionParser(source, new Set()).parse()) { calls.push(call) } expect(calls).toHaveLength(1) const call = calls[0]! expect(call.model).toBe('claude-haiku-4-5-20251001') expect(call.inputTokens).toBe(5000) expect(call.outputTokens).toBe(2000) expect(call.cacheCreationInputTokens).toBe(1000) expect(call.cacheReadInputTokens).toBe(500) expect(call.cachedInputTokens).toBe(500) // With real token counts the calculated cost takes precedence over credits. expect(call.costUSD).toBeGreaterThan(0) }) it('falls back to providerOptions.codebuff.usage in the stashed RunState history', async () => { const chatDir = await writeChat(tmpDir, 'proj', '2026-04-14T10-00-00.000Z', [ aiMessage({ credits: 7, metadata: { runState: { sessionState: { mainAgentState: { messageHistory: [ { role: 'user' }, { role: 'assistant', providerOptions: { codebuff: { model: 'openai/gpt-4o', usage: { prompt_tokens: 2000, completion_tokens: 800, prompt_tokens_details: { cached_tokens: 400 }, }, }, }, }, ], }, }, }, }, }), ]) const provider = createCodebuffProvider(tmpDir) const source = { path: chatDir, project: 'proj', provider: 'codebuff' } const calls: ParsedProviderCall[] = [] for await (const call of provider.createSessionParser(source, new Set()).parse()) { calls.push(call) } expect(calls).toHaveLength(1) expect(calls[0]!.model).toBe('openai/gpt-4o') expect(calls[0]!.inputTokens).toBe(2000) expect(calls[0]!.outputTokens).toBe(800) expect(calls[0]!.cacheReadInputTokens).toBe(400) }) it('skips assistant messages with no credits and no tokens', async () => { const chatDir = await writeChat(tmpDir, 'proj', '2026-04-14T10-00-00.000Z', [ aiMessage({ blocks: [{ type: 'text', content: 'mode-divider' }] }), ]) const provider = createCodebuffProvider(tmpDir) const source = { path: chatDir, project: 'proj', provider: 'codebuff' } const calls: ParsedProviderCall[] = [] for await (const call of provider.createSessionParser(source, new Set()).parse()) { calls.push(call) } expect(calls).toHaveLength(0) }) it('deduplicates calls seen across multiple parses', async () => { const chatDir = await writeChat(tmpDir, 'proj', '2026-04-14T10-00-00.000Z', [ aiMessage({ id: 'msg-dup', credits: 3 }), ]) const provider = createCodebuffProvider(tmpDir) const source = { path: chatDir, project: 'proj', provider: 'codebuff' } const seenKeys = new Set() const firstRun: ParsedProviderCall[] = [] for await (const call of provider.createSessionParser(source, seenKeys).parse()) { firstRun.push(call) } const secondRun: ParsedProviderCall[] = [] for await (const call of provider.createSessionParser(source, seenKeys).parse()) { secondRun.push(call) } expect(firstRun).toHaveLength(1) expect(secondRun).toHaveLength(0) }) it('yields one call per assistant message in a multi-turn chat, preserving user messages', async () => { const chatDir = await writeChat(tmpDir, 'proj', '2026-04-14T10-00-00.000Z', [ userMessage('first question'), aiMessage({ id: 'a1', credits: 5, timestamp: '2026-04-14T10:00:30.000Z' }), userMessage('second question', '2026-04-14T10:01:00.000Z'), aiMessage({ id: 'a2', credits: 8, timestamp: '2026-04-14T10:01:30.000Z' }), ]) const provider = createCodebuffProvider(tmpDir) const source = { path: chatDir, project: 'proj', provider: 'codebuff' } const calls: ParsedProviderCall[] = [] for await (const call of provider.createSessionParser(source, new Set()).parse()) { calls.push(call) } expect(calls).toHaveLength(2) expect(calls[0]!.userMessage).toBe('first question') expect(calls[0]!.costUSD).toBeCloseTo(0.05, 6) expect(calls[1]!.userMessage).toBe('second question') expect(calls[1]!.costUSD).toBeCloseTo(0.08, 6) }) it('handles a missing chat-messages.json gracefully', async () => { const provider = createCodebuffProvider(tmpDir) const source = { path: join(tmpDir, 'projects', 'missing', 'chats', 'nope'), project: 'missing', provider: 'codebuff', } const calls: ParsedProviderCall[] = [] for await (const call of provider.createSessionParser(source, new Set()).parse()) { calls.push(call) } expect(calls).toHaveLength(0) }) it('skips a malformed chat-messages.json without throwing', async () => { const chatDir = join(tmpDir, 'projects', 'proj', 'chats', '2026-04-14T10-00-00.000Z') await mkdir(chatDir, { recursive: true }) await writeFile(join(chatDir, 'chat-messages.json'), 'not-valid-json') const provider = createCodebuffProvider(tmpDir) const source = { path: chatDir, project: 'proj', provider: 'codebuff' } const calls: ParsedProviderCall[] = [] for await (const call of provider.createSessionParser(source, new Set()).parse()) { calls.push(call) } expect(calls).toHaveLength(0) }) }) describe('codebuff provider - sessionId channel scoping', () => { it('produces distinct sessionIds for the same chatId across different channel roots', async () => { const chatId = '2026-04-14T10-00-00.000Z' const channelA = join(tmpDir, 'manicode') const channelB = join(tmpDir, 'manicode-dev') const cwd = '/Users/test/shared-project' const runState = { sessionState: { projectContext: { cwd } } } const chatDirA = await writeChat( channelA, 'shared-project', chatId, [userMessage('hi'), aiMessage({ credits: 5 })], runState, ) const chatDirB = await writeChat( channelB, 'shared-project', chatId, [userMessage('hi'), aiMessage({ credits: 5 })], runState, ) const providerA = createCodebuffProvider(channelA) const providerB = createCodebuffProvider(channelB) const sourceA = { path: chatDirA, project: 'shared-project', provider: 'codebuff' } const sourceB = { path: chatDirB, project: 'shared-project', provider: 'codebuff' } const callsA: ParsedProviderCall[] = [] for await (const call of providerA.createSessionParser(sourceA, new Set()).parse()) { callsA.push(call) } const callsB: ParsedProviderCall[] = [] for await (const call of providerB.createSessionParser(sourceB, new Set()).parse()) { callsB.push(call) } expect(callsA).toHaveLength(1) expect(callsB).toHaveLength(1) // The whole point of the fix: same chatId + same project should NOT // collapse into a single session when the chats live under different // channel roots. expect(callsA[0]!.sessionId).not.toBe(callsB[0]!.sessionId) expect(callsA[0]!.sessionId).toBe(`manicode/${chatId}`) expect(callsB[0]!.sessionId).toBe(`manicode-dev/${chatId}`) // The sessionId must not contain ':' -- src/parser.ts keys sessions as // `${provider}:${sessionId}:${project}` and reconstructs the session via // `key.split(':')[1]`, so a colon would truncate the id downstream. expect(callsA[0]!.sessionId).not.toContain(':') expect(callsB[0]!.sessionId).not.toContain(':') }) it('includes the channel name in the sessionId', async () => { const chatId = '2026-04-14T10-00-00.000Z' const channelRoot = join(tmpDir, 'manicode-staging') const chatDir = await writeChat(channelRoot, 'proj', chatId, [aiMessage({ credits: 3 })]) const provider = createCodebuffProvider(channelRoot) const source = { path: chatDir, project: 'proj', provider: 'codebuff' } const calls: ParsedProviderCall[] = [] for await (const call of provider.createSessionParser(source, new Set()).parse()) { calls.push(call) } expect(calls).toHaveLength(1) expect(calls[0]!.sessionId).toBe(`manicode-staging/${chatId}`) expect(calls[0]!.sessionId).not.toContain(':') }) it('falls back to the chatId when the path does not match the expected structure', async () => { const chatId = '2026-04-14T10-00-00.000Z' // Not the canonical /projects//chats/ layout. const chatDir = join(tmpDir, 'oddly-shaped', chatId) await mkdir(chatDir, { recursive: true }) await writeFile( join(chatDir, 'chat-messages.json'), JSON.stringify([aiMessage({ credits: 2 })]), ) const provider = createCodebuffProvider(tmpDir) const source = { path: chatDir, project: 'proj', provider: 'codebuff' } const calls: ParsedProviderCall[] = [] for await (const call of provider.createSessionParser(source, new Set()).parse()) { calls.push(call) } expect(calls).toHaveLength(1) expect(calls[0]!.sessionId).toBe(chatId) }) }) describe('codebuff provider - display names', () => { const provider = createCodebuffProvider('/tmp') it('has the correct identifiers', () => { expect(provider.name).toBe('codebuff') expect(provider.displayName).toBe('Codebuff') }) it('maps known Codebuff tiers to readable names', () => { expect(provider.modelDisplayName('codebuff')).toBe('Codebuff') expect(provider.modelDisplayName('codebuff-base2')).toBe('Codebuff Base 2') expect(provider.modelDisplayName('codebuff-lite')).toBe('Codebuff Lite') }) it('returns the raw name for unknown models', () => { expect(provider.modelDisplayName('claude-sonnet-4-6')).toBe('claude-sonnet-4-6') }) it('normalizes tool names to the canonical set', () => { expect(provider.toolDisplayName('read_files')).toBe('Read') expect(provider.toolDisplayName('str_replace')).toBe('Edit') expect(provider.toolDisplayName('run_terminal_command')).toBe('Bash') expect(provider.toolDisplayName('unknown_tool')).toBe('unknown_tool') }) })