Files
2026-07-13 12:26:02 +08:00

372 lines
17 KiB
TypeScript

import { afterAll, describe, expect, it } from 'vitest'
import { appendFile, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { dedupeStreamingMessageIds, parseApiCall, parseJsonlLine } from '../src/parser.js'
import { computeSessionUsage, emptyCache, readCache, writeAllow, writeCache } from '../src/guard/usage.js'
import { runGuardHook, runGuardStatusline } from '../src/guard/hooks.js'
import { writeGuardConfig, DEFAULT_GUARD_CONFIG } from '../src/guard/store.js'
import { buildFlags, matchFlag, writeFlags, type GuardFlags } from '../src/guard/flags.js'
import type { ProjectSummary } from '../src/types.js'
const roots: string[] = []
async function tmp(): Promise<string> {
const d = await mkdtemp(join(tmpdir(), 'codeburn-guard-hooks-'))
roots.push(d)
return d
}
afterAll(async () => { for (const r of roots) await rm(r, { recursive: true, force: true }) })
type Tool = { name: string; input?: Record<string, unknown> }
function assistantLine(id: string, o: { inTok?: number; outTok?: number; tools?: Tool[]; ts?: string } = {}): string {
const content: Record<string, unknown>[] = [{ type: 'text', text: 'ok' }]
for (const t of o.tools ?? []) content.push({ type: 'tool_use', id: `${t.name}-${id}`, name: t.name, input: t.input ?? {} })
return JSON.stringify({
type: 'assistant',
timestamp: o.ts ?? '2026-07-01T00:00:00.000Z',
message: {
model: 'claude-sonnet-4-20250514',
id,
type: 'message',
role: 'assistant',
usage: { input_tokens: o.inTok ?? 1_000_000, output_tokens: o.outTok ?? 200_000, cache_read_input_tokens: 0 },
content,
},
}) + '\n'
}
async function transcript(lines: string[]): Promise<string> {
const dir = await tmp()
const path = join(dir, 'session.jsonl')
await writeFile(path, lines.join(''), 'utf-8')
return path
}
const SID = 'sess-1'
function hookInput(path: string): string {
return JSON.stringify({ session_id: SID, transcript_path: path, hook_event_name: 'PreToolUse', tool_name: 'Bash' })
}
// The shipped pipeline's cost for a transcript: parse every line, dedupe
// streaming copies last-wins exactly like parseSessionFile does, sum call
// costs. The guard's incremental fold must match this to the cent.
async function shippedDedupedCost(path: string): Promise<number> {
const entries = (await readFile(path, 'utf-8'))
.split('\n')
.filter(l => l.trim())
.map(l => parseJsonlLine(l))
.filter((e): e is NonNullable<ReturnType<typeof parseJsonlLine>> => e !== null)
return dedupeStreamingMessageIds(entries).reduce((s, e) => s + (parseApiCall(e)?.costUSD ?? 0), 0)
}
describe('incremental session cache', () => {
it('parses only the appended tail and totals match a cold parse', async () => {
const base = await tmp()
const path = await transcript([assistantLine('a'), assistantLine('b')])
const r1 = await computeSessionUsage(emptyCache(SID), path)
await writeCache(r1.cache, base)
expect(r1.resumedFrom).toBe(0)
expect(r1.cache.costUSD).toBeGreaterThan(0)
const offset1 = r1.cache.byteOffset
await appendFile(path, assistantLine('c') + assistantLine('d'), 'utf-8')
const size = (await stat(path)).size
const prev = await readCache(SID, base)
const r2 = await computeSessionUsage(prev, path)
// Bytes-read assertion: the second pass resumed exactly where the first
// stopped and consumed only the appended region (not the whole file).
expect(r2.resumedFrom).toBe(offset1)
expect(r2.cache.byteOffset).toBe(size)
expect(r2.cache.byteOffset - r2.resumedFrom).toBeGreaterThan(0)
expect(r2.resumedFrom).toBeLessThan(size)
const cold = await computeSessionUsage(emptyCache(SID), path)
expect(r2.cache.costUSD).toBeCloseTo(cold.cache.costUSD, 10)
expect(r2.cache.costUSD).toBeGreaterThan(r1.cache.costUSD)
})
it('resets to a cold parse when the transcript shrinks (rotation)', async () => {
const base = await tmp()
const path = await transcript([assistantLine('a'), assistantLine('b')])
const r1 = await computeSessionUsage(emptyCache(SID), path)
await writeCache(r1.cache, base)
await writeFile(path, assistantLine('z'), 'utf-8') // smaller file, new content
const r2 = await computeSessionUsage(await readCache(SID, base), path)
expect(r2.resumedFrom).toBe(0)
const cold = await computeSessionUsage(emptyCache(SID), path)
expect(r2.cache.costUSD).toBeCloseTo(cold.cache.costUSD, 10)
})
})
describe('streaming duplicates (per-message-id replace)', () => {
it('counts a message written 3x with identical usage once, matching the shipped dedup', async () => {
const dup = assistantLine('msg-dup', { inTok: 500_000, outTok: 100_000 })
const path = await transcript([dup, dup, dup, assistantLine('msg-other')])
const singlePath = await transcript([dup, assistantLine('msg-other')])
const { cache } = await computeSessionUsage(emptyCache(SID), path)
expect(cache.costUSD).toBeCloseTo(await shippedDedupedCost(path), 10)
const single = await computeSessionUsage(emptyCache(SID), singlePath)
expect(cache.costUSD).toBeCloseTo(single.cache.costUSD, 10)
})
it('keeps the last copy when streamed usage grows', async () => {
const copies = [10_000, 50_000, 120_000].map(outTok => assistantLine('msg-grow', { outTok }))
const path = await transcript(copies)
const lastOnly = await transcript([copies[2]!])
const { cache } = await computeSessionUsage(emptyCache(SID), path)
expect(cache.costUSD).toBeCloseTo(await shippedDedupedCost(path), 10)
const last = await computeSessionUsage(emptyCache(SID), lastOnly)
expect(cache.costUSD).toBeCloseTo(last.cache.costUSD, 10)
})
it('replaces across incremental invocations when a later copy arrives', async () => {
const base = await tmp()
const path = await transcript([assistantLine('msg-x', { outTok: 20_000 })])
const r1 = await computeSessionUsage(emptyCache(SID), path)
await writeCache(r1.cache, base)
await appendFile(path, assistantLine('msg-x', { outTok: 90_000 }) + assistantLine('msg-y'), 'utf-8')
const r2 = await computeSessionUsage(await readCache(SID, base), path)
expect(r2.resumedFrom).toBe(r1.cache.byteOffset)
expect(r2.cache.costUSD).toBeCloseTo(await shippedDedupedCost(path), 10)
})
})
describe('trailing complete line without newline', () => {
it('does not double count the line once it completes (A,B,C then D)', async () => {
const base = await tmp()
const a = assistantLine('msg-a')
const b = assistantLine('msg-b')
const c = assistantLine('msg-c')
const d = assistantLine('msg-d')
const dir = await tmp()
const path = join(dir, 'session.jsonl')
await writeFile(path, a + b + c.slice(0, -1), 'utf-8') // C complete but unterminated
const r1 = await computeSessionUsage(emptyCache(SID), path)
await writeCache(r1.cache, base)
// C was folded, but the offset stops after B's newline.
expect(r1.cache.costUSD).toBeCloseTo(await shippedDedupedCost(path), 10)
expect(r1.cache.byteOffset).toBe((a + b).length)
await appendFile(path, '\n' + d, 'utf-8')
const r2 = await computeSessionUsage(await readCache(SID, base), path)
// C is re-read as a replace, not a second add: totals equal a cold parse.
const cold = await computeSessionUsage(emptyCache(SID), path)
expect(r2.cache.costUSD).toBeCloseTo(cold.cache.costUSD, 10)
expect(r2.cache.costUSD).toBeCloseTo(await shippedDedupedCost(path), 10)
})
})
describe('git commit detection', () => {
it('requires commit to be the git subcommand', async () => {
const cases: [string, boolean][] = [
['git commit -m x', true],
['git -c user.name=x commit', true],
['npm test && git commit -am done', true],
['git add src\ngit commit -m "step"', true], // newline-separated multi-command Bash call
['git log --grep commit', false],
['git diff && echo commit', false],
['echo git then commit', false],
['git diff\ncommit notes to self', false], // commit on the next line is not a git subcommand
]
for (const [command, expected] of cases) {
const path = await transcript([assistantLine('msg-1', { tools: [{ name: 'Bash', input: { command } }] })])
const { cache } = await computeSessionUsage(emptyCache(SID), path)
expect(cache.sawGitCommit, command).toBe(expected)
}
})
})
describe('budget hook (PreToolUse)', () => {
async function costOf(path: string): Promise<number> {
return (await computeSessionUsage(emptyCache(SID), path)).cache.costUSD
}
it('stays silent below the soft cap', async () => {
const base = await tmp()
const path = await transcript([assistantLine('a')])
const c = await costOf(path)
await writeGuardConfig({ ...DEFAULT_GUARD_CONFIG, softUSD: c * 2, hardUSD: c * 4 }, base)
expect(await runGuardHook('pretooluse', hookInput(path), { base })).toBe('')
})
it('warns once on the soft cap, then suppresses the repeat', async () => {
const base = await tmp()
const path = await transcript([assistantLine('a'), assistantLine('b')])
const c = await costOf(path)
await writeGuardConfig({ ...DEFAULT_GUARD_CONFIG, softUSD: c * 0.5, hardUSD: c * 10 }, base)
const first = await runGuardHook('pretooluse', hookInput(path), { base })
expect(JSON.parse(first).systemMessage).toContain('soft cap')
const second = await runGuardHook('pretooluse', hookInput(path), { base })
expect(second).toBe('')
})
it('blocks on the hard cap with a deny decision, and allow lifts it', async () => {
const base = await tmp()
const path = await transcript([assistantLine('a'), assistantLine('b')])
const c = await costOf(path)
await writeGuardConfig({ ...DEFAULT_GUARD_CONFIG, softUSD: c * 0.2, hardUSD: c * 0.5 }, base)
const blocked = JSON.parse(await runGuardHook('pretooluse', hookInput(path), { base }))
expect(blocked.hookSpecificOutput.hookEventName).toBe('PreToolUse')
expect(blocked.hookSpecificOutput.permissionDecision).toBe('deny')
expect(blocked.hookSpecificOutput.permissionDecisionReason).toContain('guard allow')
await writeAllow(SID, base)
expect(await runGuardHook('pretooluse', hookInput(path), { base })).toBe('')
})
it('disables the cap when the threshold is null', async () => {
const base = await tmp()
const path = await transcript([assistantLine('a'), assistantLine('b')])
await writeGuardConfig({ ...DEFAULT_GUARD_CONFIG, softUSD: null, hardUSD: null }, base)
expect(await runGuardHook('pretooluse', hookInput(path), { base })).toBe('')
})
})
describe('yield checkpoint (Stop)', () => {
function stopInput(path: string): string {
return JSON.stringify({ session_id: SID, transcript_path: path, hook_event_name: 'Stop' })
}
async function withCheckpoint(base: string, path: string): Promise<void> {
const c = (await computeSessionUsage(emptyCache(SID), path)).cache.costUSD
await writeGuardConfig({ ...DEFAULT_GUARD_CONFIG, checkpointUSD: c * 0.5 }, base)
}
it('fires once for an expensive no-edit no-commit session', async () => {
const base = await tmp()
const path = await transcript([assistantLine('a'), assistantLine('b')])
await withCheckpoint(base, path)
const first = JSON.parse(await runGuardHook('stop', stopInput(path), { base }))
expect(first.systemMessage).toContain('no edits or commits')
expect(await runGuardHook('stop', stopInput(path), { base })).toBe('') // once per session
})
it('stays silent when cost is below the checkpoint', async () => {
const base = await tmp()
const path = await transcript([assistantLine('a')])
const c = (await computeSessionUsage(emptyCache(SID), path)).cache.costUSD
await writeGuardConfig({ ...DEFAULT_GUARD_CONFIG, checkpointUSD: c * 5 }, base)
expect(await runGuardHook('stop', stopInput(path), { base })).toBe('')
})
it('stays silent when the session made an edit', async () => {
const base = await tmp()
const path = await transcript([assistantLine('a', { tools: [{ name: 'Edit', input: { file_path: '/x' } }] }), assistantLine('b')])
await withCheckpoint(base, path)
expect(await runGuardHook('stop', stopInput(path), { base })).toBe('')
})
it('stays silent when the session ran git commit', async () => {
const base = await tmp()
const path = await transcript([assistantLine('a', { tools: [{ name: 'Bash', input: { command: 'git commit -m wip' } }] }), assistantLine('b')])
await withCheckpoint(base, path)
expect(await runGuardHook('stop', stopInput(path), { base })).toBe('')
})
})
describe('session opener (SessionStart)', () => {
it('emits the matching opener text for a flagged project and nothing when stale', async () => {
const base = await tmp()
const projectPath = '/tmp/flagged-project'
const flags: GuardFlags = { generatedAt: new Date().toISOString(), projects: [{ path: projectPath, openers: ['DELIVERABLE OPENER'] }] }
await writeFlags(flags, base)
const input = JSON.stringify({ session_id: SID, cwd: projectPath, hook_event_name: 'SessionStart', source: 'startup' })
const out = JSON.parse(await runGuardHook('sessionstart', input, { base }))
expect(out.hookSpecificOutput.hookEventName).toBe('SessionStart')
expect(out.hookSpecificOutput.additionalContext).toBe('DELIVERABLE OPENER')
// Unflagged cwd -> nothing.
const other = JSON.stringify({ session_id: SID, cwd: '/tmp/other', hook_event_name: 'SessionStart' })
expect(await runGuardHook('sessionstart', other, { base })).toBe('')
// Stale flag list (> 7 days) -> nothing.
const stale: GuardFlags = { generatedAt: new Date(Date.now() - 8 * 86_400_000).toISOString(), projects: flags.projects }
await writeFlags(stale, base)
expect(await runGuardHook('sessionstart', input, { base })).toBe('')
})
it('builds flags from optimize candidate detectors and matches by project path', async () => {
// A project whose only session has real spend but zero edit turns is a
// low-worth candidate; buildFlags should flag its projectPath.
const projects = [lowWorthProject()]
const flags = await buildFlags(projects as unknown as ProjectSummary[])
expect(flags.projects.length).toBeGreaterThan(0)
expect(matchFlag(flags, '/repo/alpha').length).toBeGreaterThan(0)
expect(matchFlag(flags, '/repo/alpha/src')).toEqual(matchFlag(flags, '/repo/alpha')) // subdir matches
expect(matchFlag(flags, '/repo/beta')).toEqual([])
})
})
describe('fail-open: malformed stdin', () => {
it('every handler exits with empty output on garbage input', async () => {
const base = await tmp()
for (const ev of ['pretooluse', 'sessionstart', 'stop']) {
expect(await runGuardHook(ev, 'not json {', { base })).toBe('')
expect(await runGuardHook(ev, '', { base })).toBe('')
}
expect(await runGuardHook('unknownevent', JSON.stringify({ session_id: SID }), { base })).toBe('')
expect(await runGuardStatusline('not json {', { base })).toBe('')
})
it('handlers stay silent when the transcript path is missing', async () => {
const base = await tmp()
const noPath = JSON.stringify({ session_id: SID, hook_event_name: 'PreToolUse' })
expect(await runGuardHook('pretooluse', noPath, { base })).toBe('')
expect(await runGuardHook('stop', noPath, { base })).toBe('')
})
})
describe('statusline', () => {
it('prints one line with the session cost', async () => {
const base = await tmp()
const path = await transcript([assistantLine('a')])
const out = await runGuardStatusline(JSON.stringify({ session_id: SID, transcript_path: path }), { base })
expect(out.startsWith('codeburn guard $')).toBe(true)
expect(out).not.toContain('\n')
})
})
// A minimal ProjectSummary shaped just enough for findLowWorthCandidates:
// meaningful cost, no delivery command, and no edit turns.
function lowWorthProject(): Record<string, unknown> {
const turn = {
userMessage: 'do a thing',
assistantCalls: [{ costUSD: 40, tools: [], bashCommands: [], timestamp: '2026-07-01T00:00:00Z' }],
timestamp: '2026-07-01T00:00:00Z',
sessionId: 's-alpha',
category: 'exploration',
retries: 0,
hasEdits: false,
}
const session = {
sessionId: 's-alpha',
project: 'alpha',
firstTimestamp: '2026-07-01T00:00:00Z',
lastTimestamp: '2026-07-01T01:00:00Z',
totalCostUSD: 40,
totalSavingsUSD: 0,
totalInputTokens: 100, totalOutputTokens: 100, totalReasoningTokens: 0,
totalCacheReadTokens: 0, totalCacheWriteTokens: 0,
apiCalls: 1,
turns: [turn],
modelBreakdown: {}, toolBreakdown: {}, mcpBreakdown: {}, bashBreakdown: {},
categoryBreakdown: {}, skillBreakdown: {}, subagentBreakdown: {},
}
return {
project: 'alpha',
projectPath: '/repo/alpha',
sessions: [session],
totalCostUSD: 40, totalSavingsUSD: 0, totalApiCalls: 1, totalProxiedCostUSD: 0,
}
}