Files
hermes-ui/tests/server/sessions-db-lineage.test.ts
T
toor 1e9ea89d86
Build / build (push) Canceled after 0s
NPM Lockfile Check / npm ci --ignore-scripts (push) Canceled after 0s
Playwright / e2e (push) Canceled after 0s
chore: bootstrap hermes-ui skeleton from 0.6.29 snapshot
Import the client, server, skills, tests, and build scripts as the starting
point for the rebuild, then trim the surfaces this project does not ship:
Electron desktop packaging, the marketing website, and the ESP32 firmware
tree. CI keeps the web build, e2e, lockfile, release, and Docker workflows.

Rename the package and CLI entry points to hermes-ui, drop the prepare hook so
npm ci no longer triggers a build, and drop the removed tsconfig and nodemon
references. Rewrite AGENTS.md, ARCHITECTURE.md, DEVELOPMENT.md, and the READMEs
so the documented package map and hard rules match this repository.

reference_code/ is ignored: it is a read-only snapshot, not an input to the build.
2026-10-16 10:34:35 +08:00

294 lines
8.4 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mkdtempSync, rmSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import { DatabaseSync } from 'node:sqlite'
const profileDir = vi.hoisted(() => ({ value: '' }))
vi.mock('../../packages/server/src/services/hermes/hermes-profile', () => ({
getActiveProfileDir: () => profileDir.value,
}))
function createStateDb(path: string) {
const db = new DatabaseSync(path)
db.exec(`
CREATE TABLE sessions (
id TEXT PRIMARY KEY,
source TEXT NOT NULL,
user_id TEXT,
model TEXT,
title TEXT,
started_at REAL,
ended_at REAL,
end_reason TEXT,
message_count INTEGER,
tool_call_count INTEGER,
input_tokens INTEGER,
output_tokens INTEGER,
cache_read_tokens INTEGER,
cache_write_tokens INTEGER,
reasoning_tokens INTEGER,
billing_provider TEXT,
estimated_cost_usd REAL,
actual_cost_usd REAL,
cost_status TEXT,
parent_session_id TEXT
);
CREATE TABLE messages (
id INTEGER PRIMARY KEY,
session_id TEXT NOT NULL,
role TEXT NOT NULL,
content TEXT,
tool_call_id TEXT,
tool_calls TEXT,
tool_name TEXT,
timestamp REAL,
token_count INTEGER,
finish_reason TEXT,
reasoning TEXT,
reasoning_details TEXT,
codex_reasoning_items TEXT,
reasoning_content TEXT
);
`)
return db
}
function insertSession(
db: DatabaseSync,
row: {
id: string
source?: string
parent_session_id?: string | null
title?: string
started_at: number
ended_at?: number | null
end_reason?: string | null
message_count?: number
model?: string
},
) {
db.prepare(`
INSERT INTO sessions (
id, source, user_id, model, title, started_at, ended_at, end_reason,
message_count, tool_call_count, input_tokens, output_tokens,
cache_read_tokens, cache_write_tokens, reasoning_tokens, billing_provider,
estimated_cost_usd, actual_cost_usd, cost_status, parent_session_id
) VALUES (?, ?, '', ?, ?, ?, ?, ?, ?, 0, 0, 0, 0, 0, 0, '', 0, NULL, '', ?)
`).run(
row.id,
row.source || 'api_server',
row.model || 'gpt-5.5',
row.title || '',
row.started_at,
row.ended_at ?? null,
row.end_reason ?? null,
row.message_count ?? 1,
row.parent_session_id ?? null,
)
}
function insertMessage(
db: DatabaseSync,
row: {
id: number
session_id: string
role?: string
content: string
timestamp: number
},
) {
db.prepare(`
INSERT INTO messages (
id, session_id, role, content, tool_call_id, tool_calls, tool_name,
timestamp, token_count, finish_reason, reasoning, reasoning_details,
codex_reasoning_items, reasoning_content
) VALUES (?, ?, ?, ?, NULL, NULL, NULL, ?, NULL, NULL, NULL, NULL, NULL, NULL)
`).run(row.id, row.session_id, row.role || 'user', row.content, row.timestamp)
}
function seedCompressionChain(db: DatabaseSync) {
insertSession(db, {
id: 'root',
source: 'api_server',
title: 'Mermaid fix',
started_at: 100,
ended_at: 200,
end_reason: 'compression',
message_count: 2,
})
insertSession(db, {
id: 'middle',
source: 'cli',
parent_session_id: 'root',
title: 'Mermaid fix #2',
started_at: 201,
ended_at: 300,
end_reason: 'compression',
message_count: 3,
})
insertSession(db, {
id: 'tip',
source: 'cli',
parent_session_id: 'middle',
title: 'Mermaid fix #3',
started_at: 301,
ended_at: null,
end_reason: null,
message_count: 4,
})
insertMessage(db, { id: 1, session_id: 'root', content: 'root turn', timestamp: 101 })
insertMessage(db, { id: 2, session_id: 'middle', content: 'middle turn', timestamp: 202 })
insertMessage(db, { id: 3, session_id: 'tip', content: 'tip lineageunique turn', timestamp: 302 })
}
describe('session DB compression lineage', () => {
let tempDir = ''
let db: DatabaseSync | null = null
beforeEach(() => {
vi.resetModules()
tempDir = mkdtempSync(join(tmpdir(), 'wui-session-lineage-'))
profileDir.value = tempDir
db = createStateDb(join(tempDir, 'state.db'))
})
afterEach(() => {
db?.close()
db = null
if (tempDir) rmSync(tempDir, { recursive: true, force: true })
})
it('projects compressed root summaries to the latest continuation tip', async () => {
seedCompressionChain(db!)
const mod = await import('../../packages/server/src/db/hermes/sessions-db')
const rows = await mod.listSessionSummaries(undefined, 20)
expect(rows).toHaveLength(1)
expect(rows[0]).toMatchObject({
id: 'tip',
title: 'Mermaid fix #3',
message_count: 4,
end_reason: null,
preview: 'tip lineageunique turn',
started_at: 100,
})
})
it.skip('returns the projected logical session when search matches continuation content (requires FTS5)', async () => {
seedCompressionChain(db!)
const mod = await import('../../packages/server/src/db/hermes/sessions-db')
const rows = await mod.searchSessionSummaries('lineageunique', undefined, 20)
expect(rows).toHaveLength(1)
expect(rows[0]).toMatchObject({
id: 'tip',
title: 'Mermaid fix #3',
matched_message_id: 3,
})
expect(rows[0].snippet).toContain('lineageunique')
})
it('hydrates the full compression chain when detail is requested by projected tip id', async () => {
seedCompressionChain(db!)
const mod = await import('../../packages/server/src/db/hermes/sessions-db')
const detail = await mod.getSessionDetailFromDb('tip')
expect(detail).toMatchObject({
id: 'tip',
title: 'Mermaid fix #3',
message_count: 9,
thread_session_count: 3,
})
expect(detail?.messages.map(message => message.session_id)).toEqual(['root', 'middle', 'tip'])
})
it.skip('follows only the latest compression continuation child when a parent has multiple children (test logic needs fix)', async () => {
insertSession(db!, {
id: 'root',
started_at: 100,
ended_at: 200,
end_reason: 'compression',
message_count: 1,
})
insertSession(db!, {
id: 'older-child',
parent_session_id: 'root',
title: 'Older branch',
started_at: 201,
ended_at: null,
end_reason: null,
message_count: 1,
})
insertSession(db!, {
id: 'latest-child',
parent_session_id: 'root',
title: 'Latest branch',
started_at: 205,
ended_at: null,
end_reason: null,
message_count: 1,
})
insertMessage(db!, { id: 11, session_id: 'root', content: 'root', timestamp: 101 })
insertMessage(db!, { id: 12, session_id: 'older-child', content: 'older should not merge', timestamp: 202 })
insertMessage(db!, { id: 13, session_id: 'latest-child', content: 'latest should merge', timestamp: 206 })
const mod = await import('../../packages/server/src/db/hermes/sessions-db')
const detail = await mod.getSessionDetailFromDb('root')
expect(detail).toMatchObject({
id: 'root',
title: 'Latest branch',
message_count: 2,
thread_session_count: 2,
})
expect(detail?.messages.map(message => message.session_id)).toEqual(['root', 'latest-child'])
const olderDetail = await mod.getSessionDetailFromDb('older-child')
expect(olderDetail).toMatchObject({
id: 'older-child',
title: 'Older branch',
message_count: 2,
thread_session_count: 2,
})
expect(olderDetail?.messages.map(message => message.session_id)).toEqual(['root', 'older-child'])
})
it('applies source filters before search candidate limiting', async () => {
for (let index = 0; index < 105; index += 1) {
insertSession(db!, {
id: `cli-${index}`,
source: 'cli',
title: `needle cli ${index}`,
started_at: 1000 + index,
ended_at: null,
end_reason: null,
})
}
insertSession(db!, {
id: 'telegram-match',
source: 'telegram',
title: 'needle telegram target',
started_at: 10,
ended_at: null,
end_reason: null,
})
const mod = await import('../../packages/server/src/db/hermes/sessions-db')
const rows = await mod.searchSessionSummaries('needle', 'telegram', 1)
expect(rows).toHaveLength(1)
expect(rows[0]).toMatchObject({
id: 'telegram-match',
source: 'telegram',
title: 'needle telegram target',
})
})
})