codeburn/tests/providers/codewhale.test.ts
ozymandiashh df78e50052
feat: add CodeWhale provider support (#674)
Co-authored-by: AgentSeal <hello@agentseal.org>
2026-07-16 03:12:28 -07:00

291 lines
10 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { mkdir, mkdtemp, open, readFile, rm, utimes, writeFile } from 'fs/promises'
import { tmpdir } from 'os'
import { join } from 'path'
import { clearSessionCache, parseAllSessions } from '../../src/parser.js'
import { MAX_SESSION_FILE_BYTES } from '../../src/fs-utils.js'
import { codewhale, createCodeWhaleProvider } from '../../src/providers/codewhale.js'
import type { ParsedProviderCall } from '../../src/providers/types.js'
let tmpDir: string
let previousCodeWhaleHome: string | undefined
let previousCacheDir: string | undefined
type SessionOptions = {
id?: string
createdAt?: string
updatedAt?: string
totalTokens?: number
model?: string
modelProvider?: string
workspace?: string
cost?: Record<string, unknown> | null
messages?: unknown[]
}
async function writeSession(
sessionsDir: string,
filename: string,
options: SessionOptions = {},
): Promise<string> {
await mkdir(sessionsDir, { recursive: true })
const metadata: Record<string, unknown> = {
id: options.id ?? filename.replace(/\.json$/, ''),
title: 'CodeWhale test session',
created_at: options.createdAt ?? '2026-07-14T10:00:00.000Z',
updated_at: options.updatedAt ?? '2026-07-14T11:00:00.000Z',
message_count: options.messages?.length ?? 0,
total_tokens: options.totalTokens ?? 1_000,
model: options.model ?? 'deepseek-chat',
model_provider: options.modelProvider ?? 'deepseek',
workspace: options.workspace ?? '/Users/test/codewhale-project',
mode: 'agent',
}
if (options.cost !== null) {
metadata.cost = options.cost ?? {
session_cost_usd: 0.25,
subagent_cost_usd: 0.05,
}
}
const path = join(sessionsDir, filename)
await writeFile(path, JSON.stringify({
schema_version: 1,
metadata,
messages: options.messages ?? [],
system_prompt: null,
}))
return path
}
async function parseOne(path: string, seenKeys = new Set<string>()): Promise<ParsedProviderCall[]> {
const source = { path, project: 'fixture', provider: 'codewhale' }
const calls: ParsedProviderCall[] = []
for await (const call of codewhale.createSessionParser(source, seenKeys).parse()) {
calls.push(call)
}
return calls
}
describe('codewhale provider', () => {
beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), 'codewhale-test-'))
previousCodeWhaleHome = process.env['CODEWHALE_HOME']
previousCacheDir = process.env['CODEBURN_CACHE_DIR']
clearSessionCache()
})
afterEach(async () => {
clearSessionCache()
if (previousCodeWhaleHome === undefined) delete process.env['CODEWHALE_HOME']
else process.env['CODEWHALE_HOME'] = previousCodeWhaleHome
if (previousCacheDir === undefined) delete process.env['CODEBURN_CACHE_DIR']
else process.env['CODEBURN_CACHE_DIR'] = previousCacheDir
await rm(tmpDir, { recursive: true, force: true })
})
it('discovers primary and legacy sessions while the primary id wins collisions', async () => {
const primary = join(tmpDir, 'primary')
const legacy = join(tmpDir, 'legacy')
const primaryDuplicate = await writeSession(primary, 'primary-copy.json', {
id: 'same-session',
workspace: '/repos/primary-project',
})
await writeSession(legacy, 'legacy-copy.json', {
id: 'same-session',
workspace: '/repos/legacy-project',
})
const legacyOnly = await writeSession(legacy, 'legacy-only.json', {
id: 'legacy-only',
workspace: 'C:\\repos\\windows-project',
})
await writeFile(join(primary, 'malformed.json'), '{not-json')
await writeFile(join(primary, 'not-a-session.json'), JSON.stringify({ metadata: { title: 'missing id' } }))
await mkdir(join(primary, 'checkpoints'), { recursive: true })
await writeFile(join(primary, 'checkpoints', 'latest.json'), '{}')
const sessions = await createCodeWhaleProvider([primary, legacy]).discoverSessions()
expect(sessions).toEqual([
{ path: primaryDuplicate, project: 'primary-project', provider: 'codewhale' },
{ path: legacyOnly, project: 'windows-project', provider: 'codewhale' },
])
})
it('treats CODEWHALE_HOME as the exact home and scans its sessions child', async () => {
const home = join(tmpDir, 'custom-home')
const session = await writeSession(join(home, 'sessions'), 'custom.json')
await writeSession(join(home, '.codewhale', 'sessions'), 'wrongly-nested.json')
process.env['CODEWHALE_HOME'] = home
const sessions = await createCodeWhaleProvider().discoverSessions()
expect(sessions.map(source => source.path)).toEqual([session])
})
it('parses one cumulative record without inventing an input/output split', async () => {
const path = await writeSession(join(tmpDir, 'sessions'), 'full.json', {
id: 'session-full',
totalTokens: 12_345,
model: 'anthropic/claude-sonnet-4-6',
workspace: '/Users/alice/codewhale-demo',
updatedAt: '2026-07-15T12:34:56.000Z',
cost: {
session_cost_usd: 0.75,
subagent_cost_usd: 0.20,
displayed_cost_high_water_usd: 2,
},
messages: [
{ role: 'user', content: [{ type: 'text', text: 'Implement the parser' }] },
{
role: 'assistant',
content: [
{ type: 'thinking', thinking: '...' },
{ type: 'tool_use', id: 't1', name: 'read_file', input: { file_path: 'src/app.ts' } },
{ type: 'tool_use', id: 't2', name: 'exec_shell', input: { command: 'npm test && git status' } },
{ type: 'tool_use', id: 't3', name: 'edit_file', input: { path: 'src/app.ts' } },
],
},
{
role: 'assistant',
content: [
{ type: 'tool_use', id: 't4', name: 'load_skill', input: { name: 'typescript' } },
{ type: 'tool_use', id: 't5', name: 'agent', input: { type: 'reviewer' } },
{ type: 'server_tool_use', id: 't6', name: 'web_search', input: { query: 'CodeWhale' } },
],
},
],
})
const [call] = await parseOne(path)
expect(call).toMatchObject({
provider: 'codewhale',
model: 'anthropic/claude-sonnet-4-6',
inputTokens: 12_345,
outputTokens: 0,
cacheCreationInputTokens: 0,
cacheReadInputTokens: 0,
reasoningTokens: 0,
webSearchRequests: 1,
costUSD: 0.95,
costIsEstimated: false,
timestamp: '2026-07-15T12:34:56.000Z',
userMessage: 'Implement the parser',
sessionId: 'session-full',
project: 'codewhale-demo',
projectPath: '/Users/alice/codewhale-demo',
tools: ['Read', 'Bash', 'Edit', 'Skill', 'Agent', 'WebSearch'],
bashCommands: ['npm', 'git'],
skills: ['typescript'],
subagentTypes: ['reviewer'],
deduplicationKey: 'codewhale:session-full',
})
expect(call!.toolSequence).toEqual([
[
{ tool: 'Read', file: 'src/app.ts' },
{ tool: 'Bash', command: 'npm test && git status' },
{ tool: 'Edit', file: 'src/app.ts' },
],
[
{ tool: 'Skill' },
{ tool: 'Agent' },
{ tool: 'WebSearch' },
],
])
})
it('uses model pricing only when CodeWhale did not persist a cost snapshot', async () => {
const estimatedPath = await writeSession(join(tmpDir, 'sessions'), 'estimated.json', {
totalTokens: 1_000_000,
model: 'gpt-4o-mini',
cost: null,
})
const exactZeroPath = await writeSession(join(tmpDir, 'sessions'), 'exact-zero.json', {
totalTokens: 1_000_000,
model: 'gpt-4o-mini',
cost: { session_cost_usd: 0, subagent_cost_usd: 0 },
})
const [estimated] = await parseOne(estimatedPath)
const [exactZero] = await parseOne(exactZeroPath)
expect(estimated!.costUSD).toBeGreaterThan(0)
expect(estimated!.costIsEstimated).toBe(true)
expect(exactZero!.costUSD).toBe(0)
expect(exactZero!.costIsEstimated).toBe(false)
})
it('falls back to file mtime for malformed timestamps and deduplicates by session id', async () => {
const path = await writeSession(join(tmpDir, 'sessions'), 'mtime.json', {
id: 'mtime-session',
createdAt: 'invalid',
updatedAt: 'also-invalid',
})
const fallback = new Date('2026-07-13T08:30:00.000Z')
await utimes(path, fallback, fallback)
const seen = new Set<string>()
const first = await parseOne(path, seen)
const second = await parseOne(path, seen)
expect(first[0]!.timestamp).toBe(fallback.toISOString())
expect(second).toEqual([])
})
it('keeps authoritative aggregate usage when a transcript exceeds the full-read cap', async () => {
const path = await writeSession(join(tmpDir, 'sessions'), 'oversize.json', {
id: 'oversize-session',
totalTokens: 98_765,
cost: { session_cost_usd: 1.2, subagent_cost_usd: 0.3 },
})
const handle = await open(path, 'r+')
await handle.truncate(MAX_SESSION_FILE_BYTES + 1)
await handle.close()
const [call] = await parseOne(path)
expect(call).toMatchObject({
sessionId: 'oversize-session',
inputTokens: 98_765,
costUSD: 1.5,
tools: [],
})
})
it('preserves CodeWhale-reported cost after the shared disk-cache round trip', async () => {
const home = join(tmpDir, 'home')
const cacheDir = join(tmpDir, 'cache')
await writeSession(join(home, 'sessions'), 'cached.json', {
id: 'cached-session',
totalTokens: 1_000_000,
model: 'gpt-4o-mini',
cost: { session_cost_usd: 0.7, subagent_cost_usd: 0.05 },
})
process.env['CODEWHALE_HOME'] = home
process.env['CODEBURN_CACHE_DIR'] = cacheDir
const first = await parseAllSessions(undefined, 'codewhale')
clearSessionCache()
const second = await parseAllSessions(undefined, 'codewhale')
expect(first).toHaveLength(1)
expect(first[0]!.totalCostUSD).toBeCloseTo(0.75)
expect(second[0]!.totalCostUSD).toBeCloseTo(0.75)
const cache = JSON.parse(await readFile(join(cacheDir, 'session-cache.json'), 'utf-8')) as {
providers: { codewhale: { envFingerprint: string } }
}
expect(cache.providers.codewhale.envFingerprint).toMatch(/^[a-f0-9]{16}$/)
})
it('exposes canonical model and tool display names', () => {
expect(codewhale.name).toBe('codewhale')
expect(codewhale.displayName).toBe('CodeWhale')
expect(codewhale.modelDisplayName('claude-sonnet-4-6')).toBe('Sonnet 4.6')
expect(codewhale.toolDisplayName('apply_patch')).toBe('Edit')
expect(codewhale.toolDisplayName('custom_plugin_tool')).toBe('custom_plugin_tool')
expect(codewhale.toolDisplayName('__proto__')).toBe('__proto__')
})
})