codeburn/tests/providers/hermes.test.ts
ozymandiashh b7235adb16 fix(parser): fold SQLite -wal siblings into source fingerprints
Hermes, Cursor, OpenCode and copilot OTel sources all live in SQLite
databases that their agents keep open in WAL mode for the life of the
process. Committed writes park in <db>-wal until a checkpoint, so the
main file's stat can sit hours or days behind the newest committed data.

fingerprintFile only statted the main file, which broke two ways:

- The date-range mtime pre-filter in parseProviderSources read the stale
  mtime as "nothing in range" and skipped the source entirely. Every
  Hermes session committed after the last checkpoint vanished from
  reports: the today-parse skipped the db (mtime < local midnight) while
  the backfill only keeps days through yesterday. Exactly the "17
  sessions in the DB, 14 reported, the 3 from today missing" report in
  issue #913.

- reconcileFile saw an unchanged fingerprint between checkpoints and kept
  serving stale cached turns for sessions that had since grown.

Fold the -wal sibling into the fingerprint: newest mtime wins and sizes
add, so both WAL growth and a checkpoint (db grows, wal truncates) move
the fingerprint. -shm is deliberately ignored (it mutates on reads).
Bare SQLite paths get the fold only when the extension says database, so
JSONL transcript fingerprints (offset-based append detection) are
untouched.

Refs #913
2026-08-04 14:38:43 +03:00

587 lines
22 KiB
TypeScript

import { mkdir, mkdtemp, rm, utimes, writeFile } from 'fs/promises'
import { basename, dirname, join } from 'path'
import { tmpdir } from 'os'
import { createRequire } from 'node:module'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { calculateCost } from '../../src/models.js'
import { createHermesProvider } from '../../src/providers/hermes.js'
import { isSqliteAvailable } from '../../src/sqlite.js'
import type { ParsedProviderCall } from '../../src/providers/types.js'
import type { DateRange } from '../../src/types.js'
const requireForTest = createRequire(import.meta.url)
type TestDb = {
exec(sql: string): void
prepare(sql: string): { run(...params: unknown[]): void }
close(): void
}
let tmpDir: string
let cacheDir: string
let originalHermesHome: string | undefined
let originalCodeburnCacheDir: string | undefined
beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), 'hermes-provider-test-'))
cacheDir = await mkdtemp(join(tmpdir(), 'hermes-provider-cache-'))
originalHermesHome = process.env['HERMES_HOME']
originalCodeburnCacheDir = process.env['CODEBURN_CACHE_DIR']
})
afterEach(async () => {
if (originalHermesHome === undefined) delete process.env['HERMES_HOME']
else process.env['HERMES_HOME'] = originalHermesHome
if (originalCodeburnCacheDir === undefined) delete process.env['CODEBURN_CACHE_DIR']
else process.env['CODEBURN_CACHE_DIR'] = originalCodeburnCacheDir
await rm(tmpDir, { recursive: true, force: true })
await rm(cacheDir, { recursive: true, force: true })
})
function createHermesDb(homeDir: string): string {
const { DatabaseSync: Database } = requireForTest('node:sqlite')
const dbPath = join(homeDir, 'state.db')
const db = new Database(dbPath)
db.exec(`
CREATE TABLE sessions (
id TEXT PRIMARY KEY,
source TEXT,
model TEXT,
cwd TEXT,
billing_provider TEXT,
billing_base_url TEXT,
billing_mode TEXT,
input_tokens INTEGER DEFAULT 0,
output_tokens INTEGER DEFAULT 0,
cache_read_tokens INTEGER DEFAULT 0,
cache_write_tokens INTEGER DEFAULT 0,
reasoning_tokens INTEGER DEFAULT 0,
estimated_cost_usd REAL,
actual_cost_usd REAL,
cost_status TEXT,
api_call_count INTEGER DEFAULT 0,
message_count INTEGER DEFAULT 0,
tool_call_count INTEGER DEFAULT 0,
started_at REAL,
ended_at REAL,
title TEXT
)
`)
db.exec(`
CREATE TABLE messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL,
role TEXT NOT NULL,
content TEXT,
tool_call_id TEXT,
tool_calls TEXT,
tool_name TEXT,
timestamp REAL NOT NULL
)
`)
db.close()
return dbPath
}
function createLegacyHermesDb(homeDir: string): string {
const { DatabaseSync: Database } = requireForTest('node:sqlite')
const dbPath = join(homeDir, 'state.db')
const db = new Database(dbPath)
db.exec(`
CREATE TABLE sessions (
id TEXT PRIMARY KEY,
model TEXT,
input_tokens INTEGER DEFAULT 0,
output_tokens INTEGER DEFAULT 0,
started_at REAL
)
`)
db.exec(`
CREATE TABLE messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL,
role TEXT NOT NULL,
content TEXT,
tool_calls TEXT,
timestamp REAL NOT NULL
)
`)
db.close()
return dbPath
}
async function createProfileHermesDb(hermesHome: string, profile: string): Promise<string> {
const profileDir = join(hermesHome, 'profiles', profile)
await mkdir(profileDir, { recursive: true })
return createHermesDb(profileDir)
}
function insertSession(db: TestDb, values: {
id: string
source?: string
model?: string
cwd?: string | null
billingProvider?: string
inputTokens: number
outputTokens: number
cacheReadTokens: number
cacheWriteTokens: number
reasoningTokens: number
estimatedCost?: number | null
actualCost?: number | null
apiCalls?: number
toolCalls?: number
startedAt: number
title?: string
}): void {
db.prepare(
`INSERT INTO sessions (
id, source, model, cwd, billing_provider, input_tokens, output_tokens,
cache_read_tokens, cache_write_tokens, reasoning_tokens, estimated_cost_usd,
actual_cost_usd, api_call_count, tool_call_count, started_at, title
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
).run(
values.id,
values.source ?? 'cli',
values.model ?? 'gpt-5.5',
values.cwd ?? null,
values.billingProvider ?? 'openai-codex',
values.inputTokens,
values.outputTokens,
values.cacheReadTokens,
values.cacheWriteTokens,
values.reasoningTokens,
values.estimatedCost ?? null,
values.actualCost ?? null,
values.apiCalls ?? 1,
values.toolCalls ?? 0,
values.startedAt,
values.title ?? values.id,
)
}
function withTestDb(dbPath: string, fn: (db: TestDb) => void): void {
const { DatabaseSync: Database } = requireForTest('node:sqlite')
const db = new Database(dbPath)
try {
fn(db)
} finally {
db.close()
}
}
function dayRange(): DateRange {
return {
start: new Date('2026-05-23T00:00:00.000Z'),
end: new Date('2026-05-23T23:59:59.999Z'),
}
}
async function loadParserWithHermesHome(hermesHome: string, codeburnCacheDir: string) {
process.env['HERMES_HOME'] = hermesHome
process.env['CODEBURN_CACHE_DIR'] = codeburnCacheDir
vi.resetModules()
const parser = await import('../../src/parser.js')
return parser
}
async function collectCalls(hermesHome: string, sourcePath: string): Promise<ParsedProviderCall[]> {
const provider = createHermesProvider(hermesHome)
const calls: ParsedProviderCall[] = []
for await (const call of provider.createSessionParser({ path: sourcePath, project: 'hermes', provider: 'hermes' }, new Set()).parse()) {
calls.push(call)
}
return calls
}
const skipUnlessSqlite = isSqliteAvailable() ? describe : describe.skip
skipUnlessSqlite('hermes provider', () => {
it('discovers state.db sessions with token usage', async () => {
const dbPath = createHermesDb(tmpDir)
withTestDb(dbPath, (db) => {
insertSession(db, {
id: 'session-1',
inputTokens: 100,
outputTokens: 20,
cacheReadTokens: 50,
cacheWriteTokens: 0,
reasoningTokens: 5,
startedAt: 1779549200,
title: 'Test Project',
})
db.prepare(
`INSERT INTO sessions (id, source, model, input_tokens, output_tokens, started_at, title)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
).run('empty', 'cli', 'gpt-5.5', 0, 0, 1779549300, 'Empty')
})
const provider = createHermesProvider(tmpDir)
const sessions = await provider.discoverSessions()
expect(sessions).toHaveLength(1)
expect(sessions[0]!.provider).toBe('hermes')
expect(sessions[0]!.path).toBe(`${dbPath}#hermes-session=session-1`)
expect(sessions[0]!.project).toBe('default')
})
it('parses session-level token usage and tool calls from messages', async () => {
const dbPath = createHermesDb(tmpDir)
withTestDb(dbPath, (db) => {
insertSession(db, {
id: 'session-1',
source: 'tui',
inputTokens: 1000,
outputTokens: 200,
cacheReadTokens: 300,
cacheWriteTokens: 40,
reasoningTokens: 25,
estimatedCost: 0.12,
apiCalls: 3,
toolCalls: 2,
startedAt: 1779549200,
title: 'Provider Work',
})
db.prepare('INSERT INTO messages (session_id, role, content, timestamp) VALUES (?, ?, ?, ?)')
.run('session-1', 'user', 'Add Hermes support', 1779549201)
db.prepare('INSERT INTO messages (session_id, role, content, tool_calls, timestamp) VALUES (?, ?, ?, ?, ?)')
.run(
'session-1',
'assistant',
'',
JSON.stringify([
{ function: { name: 'read_file', arguments: JSON.stringify({ path: '/tmp/file.ts' }) } },
{ function: { name: 'terminal', arguments: JSON.stringify({ command: 'npm test' }) } },
]),
1779549202,
)
})
const calls = await collectCalls(tmpDir, `${dbPath}#hermes-session=session-1`)
expect(calls).toHaveLength(1)
expect(calls[0]!).toMatchObject({
provider: 'hermes',
model: 'gpt-5.5',
inputTokens: 1000,
outputTokens: 200,
cacheReadInputTokens: 300,
cacheCreationInputTokens: 40,
cachedInputTokens: 300,
reasoningTokens: 25,
costUSD: 0.12,
userMessage: 'Add Hermes support',
sessionId: 'session-1',
deduplicationKey: 'hermes:default:session-1',
})
expect(calls[0]!.tools).toEqual(['Read', 'Bash'])
expect(calls[0]!.bashCommands).toEqual(['npm test'])
expect(calls[0]!.toolSequence).toEqual([
[{ tool: 'Read', file: '/tmp/file.ts' }, { tool: 'Bash', command: 'npm test' }],
])
})
it('maps composio MCP tools before generic MCP prefixes', () => {
const provider = createHermesProvider(tmpDir)
expect(provider.toolDisplayName('mcp_composio_GMAIL_SEND_EMAIL')).toBe('MCP')
expect(provider.toolDisplayName('mcp__github__create_issue')).toBe('mcp__github__create_issue')
})
it('falls back to calculateCost when no actual or estimated cost is recorded', async () => {
const dbPath = createHermesDb(tmpDir)
withTestDb(dbPath, (db) => {
insertSession(db, {
id: 'no-cost-session',
model: 'claude-sonnet-4-20250514',
inputTokens: 1000,
outputTokens: 200,
cacheReadTokens: 0,
cacheWriteTokens: 0,
reasoningTokens: 50,
estimatedCost: null,
actualCost: null,
startedAt: 1779549200,
})
db.prepare('INSERT INTO messages (session_id, role, content, timestamp) VALUES (?, ?, ?, ?)')
.run('no-cost-session', 'user', 'Test calculateCost fallback', 1779549201)
})
const calls = await collectCalls(tmpDir, `${dbPath}#hermes-session=no-cost-session`)
expect(calls).toHaveLength(1)
expect(calls[0]!.costUSD).toBe(calculateCost('claude-sonnet-4-20250514', 1000, 250, 0, 0, 0))
expect(calls[0]!.reasoningTokens).toBe(50)
})
it('does not split multibyte characters when truncating the first user message', async () => {
const dbPath = createHermesDb(tmpDir)
const message = `${'a'.repeat(499)}😀truncated tail`
withTestDb(dbPath, (db) => {
insertSession(db, {
id: 'emoji-session',
inputTokens: 10,
outputTokens: 5,
cacheReadTokens: 0,
cacheWriteTokens: 0,
reasoningTokens: 0,
estimatedCost: 0.01,
startedAt: 1779549200,
})
db.prepare('INSERT INTO messages (session_id, role, content, timestamp) VALUES (?, ?, ?, ?)')
.run('emoji-session', 'user', message, 1779549201)
})
const calls = await collectCalls(tmpDir, `${dbPath}#hermes-session=emoji-session`)
expect(calls).toHaveLength(1)
expect(calls[0]!.userMessage).toBe(`${'a'.repeat(499)}😀`)
})
it('parses legacy databases that predate optional accounting columns', async () => {
const dbPath = createLegacyHermesDb(tmpDir)
withTestDb(dbPath, (db) => {
db.prepare(
`INSERT INTO sessions (id, model, input_tokens, output_tokens, started_at)
VALUES (?, ?, ?, ?, ?)`,
).run('legacy-session', 'gpt-5.5', 12, 34, 1779549200)
db.prepare('INSERT INTO messages (session_id, role, content, timestamp) VALUES (?, ?, ?, ?)')
.run('legacy-session', 'user', 'Legacy Hermes DB', 1779549201)
})
const provider = createHermesProvider(tmpDir)
const discovered = await provider.discoverSessions()
expect(discovered.map(s => s.path)).toEqual([`${dbPath}#hermes-session=legacy-session`])
const calls = await collectCalls(tmpDir, `${dbPath}#hermes-session=legacy-session`)
expect(calls).toHaveLength(1)
expect(calls[0]).toMatchObject({
inputTokens: 12,
outputTokens: 34,
cacheReadInputTokens: 0,
cacheCreationInputTokens: 0,
reasoningTokens: 0,
userMessage: 'Legacy Hermes DB',
})
})
it('discovers root and profile databases and preserves Hermes DB accounting through parser aggregation', async () => {
const rootDbPath = createHermesDb(tmpDir)
const profileDbPath = await createProfileHermesDb(tmpDir, 'coder')
withTestDb(rootDbPath, (db) => {
insertSession(db, {
id: 'root-session',
model: 'gpt-5.5',
inputTokens: 100,
outputTokens: 20,
cacheReadTokens: 30,
cacheWriteTokens: 40,
reasoningTokens: 5,
estimatedCost: 0.25,
actualCost: 0.30,
startedAt: 1779494400,
title: 'Root session',
})
db.prepare('INSERT INTO messages (session_id, role, content, timestamp) VALUES (?, ?, ?, ?)')
.run('root-session', 'user', 'Current working directory: /tmp/root-project\nImplement root support', 1779494401)
})
withTestDb(profileDbPath, (db) => {
insertSession(db, {
id: 'profile-session',
model: 'gpt-5.5',
inputTokens: 200,
outputTokens: 70,
cacheReadTokens: 11,
cacheWriteTokens: 13,
reasoningTokens: 17,
estimatedCost: 0.42,
actualCost: null,
startedAt: 1779501600,
title: 'Profile session',
})
db.prepare('INSERT INTO messages (session_id, role, content, timestamp) VALUES (?, ?, ?, ?)')
.run('profile-session', 'user', 'Current working directory: /tmp/profile-project\nImplement profile support', 1779501601)
})
const provider = createHermesProvider(tmpDir)
const discovered = await provider.discoverSessions()
expect(discovered.map(s => s.path).sort()).toEqual([
`${profileDbPath}#hermes-session=profile-session`,
`${rootDbPath}#hermes-session=root-session`,
].sort())
expect(discovered.map(s => s.project).sort()).toEqual(['coder', 'default'])
const rootCalls = await collectCalls(tmpDir, `${rootDbPath}#hermes-session=root-session`)
const profileCalls = await collectCalls(tmpDir, `${profileDbPath}#hermes-session=profile-session`)
expect(rootCalls[0]).toMatchObject({ inputTokens: 100, outputTokens: 20, cacheReadInputTokens: 30, cacheCreationInputTokens: 40, reasoningTokens: 5, costUSD: 0.30 })
expect(profileCalls[0]).toMatchObject({ inputTokens: 200, outputTokens: 70, cacheReadInputTokens: 11, cacheCreationInputTokens: 13, reasoningTokens: 17, costUSD: 0.42 })
const { clearSessionCache, parseAllSessions } = await loadParserWithHermesHome(tmpDir, cacheDir)
clearSessionCache()
const projects = await parseAllSessions(dayRange(), 'hermes')
const sessions = projects.flatMap(project => project.sessions)
expect(sessions).toHaveLength(2)
expect(sessions.reduce((sum, session) => sum + session.totalInputTokens, 0)).toBe(300)
expect(sessions.reduce((sum, session) => sum + session.totalOutputTokens, 0)).toBe(90)
expect(sessions.reduce((sum, session) => sum + session.totalReasoningTokens, 0)).toBe(22)
expect(sessions.reduce((sum, session) => sum + session.totalCacheReadTokens, 0)).toBe(41)
expect(sessions.reduce((sum, session) => sum + session.totalCacheWriteTokens, 0)).toBe(53)
expect(sessions.reduce((sum, session) => sum + session.totalCostUSD, 0)).toBeCloseTo(0.72)
expect(projects.map(project => project.project).sort()).toEqual(['tmp-profile-project', 'tmp-root-project'])
const modelTokens = sessions.flatMap(session => Object.values(session.modelBreakdown).map(model => model.tokens))
expect(modelTokens.reduce((sum, tokens) => sum + tokens.outputTokens, 0)).toBe(90)
expect(modelTokens.reduce((sum, tokens) => sum + tokens.reasoningTokens, 0)).toBe(22)
})
// Regression for issue #913: Hermes writes state.db in WAL mode and keeps
// the writer connection open for the life of the agent, so recent sessions
// live in state.db-wal while the main file's mtime stays at the last
// checkpoint. The date-range mtime pre-filter in parseProviderSources
// then reads the source as "older than the range" and skips it, and every
// session committed since the last checkpoint disappears from reports.
// The fingerprint must fold the -wal sibling in so a stale main-file stat
// cannot hide fresh sessions.
it('still parses sessions committed to the WAL when the main db stat is checkpoint-stale', async () => {
const dbPath = createHermesDb(tmpDir)
withTestDb(dbPath, (db) => {
insertSession(db, {
id: 'wal-session',
inputTokens: 100,
outputTokens: 20,
cacheReadTokens: 0,
cacheWriteTokens: 0,
reasoningTokens: 0,
startedAt: 1779549200,
title: 'WAL Session',
})
db.prepare('INSERT INTO messages (session_id, role, content, timestamp) VALUES (?, ?, ?, ?)')
.run('wal-session', 'user', 'Session committed after the last checkpoint', 1779549201)
})
// Simulate the WAL-mode stat shape: the main file's mtime predates the
// requested range (last checkpoint days ago), while a fresh -wal sibling
// holds the recent commits. The db itself was written in rollback-journal
// mode, so SQLite ignores the stray -wal on open; only its stat matters.
const beforeRange = new Date('2026-05-20T00:00:00.000Z')
await utimes(dbPath, beforeRange, beforeRange)
await writeFile(`${dbPath}-wal`, 'wal-frames')
const { clearSessionCache, parseAllSessions } = await loadParserWithHermesHome(tmpDir, cacheDir)
clearSessionCache()
const projects = await parseAllSessions(dayRange(), 'hermes')
const sessions = projects.flatMap(project => project.sessions)
expect(sessions).toHaveLength(1)
expect(sessions[0]!.totalInputTokens).toBe(100)
})
it('treats sibling profile-like directories as default sessions', async () => {
const profileLikeDir = join(dirname(tmpDir), `${basename(tmpDir)}-profiles_backup`, 'coder')
await mkdir(profileLikeDir, { recursive: true })
const dbPath = createHermesDb(profileLikeDir)
withTestDb(dbPath, (db) => {
insertSession(db, {
id: 'sibling-session',
inputTokens: 10,
outputTokens: 5,
cacheReadTokens: 0,
cacheWriteTokens: 0,
reasoningTokens: 0,
startedAt: 1779549200,
})
db.prepare('INSERT INTO messages (session_id, role, content, timestamp) VALUES (?, ?, ?, ?)')
.run('sibling-session', 'user', 'Sibling profile-like directory', 1779549201)
})
const calls = await collectCalls(tmpDir, `${dbPath}#hermes-session=sibling-session`)
expect(calls[0]).toMatchObject({
deduplicationKey: 'hermes:default:sibling-session',
project: 'default',
})
})
it('infers projects from Windows current working directory messages', async () => {
const dbPath = createHermesDb(tmpDir)
withTestDb(dbPath, (db) => {
insertSession(db, {
id: 'windows-cwd-session',
inputTokens: 10,
outputTokens: 5,
cacheReadTokens: 0,
cacheWriteTokens: 0,
reasoningTokens: 0,
startedAt: 1779549200,
})
db.prepare('INSERT INTO messages (session_id, role, content, timestamp) VALUES (?, ?, ?, ?)')
.run('windows-cwd-session', 'user', 'Current working directory: C:\\AI_LAB\\OPENCLAW\nAdd Windows path support', 1779549201)
})
const calls = await collectCalls(tmpDir, `${dbPath}#hermes-session=windows-cwd-session`)
expect(calls[0]).toMatchObject({
project: 'C--AI_LAB-OPENCLAW',
projectPath: 'C:\\AI_LAB\\OPENCLAW',
})
})
it('groups by the sessions.cwd column when present, ahead of message scraping', async () => {
const dbPath = createHermesDb(tmpDir)
withTestDb(dbPath, (db) => {
insertSession(db, {
id: 'cwd-session',
cwd: '/Users/me/projects/codeburn',
inputTokens: 30,
outputTokens: 10,
cacheReadTokens: 0,
cacheWriteTokens: 0,
reasoningTokens: 0,
startedAt: 1779549200,
})
db.prepare('INSERT INTO messages (session_id, role, content, timestamp) VALUES (?, ?, ?, ?)')
.run('cwd-session', 'user', 'Current working directory: /tmp/decoy\nbuild it', 1779549201)
})
const calls = await collectCalls(tmpDir, `${dbPath}#hermes-session=cwd-session`)
expect(calls[0]).toMatchObject({
project: 'Users-me-projects-codeburn',
projectPath: '/Users/me/projects/codeburn',
})
})
it('flags estimated cost only when Hermes recorded none', async () => {
const dbPath = createHermesDb(tmpDir)
withTestDb(dbPath, (db) => {
insertSession(db, {
id: 'no-cost',
inputTokens: 100, outputTokens: 20, cacheReadTokens: 0, cacheWriteTokens: 0, reasoningTokens: 0,
startedAt: 1779549200,
})
insertSession(db, {
id: 'recorded-cost',
actualCost: 1.23,
inputTokens: 100, outputTokens: 20, cacheReadTokens: 0, cacheWriteTokens: 0, reasoningTokens: 0,
startedAt: 1779549300,
})
})
const noCost = await collectCalls(tmpDir, `${dbPath}#hermes-session=no-cost`)
expect(noCost[0]!.costIsEstimated).toBe(true)
const recorded = await collectCalls(tmpDir, `${dbPath}#hermes-session=recorded-cost`)
expect(recorded[0]).toMatchObject({ costUSD: 1.23, costIsEstimated: false })
})
it('counts tool-result messages by their tool_name', async () => {
const dbPath = createHermesDb(tmpDir)
withTestDb(dbPath, (db) => {
insertSession(db, {
id: 'tool-result-session',
inputTokens: 10, outputTokens: 5, cacheReadTokens: 0, cacheWriteTokens: 0, reasoningTokens: 0,
startedAt: 1779549200,
})
db.prepare('INSERT INTO messages (session_id, role, content, tool_name, timestamp) VALUES (?, ?, ?, ?, ?)')
.run('tool-result-session', 'tool', null, 'read_file', 1779549201)
})
const calls = await collectCalls(tmpDir, `${dbPath}#hermes-session=tool-result-session`)
expect(calls[0]!.tools).toContain('Read')
})
})