Files
hermes-ui/tests/server/conversations-service.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

264 lines
8.3 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest'
const exportSessionsRawMock = vi.fn()
vi.mock('../../packages/server/src/services/hermes/hermes-cli', () => ({
exportSessionsRaw: exportSessionsRawMock,
}))
describe('conversations service', () => {
beforeEach(() => {
vi.resetModules()
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-04-20T00:00:00Z'))
exportSessionsRawMock.mockReset()
})
it('aggregates a single compression continuation even when the child preview differs', async () => {
exportSessionsRawMock.mockResolvedValue([
{
id: 'root',
parent_session_id: null,
source: 'cli',
model: 'openai/gpt-5.4',
title: null,
started_at: 100,
ended_at: 110,
end_reason: 'compression',
message_count: 2,
tool_call_count: 0,
input_tokens: 5,
output_tokens: 8,
cache_read_tokens: 0,
cache_write_tokens: 0,
reasoning_tokens: 0,
billing_provider: 'openai',
estimated_cost_usd: 0.1,
actual_cost_usd: 0.1,
cost_status: 'estimated',
messages: [
{ id: 1, session_id: 'root', role: 'user', content: 'Start here', timestamp: 101 },
{ id: 2, session_id: 'root', role: 'assistant', content: 'Assistant reply', timestamp: 102 },
],
},
{
id: 'root-cont',
parent_session_id: 'root',
source: 'cli',
model: 'openai/gpt-5.4',
title: 'Continuation',
started_at: 110,
ended_at: 111,
end_reason: null,
message_count: 2,
tool_call_count: 0,
input_tokens: 3,
output_tokens: 4,
cache_read_tokens: 0,
cache_write_tokens: 0,
reasoning_tokens: 0,
billing_provider: 'openai',
estimated_cost_usd: 0.2,
actual_cost_usd: 0.2,
cost_status: 'final',
messages: [
{ id: 3, session_id: 'root-cont', role: 'user', content: 'Continue with more detail', timestamp: 110 },
{ id: 4, session_id: 'root-cont', role: 'assistant', content: 'Continued answer', timestamp: 111 },
],
},
])
const mod = await import('../../packages/server/src/services/hermes/conversations')
const summaries = await mod.listConversationSummaries({ humanOnly: true })
expect(summaries).toHaveLength(1)
expect(summaries[0]).toEqual(
expect.objectContaining({
id: 'root',
thread_session_count: 2,
ended_at: 111,
cost_status: 'mixed',
actual_cost_usd: 0.30000000000000004,
}),
)
const detail = await mod.getConversationDetail('root', { humanOnly: true })
expect(detail?.thread_session_count).toBe(2)
expect(detail?.messages.map((message: any) => message.content)).toEqual([
'Start here',
'Assistant reply',
'Continue with more detail',
'Continued answer',
])
})
it('treats branched children as their own visible conversations', async () => {
exportSessionsRawMock.mockResolvedValue([
{
id: 'root',
parent_session_id: null,
source: 'cli',
model: 'openai/gpt-5.4',
title: 'Root',
started_at: 100,
ended_at: 200,
end_reason: 'branched',
message_count: 1,
tool_call_count: 0,
input_tokens: 0,
output_tokens: 0,
cache_read_tokens: 0,
cache_write_tokens: 0,
reasoning_tokens: 0,
billing_provider: 'openai',
estimated_cost_usd: 0,
actual_cost_usd: 0,
cost_status: 'estimated',
messages: [{ id: 1, session_id: 'root', role: 'user', content: 'Root prompt', timestamp: 101 }],
},
{
id: 'branch-child',
parent_session_id: 'root',
source: 'cli',
model: 'openai/gpt-5.4',
title: 'Branch child',
started_at: 201,
ended_at: 210,
end_reason: null,
message_count: 2,
tool_call_count: 0,
input_tokens: 0,
output_tokens: 0,
cache_read_tokens: 0,
cache_write_tokens: 0,
reasoning_tokens: 0,
billing_provider: 'openai',
estimated_cost_usd: 0,
actual_cost_usd: 0,
cost_status: 'estimated',
messages: [
{ id: 2, session_id: 'branch-child', role: 'user', content: 'Branch prompt', timestamp: 202 },
{ id: 3, session_id: 'branch-child', role: 'assistant', content: 'Branch answer', timestamp: 203 },
],
},
])
const mod = await import('../../packages/server/src/services/hermes/conversations')
const summaries = await mod.listConversationSummaries({ humanOnly: true })
expect(summaries.map((summary: any) => summary.id)).toEqual(['branch-child', 'root'])
const detail = await mod.getConversationDetail('branch-child', { humanOnly: true })
expect(detail?.messages.map((message: any) => message.content)).toEqual(['Branch prompt', 'Branch answer'])
})
it('excludes human-only conversations with no visible human messages', async () => {
exportSessionsRawMock.mockResolvedValue([
{
id: 'synthetic-root',
parent_session_id: null,
source: 'cli',
model: 'openai/gpt-5.4',
title: null,
started_at: 100,
ended_at: 101,
end_reason: null,
message_count: 1,
tool_call_count: 0,
input_tokens: 0,
output_tokens: 0,
cache_read_tokens: 0,
cache_write_tokens: 0,
reasoning_tokens: 0,
billing_provider: 'openai',
estimated_cost_usd: 0,
actual_cost_usd: 0,
cost_status: 'estimated',
messages: [
{
id: 1,
session_id: 'synthetic-root',
role: 'user',
content: "You've reached the maximum number of tool-calling iterations allowed.",
timestamp: 100,
},
],
},
])
const mod = await import('../../packages/server/src/services/hermes/conversations')
const summaries = await mod.listConversationSummaries({ humanOnly: true })
const detail = await mod.getConversationDetail('synthetic-root', { humanOnly: true })
expect(summaries).toEqual([])
expect(detail).toBeNull()
})
it('caches raw exports briefly and normalizes structured message content', async () => {
exportSessionsRawMock.mockResolvedValue([
{
id: 'recent-open',
parent_session_id: null,
source: 'cli',
model: 'openai/gpt-5.4',
title: 'Recent open',
started_at: 1776643190,
ended_at: null,
end_reason: null,
message_count: 1,
tool_call_count: 0,
input_tokens: 0,
output_tokens: 0,
cache_read_tokens: 0,
cache_write_tokens: 0,
reasoning_tokens: 0,
billing_provider: 'openai',
estimated_cost_usd: 0,
actual_cost_usd: 0,
cost_status: 'estimated',
messages: [
{
id: 11,
session_id: 'recent-open',
role: 'assistant',
content: [{ text: 'hello' }, { text: 'world' }],
timestamp: 1776643198,
},
],
},
{
id: 'stale-open',
parent_session_id: null,
source: 'cli',
model: 'openai/gpt-5.4',
title: 'Stale open',
started_at: 1776642000,
ended_at: null,
end_reason: null,
message_count: 0,
tool_call_count: 0,
input_tokens: 0,
output_tokens: 0,
cache_read_tokens: 0,
cache_write_tokens: 0,
reasoning_tokens: 0,
billing_provider: 'openai',
estimated_cost_usd: 0,
actual_cost_usd: 0,
cost_status: 'estimated',
messages: [],
},
])
const mod = await import('../../packages/server/src/services/hermes/conversations')
const firstSummaries = await mod.listConversationSummaries({ humanOnly: false })
const detail = await mod.getConversationDetail('recent-open', { humanOnly: false })
const secondSummaries = await mod.listConversationSummaries({ humanOnly: false })
expect(exportSessionsRawMock).toHaveBeenCalledTimes(1)
expect(firstSummaries.find((summary: any) => summary.id === 'recent-open')?.is_active).toBe(true)
expect(secondSummaries.find((summary: any) => summary.id === 'stale-open')?.is_active).toBe(false)
expect(detail?.messages[0].content).toBe('hello\nworld')
})
})