codeburn/tests/providers/amp.test.ts
ozymandiashh 157347aa99
feat: Amp support - read the local thread mirror (#1572) (#1578)
* feat: Amp support - read the local thread mirror (#1572)

Amp (Sourcegraph's coding agent) mirrors every thread it serves as one
JSON document per thread at ~/.local/share/amp/threads/ (AMP_DATA_DIR
honored, comma-separated). Each file is { id, messages,
usageLedger?{events} }; the ledger's events are the per-request record
(tokens.input/output, cache tokens joined from the billed message
through toMessageId -> messages[].messageId), with a per-assistant-
message usage fallback for threads without a ledger and a total-only
record billing as output.

Amp bills a subscription and records no charged dollars, so every call
is priced from the underlying model's token rates and marked
costIsEstimated; the recorded credits field is ignored (no published
per-credit dollar rate). Thread files carry no cwd, so sessions group
under the provider name. Registered eager; parse version
usage-ledger-v1; no artwork yet (mono-initial fallback, like Codebuff).

Surfaces: CLI registry + doctor probe roots, Electron sandbox read
paths + menubar catalog (Amp after Cursor, matching the Swift order),
Windows tray tab color + usage_guard root, GNOME prefs dropdown, macOS
ProviderConnectionCatalog, docs/providers/amp.md, README tool list,
CHANGELOG.

* fix: address Amp provider review feedback
2026-10-02 10:36:05 +02:00

331 lines
12 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 { createAmpProvider } from '../../src/providers/amp.js'
import { calculateCost } from '../../src/models.js'
let tmpDir: string
beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), 'amp-test-'))
})
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true })
})
// Mirrors the real on-disk layout: <amp-data>/threads/<thread-id>.json, one
// JSON document per thread. The shapes follow ccusage's amp adapter fixtures
// (rust/adapters/amp/src/parser.rs), which parse real Amp thread files.
async function writeThread(id: string, thread: unknown): Promise<string> {
const dir = join(tmpDir, 'threads')
await mkdir(dir, { recursive: true })
const path = join(dir, `${id}.json`)
await writeFile(path, JSON.stringify(thread), 'utf-8')
return path
}
const LEDGER_THREAD = {
id: 'T-abc123',
messages: [
{ role: 'user', content: 'Fix the flaky test in parser.ts' },
{
role: 'assistant',
messageId: 11,
usage: {
model: 'claude-opus-4-6',
inputTokens: 10,
outputTokens: 178,
cacheCreationInputTokens: 986,
cacheReadInputTokens: 11372,
timestamp: '2026-09-20T11:42:10.652Z',
},
},
{ role: 'user', content: 'Now make it fast' },
{
role: 'assistant',
messageId: 14,
usage: {
model: 'claude-opus-4-6',
inputTokens: 5,
outputTokens: 42,
cacheCreationInputTokens: 0,
cacheReadInputTokens: 12000,
timestamp: '2026-09-20T11:43:00.000Z',
},
},
],
usageLedger: {
events: [
{
id: 'evt-1',
timestamp: '2026-09-20T11:42:12.000Z',
model: 'claude-opus-4-6',
tokens: { input: 12368, output: 178 },
toMessageId: 11,
},
{
id: 'evt-2',
timestamp: '2026-09-20T11:43:02.000Z',
model: 'claude-opus-4-6',
tokens: { input: 12005, output: 42 },
toMessageId: 14,
},
],
},
}
async function parseAll(source: Awaited<ReturnType<ReturnType<typeof createAmpProvider>['discoverSessions']>[number]>, seenKeys = new Set<string>()) {
const provider = createAmpProvider([tmpDir])
const calls = []
for await (const call of provider.createSessionParser(source, seenKeys).parse()) calls.push(call)
return calls
}
describe('amp provider', () => {
it('reports its identity', () => {
const provider = createAmpProvider([tmpDir])
expect(provider.name).toBe('amp')
expect(provider.displayName).toBe('Amp')
expect(provider.modelDisplayName('claude-opus-4-6')).toBeTruthy()
expect(provider.toolDisplayName('some_tool')).toBe('some_tool')
})
it('probes the threads dir it discovers', async () => {
const provider = createAmpProvider([tmpDir])
expect(await provider.probeRoots()).toEqual([{ path: join(tmpDir, 'threads'), label: 'threads' }])
})
it('resolves the default root from AMP_DATA_DIR, comma-separated', async () => {
const first = await mkdtemp(join(tmpdir(), 'amp-root-a-'))
const second = await mkdtemp(join(tmpdir(), 'amp-root-b-'))
try {
process.env['AMP_DATA_DIR'] = `${first}, ${second}`
const provider = createAmpProvider()
expect(await provider.probeRoots()).toEqual([
{ path: join(first, 'threads'), label: 'threads' },
{ path: join(second, 'threads'), label: 'threads' },
])
} finally {
delete process.env['AMP_DATA_DIR']
await rm(first, { recursive: true, force: true })
await rm(second, { recursive: true, force: true })
}
})
it('discovers nothing when the data dir is absent', async () => {
const provider = createAmpProvider([join(tmpDir, 'missing')])
expect(await provider.discoverSessions()).toEqual([])
})
it('discovers JSON candidates without parsing and skips invalid threads during parsing', async () => {
await writeThread('T-good', LEDGER_THREAD)
await mkdir(join(tmpDir, 'threads'), { recursive: true })
await writeFile(join(tmpDir, 'threads', 'notes.txt'), 'not a thread', 'utf-8')
await writeFile(join(tmpDir, 'threads', 'T-broken.json'), '{not json', 'utf-8')
await writeFile(join(tmpDir, 'threads', 'T-noid.json'), JSON.stringify({ messages: [] }), 'utf-8')
const provider = createAmpProvider([tmpDir])
const sources = await provider.discoverSessions()
expect(sources.map(source => source.path).sort()).toEqual([
join(tmpDir, 'threads', 'T-broken.json'),
join(tmpDir, 'threads', 'T-good.json'),
join(tmpDir, 'threads', 'T-noid.json'),
])
for (const source of sources) {
expect(source.provider).toBe('amp')
expect(source.project).toBe('Amp')
expect(await parseAll(source)).toHaveLength(source.path.endsWith('T-good.json') ? 2 : 0)
}
})
it('discovers and parses nested threads', async () => {
const dir = join(tmpDir, 'threads', 'sub', 'nested')
await mkdir(dir, { recursive: true })
const path = join(dir, 'T-2.json')
await writeFile(path, JSON.stringify({ ...LEDGER_THREAD, id: 'T-2' }), 'utf-8')
await writeFile(join(dir, 'notes.txt'), 'not a thread', 'utf-8')
const sources = await createAmpProvider([tmpDir]).discoverSessions()
expect(sources).toEqual([{ path, project: 'Amp', provider: 'amp' }])
const calls = await parseAll(sources[0])
expect(calls).toHaveLength(2)
expect(calls[0].sessionId).toBe('T-2')
})
it('parses ledger events with cache tokens joined from the billed message', async () => {
const path = await writeThread('T-abc123', LEDGER_THREAD)
const calls = await parseAll({ path, project: 'Amp', provider: 'amp' })
expect(calls).toHaveLength(2)
expect(calls[0].model).toBe('claude-opus-4-6')
expect(calls[0].inputTokens).toBe(12368)
expect(calls[0].outputTokens).toBe(178)
expect(calls[0].cacheCreationInputTokens).toBe(986)
expect(calls[0].cacheReadInputTokens).toBe(11372)
expect(calls[0].cachedInputTokens).toBe(11372)
expect(calls[0].reasoningTokens).toBe(0)
expect(calls[0].timestamp).toBe('2026-09-20T11:42:12.000Z')
expect(calls[0].sessionId).toBe('T-abc123')
expect(calls[0].deduplicationKey).toBe('amp:T-abc123:evt-1')
expect(calls[0].costIsEstimated).toBe(true)
expect(calls[0].costUSD).toBe(calculateCost('claude-opus-4-6', 12368, 178, 986, 11372, 0))
expect(calls[0].costUSD).toBeGreaterThan(0)
// The ledger event carries no text; attribution falls back to the
// thread's first user message.
expect(calls[0].userMessage).toBe('Fix the flaky test in parser.ts')
expect(calls[1].deduplicationKey).toBe('amp:T-abc123:evt-2')
expect(calls[1].cacheReadInputTokens).toBe(12000)
})
it('bills a total-only token record as output', async () => {
await writeThread('T-total', {
id: 'T-total',
messages: [],
usageLedger: {
events: [
{ id: 'evt-t', timestamp: '2026-09-20T12:00:00.000Z', model: 'gpt-5.6-codex', tokens: { total: 345 } },
],
},
})
const path = join(tmpDir, 'threads', 'T-total.json')
const calls = await parseAll({ path, project: 'Amp', provider: 'amp' })
expect(calls).toHaveLength(1)
expect(calls[0].inputTokens).toBe(0)
expect(calls[0].outputTokens).toBe(345)
expect(calls[0].costUSD).toBeGreaterThan(0)
})
it.each([
{ input: 10, output: 20, creation: 30, read: 40, total: 150, expectedOutput: 70 },
{ input: 10, output: 20, creation: 30, read: 40, total: 100, expectedOutput: 20 },
{ input: 10, output: 20, creation: 30, read: 40, total: 50, expectedOutput: 20 },
{ input: 10, output: 20, creation: 0, read: 0, total: 50, expectedOutput: 40 },
{ input: 0, output: 0, creation: 30, read: 40, total: 100, expectedOutput: 30 },
])('accounts for ledger total remainders: %j', async ({ input, output, creation, read, total, expectedOutput }) => {
const path = await writeThread('T-remainder', {
id: 'T-remainder',
messages: [{
role: 'assistant',
messageId: 1,
usage: { cacheCreationInputTokens: creation, cacheReadInputTokens: read },
}],
usageLedger: {
events: [{
id: 'evt-remainder',
timestamp: '2026-09-20T12:00:00.000Z',
model: 'claude-opus-4-6',
tokens: { input, output, total },
toMessageId: 1,
}],
},
})
const calls = await parseAll({ path, project: 'Amp', provider: 'amp' })
expect(calls).toHaveLength(1)
expect(calls[0].inputTokens).toBe(input)
expect(calls[0].outputTokens).toBe(expectedOutput)
expect(calls[0].cacheCreationInputTokens).toBe(creation)
expect(calls[0].cacheReadInputTokens).toBe(read)
expect(calls[0].costUSD).toBe(calculateCost('claude-opus-4-6', input, expectedOutput, creation, read, 0))
})
it('falls back to per-message usage when the thread has no ledger', async () => {
await writeThread('T-msgs', {
id: 'T-msgs',
messages: [
{ role: 'user', content: 'hi' },
{
role: 'assistant',
messageId: 3,
usage: {
model: 'claude-haiku-4-5-20251001',
inputTokens: 10,
outputTokens: 178,
cacheCreationInputTokens: 986,
cacheReadInputTokens: 11372,
timestamp: '2026-09-19T11:42:10.652Z',
},
},
{
role: 'assistant',
// Model and timestamp may live on the message when the usage block
// omits them.
model: 'gpt-5.6-codex',
timestamp: '2026-09-19T11:43:00.000Z',
usage: { inputTokens: 5, outputTokens: 42 },
},
],
})
const path = join(tmpDir, 'threads', 'T-msgs.json')
const calls = await parseAll({ path, project: 'Amp', provider: 'amp' })
expect(calls).toHaveLength(2)
expect(calls[0].model).toBe('claude-haiku-4-5-20251001')
expect(calls[0].inputTokens).toBe(10)
expect(calls[0].outputTokens).toBe(178)
expect(calls[0].cacheCreationInputTokens).toBe(986)
expect(calls[0].cacheReadInputTokens).toBe(11372)
expect(calls[0].timestamp).toBe('2026-09-19T11:42:10.652Z')
expect(calls[0].userMessage).toBe('hi')
expect(calls[1].model).toBe('gpt-5.6-codex')
expect(calls[1].timestamp).toBe('2026-09-19T11:43:00.000Z')
})
it('skips records with no usable tokens', async () => {
await writeThread('T-empty', {
id: 'T-empty',
messages: [],
usageLedger: {
events: [
{ id: 'evt-e', timestamp: '2026-09-20T12:00:00.000Z', model: 'gpt-5.6-codex', tokens: {} },
{ timestamp: '2026-09-20T12:00:01.000Z', model: 'gpt-5.6-codex', tokens: { input: 1 } },
],
},
})
const path = join(tmpDir, 'threads', 'T-empty.json')
const calls = await parseAll({ path, project: 'Amp', provider: 'amp' })
expect(calls).toHaveLength(1)
expect(calls[0].inputTokens).toBe(1)
})
it('deduplicates through seenKeys', async () => {
const path = await writeThread('T-abc123', LEDGER_THREAD)
const source = { path, project: 'Amp', provider: 'amp' }
const seenKeys = new Set<string>()
const first = await parseAll(source, seenKeys)
expect(first).toHaveLength(2)
const second = await parseAll(source, seenKeys)
expect(second).toHaveLength(0)
})
it('clamps malformed token fields instead of leaking NaN', async () => {
await writeThread('T-bad', {
id: 'T-bad',
messages: [],
usageLedger: {
events: [
{ id: 'evt-n', timestamp: '2026-09-20T12:00:00.000Z', model: 'gpt-5.6-codex', tokens: { input: -5, output: 'many' } },
],
},
})
const path = join(tmpDir, 'threads', 'T-bad.json')
const calls = await parseAll({ path, project: 'Amp', provider: 'amp' })
// Every field malformed or non-positive -> nothing usable, no call.
expect(calls).toHaveLength(0)
})
it('is registered in the provider registry', async () => {
const { getAllProviders } = await import('../../src/providers/index.js')
const providers = await getAllProviders()
expect(providers.find(p => p.name === 'amp')).toBeDefined()
})
})