mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-05-17 03:56:45 +00:00
Resolves merge conflicts with main (README.md, src/providers/index.ts, tests/provider-registry.test.ts, package-lock.json) and addresses PR review feedback from Qodo-Free-For-OSS: - Codebuff sessionId now includes the channel root name (manicode, manicode-dev, manicode-staging) derived from the chat directory structure, preventing the same chatId from colliding across channels when downstream aggregation keys by (provider, sessionId, project). - Uses '/' as the channel/chatId separator to avoid clashing with src/parser.ts's colon-delimited session key format. - Adds tests: collision across channels, channel inclusion, and fallback when the path doesn't match the expected shape. - Updates README dedup paragraph to mention Codebuff's strategy.
480 lines
17 KiB
TypeScript
480 lines
17 KiB
TypeScript
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<string, unknown>
|
||
}
|
||
|
||
type TextBlock = { type: 'text'; content: string }
|
||
|
||
type Block = ToolBlock | TextBlock
|
||
|
||
type AiOpts = {
|
||
id?: string
|
||
credits?: number
|
||
timestamp?: string
|
||
blocks?: Block[]
|
||
metadata?: Record<string, unknown>
|
||
}
|
||
|
||
function aiMessage(opts: AiOpts = {}) {
|
||
const m: Record<string, unknown> = {
|
||
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<string> {
|
||
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/<name>/chats/<chatId>/', 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<string>()
|
||
|
||
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 <channel>/projects/<proj>/chats/<chatId> 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')
|
||
})
|
||
})
|