Files
hermes-web-ui/tests/server/db-index.test.ts
ekko 6f69c69802 feat: add token usage tracking, context display, and dynamic context length (#132)
* fix: specify TS_NODE_PROJECT for dev:server script

ts-node/register resolves tsconfig from the entry file upward,
finding the root solution-style tsconfig.json (no compilerOptions).
This causes target to default to ES3, breaking MapIterator spread
syntax (TS2802). Set TS_NODE_PROJECT env var to point to the server
tsconfig which targets ES2024.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add token usage tracking, context display, and dynamic context length

- Intercept SSE proxy to capture run.completed events and persist token
  usage (input_tokens, output_tokens) per session to SQLite/JSON store
- Display context usage bar in ChatInput showing used/total/remaining tokens
- Resolve actual context length from Hermes models_dev_cache.json based
  on the active profile's default model (fallback 200K), with 5min in-memory cache
- Move sessions-db.ts to db/hermes/ for unified database layer
- Add usage store with SQLite + JSON fallback (auto-migration via ensureTable)
- Fix proxy SSE path regex to match rewritten upstream path
- Fix route ordering: /sessions/usage before /sessions/:id to avoid 404
- Fetch per-session usage on session enter instead of batch
- Add unit tests for usage-store, db index, and proxy SSE interception

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-22 16:14:50 +08:00

117 lines
4.0 KiB
TypeScript

import { describe, it, expect, vi } from 'vitest'
// Force JSON fallback by mocking isSqliteAvailable
vi.mock('../../packages/server/src/db/index', async (importOriginal) => {
const actual = await importOriginal() as any
return {
...actual,
isSqliteAvailable: () => false,
getDb: () => null,
}
})
import {
jsonGet,
jsonSet,
jsonGetAll,
jsonDelete,
} from '../../packages/server/src/db/index'
describe('JSON fallback store', () => {
it('jsonSet and jsonGet round-trip', () => {
expect(typeof jsonSet).toBe('function')
expect(typeof jsonGet).toBe('function')
expect(typeof jsonGetAll).toBe('function')
expect(typeof jsonDelete).toBe('function')
})
})
// Test ensureTable with a real in-memory SQLite (Node 22+)
describe('SQLite ensureTable', () => {
it('creates table with correct columns and handles migration', () => {
// This test requires Node 22.5+ for node:sqlite
const nodeVersion = process.versions.node.split('.').map(Number)
const isAvailable = nodeVersion[0] > 22 || (nodeVersion[0] === 22 && nodeVersion[1] >= 5)
if (!isAvailable) {
console.log('Skipping SQLite test — Node < 22.5')
return
}
const { DatabaseSync } = require('node:sqlite')
const db = new DatabaseSync(':memory:')
// Simulate ensureTable logic
function ensureTable(tableName: string, schema: Record<string, string>): void {
const colDefs = Object.entries(schema)
.map(([col, def]) => `"${col}" ${def}`)
.join(', ')
db.exec(`CREATE TABLE IF NOT EXISTS "${tableName}" (${colDefs})`)
const rows = db.prepare(`PRAGMA table_info("${tableName}")`).all() as Array<{ name: string }>
const existingCols = new Set(rows.map(r => r.name))
const expectedCols = new Set(Object.keys(schema))
for (const col of expectedCols) {
if (!existingCols.has(col)) {
db.exec(`ALTER TABLE "${tableName}" ADD COLUMN "${col}" ${schema[col]}`)
}
}
for (const col of existingCols) {
if (!expectedCols.has(col)) {
db.exec(`ALTER TABLE "${tableName}" DROP COLUMN "${col}"`)
}
}
}
// Initial schema
const schema: Record<string, string> = {
session_id: 'TEXT PRIMARY KEY',
input_tokens: 'INTEGER NOT NULL DEFAULT 0',
output_tokens: 'INTEGER NOT NULL DEFAULT 0',
updated_at: 'INTEGER NOT NULL',
}
ensureTable('session_usage', schema)
// Verify columns
const cols = db.prepare(`PRAGMA table_info("session_usage")`).all() as Array<{ name: string }>
const colNames = cols.map(c => c.name)
expect(colNames).toContain('session_id')
expect(colNames).toContain('input_tokens')
expect(colNames).toContain('output_tokens')
expect(colNames).toContain('updated_at')
// Add a column
schema['cost_usd'] = 'REAL DEFAULT 0'
ensureTable('session_usage', schema)
const cols2 = db.prepare(`PRAGMA table_info("session_usage")`).all() as Array<{ name: string }>
const colNames2 = cols2.map(c => c.name)
expect(colNames2).toContain('cost_usd')
// Remove a column
delete schema['cost_usd']
ensureTable('session_usage', schema)
const cols3 = db.prepare(`PRAGMA table_info("session_usage")`).all() as Array<{ name: string }>
const colNames3 = cols3.map(c => c.name)
expect(colNames3).not.toContain('cost_usd')
// Verify INSERT works
db.prepare(
`INSERT INTO session_usage (session_id, input_tokens, output_tokens, updated_at)
VALUES (?, ?, ?, ?)`,
).run('test-session', 100, 50, Date.now())
const row = db.prepare('SELECT * FROM session_usage WHERE session_id = ?').get('test-session') as any
expect(row.session_id).toBe('test-session')
expect(row.input_tokens).toBe(100)
expect(row.output_tokens).toBe(50)
// Verify DELETE works
db.prepare('DELETE FROM session_usage WHERE session_id = ?').run('test-session')
const deleted = db.prepare('SELECT * FROM session_usage WHERE session_id = ?').get('test-session')
expect(deleted).toBeUndefined()
db.close()
})
})