Files
hermes-ui/tests/server/run-chat-compression.test.ts
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

954 lines
30 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest'
const getSessionDetailMock = vi.fn()
const getSessionMock = vi.fn()
const getCompressionSnapshotMock = vi.fn()
const getModelContextLengthMock = vi.fn()
const calcAndUpdateUsageMock = vi.fn()
const estimateUsageTokensFromMessagesMock = vi.fn()
const updateMessageContextTokenUsageMock = vi.fn((sid: string, state: any, emit: any, messageTokens: number, usage?: { inputTokens: number; outputTokens: number }) => {
state.contextTokens = messageTokens
emit('usage.updated', {
event: 'usage.updated',
session_id: sid,
inputTokens: usage?.inputTokens ?? state.inputTokens ?? 0,
outputTokens: usage?.outputTokens ?? state.outputTokens ?? 0,
contextTokens: messageTokens,
})
return messageTokens
})
const compressorCompressMock = vi.fn()
const readConfigYamlForProfileMock = vi.fn()
const compressorConstructorMock = vi.fn()
vi.mock('../../packages/server/src/db/hermes/session-store', () => ({
getSessionDetail: getSessionDetailMock,
getSession: getSessionMock,
}))
vi.mock('../../packages/server/src/db/hermes/compression-snapshot', () => ({
getCompressionSnapshot: getCompressionSnapshotMock,
}))
vi.mock('../../packages/server/src/lib/context-compressor', () => ({
SUMMARY_PREFIX: '[Previous context summary]',
ChatContextCompressor: class {
constructor(opts?: any) {
compressorConstructorMock(opts)
}
compress = compressorCompressMock
},
}))
vi.mock('../../packages/server/src/services/hermes/model-context', () => ({
getModelContextLength: getModelContextLengthMock,
}))
vi.mock('../../packages/server/src/services/config-helpers', () => ({
readConfigYamlForProfile: readConfigYamlForProfileMock,
}))
vi.mock('../../packages/server/src/services/logger', () => ({
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
},
bridgeLogger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
},
}))
vi.mock('../../packages/server/src/services/hermes/run-chat/usage', () => ({
calcAndUpdateUsage: calcAndUpdateUsageMock,
estimateUsageTokensFromMessages: estimateUsageTokensFromMessagesMock,
updateMessageContextTokenUsage: updateMessageContextTokenUsageMock,
}))
vi.mock('../../packages/server/src/services/hermes/run-chat/message-format', () => ({
isAssistantMessageSendable: vi.fn(() => true),
}))
describe('run chat compression trigger', () => {
beforeEach(() => {
getSessionDetailMock.mockReset()
getSessionMock.mockReset()
getCompressionSnapshotMock.mockReset()
getModelContextLengthMock.mockReset()
calcAndUpdateUsageMock.mockReset()
estimateUsageTokensFromMessagesMock.mockReset()
updateMessageContextTokenUsageMock.mockClear()
compressorCompressMock.mockReset()
compressorConstructorMock.mockReset()
readConfigYamlForProfileMock.mockReset()
getSessionMock.mockReturnValue({ id: 'session-1', profile: 'default' })
getModelContextLengthMock.mockReturnValue(256_000)
calcAndUpdateUsageMock.mockResolvedValue({ inputTokens: 1_000, outputTokens: 0 })
estimateUsageTokensFromMessagesMock.mockReturnValue({ inputTokens: 0, outputTokens: 0 })
getCompressionSnapshotMock.mockReturnValue(null)
readConfigYamlForProfileMock.mockResolvedValue({})
})
it('preserves empty assistant reasoning_content in bridge history', async () => {
getSessionDetailMock.mockReturnValue({
messages: [
{
id: 1,
session_id: 'session-1',
role: 'assistant',
content: 'called a tool',
timestamp: 1,
tool_call_id: null,
tool_calls: null,
tool_name: null,
finish_reason: null,
reasoning_content: '',
},
{
id: 2,
session_id: 'session-1',
role: 'user',
content: 'next',
timestamp: 2,
tool_call_id: null,
tool_calls: null,
tool_name: null,
finish_reason: null,
reasoning_content: null,
},
],
})
const { buildCompressedHistory } = await import('../../packages/server/src/services/hermes/run-chat/compression')
const history = await buildCompressedHistory(
'session-1',
'default',
'http://upstream',
undefined,
vi.fn(),
new Map(),
)
expect(history).toEqual([
{ role: 'assistant', content: 'called a tool', reasoning_content: '' },
])
})
it('excludes persisted MoA display rows from bridge history', async () => {
getSessionDetailMock.mockReturnValue({
messages: [
{
id: 1,
session_id: 'session-1',
role: 'user',
content: 'start',
timestamp: 1,
},
{
id: 2,
session_id: 'session-1',
role: 'moa',
display_role: 'tool',
content: JSON.stringify({ preview: '1/2 grok', text: 'reference answer' }),
tool_call_id: 'moa:reference:run-1:1',
tool_name: 'moa_reference',
timestamp: 2,
},
{
id: 3,
session_id: 'session-1',
role: 'assistant',
content: 'final answer',
timestamp: 3,
},
],
})
const { buildDbHistory } = await import('../../packages/server/src/services/hermes/run-chat/compression')
await expect(buildDbHistory('session-1')).resolves.toEqual([
{ role: 'user', content: 'start' },
{ role: 'assistant', content: 'final answer' },
])
})
it('projects complete persisted tool results into a 5500-character bridge history', async () => {
const completeToolResult = `HEAD-${'x'.repeat(7_000)}-TAIL`
const detail = {
messages: [
{
id: 1,
session_id: 'session-1',
role: 'tool',
content: completeToolResult,
tool_call_id: 'tool-call-1',
tool_name: 'session_get',
timestamp: 1,
},
],
}
getSessionDetailMock.mockReturnValue(detail)
const { buildDbHistory } = await import('../../packages/server/src/services/hermes/run-chat/compression')
const history = await buildDbHistory('session-1')
const projected = String(history[0]?.content || '')
expect(projected).toHaveLength(5_500)
expect(projected).toContain('... [truncated]')
expect(projected).toMatch(/^HEAD-/)
expect(projected).toMatch(/-TAIL$/)
expect(detail.messages[0].content).toBe(completeToolResult)
})
it('uses the 5500-character bridge history for context tokens instead of complete DB usage', async () => {
const completeToolResult = `HEAD-${'x'.repeat(70_000)}-TAIL`
getSessionDetailMock.mockReturnValue({
messages: [
{
id: 1,
session_id: 'session-1',
role: 'tool',
content: completeToolResult,
tool_call_id: 'tool-call-1',
tool_name: 'session_get',
timestamp: 1,
},
],
})
calcAndUpdateUsageMock.mockResolvedValue({
inputTokens: 0,
outputTokens: 500_000,
contextInputTokens: 0,
contextOutputTokens: 5_500,
})
estimateUsageTokensFromMessagesMock.mockImplementation((messages: any[]) => ({
inputTokens: 0,
outputTokens: messages.reduce((sum, message) => sum + String(message.content || '').length, 0),
}))
const contextTokenEstimator = vi.fn(async (_messages: any[], messageTokens: number) => messageTokens)
const { buildCompressedHistory } = await import('../../packages/server/src/services/hermes/run-chat/compression')
const history = await buildCompressedHistory(
'session-1',
'default',
'http://upstream',
undefined,
vi.fn(),
new Map(),
{},
contextTokenEstimator,
)
expect(String(history[0]?.content || '')).toHaveLength(5_500)
expect(calcAndUpdateUsageMock).toHaveBeenCalledWith(
'session-1',
expect.any(Object),
expect.any(Function),
{ truncateToolResultsForContext: true },
)
expect(contextTokenEstimator).toHaveBeenCalledWith(expect.any(Array), 5_500)
expect(compressorCompressMock).not.toHaveBeenCalled()
})
it('does not compress long low-token history just because it has more than 150 messages', async () => {
const messages = Array.from({ length: 152 }, (_, index) => ({
id: index + 1,
session_id: 'session-1',
role: index === 151 ? 'user' : index % 2 === 0 ? 'user' : 'assistant',
content: `m${index}`,
timestamp: index + 1,
tool_call_id: null,
tool_calls: null,
tool_name: null,
finish_reason: null,
reasoning_content: null,
}))
getSessionDetailMock.mockReturnValue({ messages })
const { buildCompressedHistory } = await import('../../packages/server/src/services/hermes/run-chat/compression')
const history = await buildCompressedHistory(
'session-1',
'default',
'http://upstream',
undefined,
vi.fn(),
new Map(),
)
expect(history).toHaveLength(151)
expect(history[0]).toEqual({ role: 'user', content: 'm0' })
expect(history.at(-1)).toEqual({ role: 'user', content: 'm150' })
expect(compressorCompressMock).not.toHaveBeenCalled()
})
it('uses configured threshold before triggering compression', async () => {
const messages = Array.from({ length: 10 }, (_, index) => ({
id: index + 1,
session_id: 'session-1',
role: index === 9 ? 'user' : index % 2 === 0 ? 'user' : 'assistant',
content: `message ${index}`,
timestamp: index + 1,
tool_call_id: null,
tool_calls: null,
tool_name: null,
finish_reason: null,
reasoning_content: null,
}))
getSessionDetailMock.mockReturnValue({ messages })
readConfigYamlForProfileMock.mockResolvedValue({
compression: { threshold: 0.25, target_ratio: 0.1, protect_last_n: 7, protect_first_n: 2 },
})
calcAndUpdateUsageMock.mockResolvedValue({ inputTokens: 70_000, outputTokens: 0 })
compressorCompressMock.mockResolvedValue({
messages: [{ role: 'user', content: 'compressed' }],
meta: {
compressed: true,
llmCompressed: true,
totalMessages: 9,
summaryTokenEstimate: 1,
verbatimCount: 0,
compressedStartIndex: 0,
},
})
const { buildCompressedHistory } = await import('../../packages/server/src/services/hermes/run-chat/compression')
const history = await buildCompressedHistory(
'session-1',
'default',
'http://upstream',
undefined,
vi.fn(),
new Map(),
)
expect(history).toEqual([{ role: 'user', content: 'compressed' }])
expect(compressorCompressMock).toHaveBeenCalledWith(
expect.any(Array),
'http://upstream',
undefined,
'session-1',
expect.objectContaining({ profile: 'default' }),
)
})
it('uses the session model for compression when auxiliary compression is auto', async () => {
const messages = Array.from({ length: 10 }, (_, index) => ({
id: index + 1,
session_id: 'session-1',
role: index === 9 ? 'user' : index % 2 === 0 ? 'user' : 'assistant',
content: `message ${index}`,
timestamp: index + 1,
tool_call_id: null,
tool_calls: null,
tool_name: null,
finish_reason: null,
reasoning_content: null,
}))
getSessionDetailMock.mockReturnValue({ messages })
getSessionMock.mockReturnValue({
id: 'session-1',
profile: 'default',
model: 'session-model',
provider: 'session-provider',
})
readConfigYamlForProfileMock.mockResolvedValue({
auxiliary: { compression: { provider: 'auto' } },
})
calcAndUpdateUsageMock.mockResolvedValue({ inputTokens: 160_000, outputTokens: 0 })
compressorCompressMock.mockResolvedValue({
messages: [{ role: 'user', content: 'compressed' }],
meta: {
compressed: true,
llmCompressed: true,
totalMessages: 9,
summaryTokenEstimate: 1,
verbatimCount: 0,
compressedStartIndex: 0,
},
})
const { buildCompressedHistory } = await import('../../packages/server/src/services/hermes/run-chat/compression')
await buildCompressedHistory(
'session-1',
'default',
'http://upstream',
undefined,
vi.fn(),
new Map(),
)
expect(compressorCompressMock).toHaveBeenCalledWith(
expect.any(Array),
'http://upstream',
undefined,
'session-1',
expect.objectContaining({
model: 'session-model',
provider: 'session-provider',
}),
)
})
it('uses the profile default model for compression when auxiliary compression is main', async () => {
const messages = Array.from({ length: 10 }, (_, index) => ({
id: index + 1,
session_id: 'session-1',
role: index === 9 ? 'user' : index % 2 === 0 ? 'user' : 'assistant',
content: `message ${index}`,
timestamp: index + 1,
tool_call_id: null,
tool_calls: null,
tool_name: null,
finish_reason: null,
reasoning_content: null,
}))
getSessionDetailMock.mockReturnValue({ messages })
getSessionMock.mockReturnValue({
id: 'session-1',
profile: 'default',
model: 'session-model',
provider: 'session-provider',
})
readConfigYamlForProfileMock.mockResolvedValue({
model: { default: 'main-model', provider: 'main-provider' },
auxiliary: { compression: { provider: 'main' } },
})
calcAndUpdateUsageMock.mockResolvedValue({ inputTokens: 160_000, outputTokens: 0 })
compressorCompressMock.mockResolvedValue({
messages: [{ role: 'user', content: 'compressed' }],
meta: {
compressed: true,
llmCompressed: true,
totalMessages: 9,
summaryTokenEstimate: 1,
verbatimCount: 0,
compressedStartIndex: 0,
},
})
const { buildCompressedHistory } = await import('../../packages/server/src/services/hermes/run-chat/compression')
await buildCompressedHistory(
'session-1',
'default',
'http://upstream',
undefined,
vi.fn(),
new Map(),
)
expect(compressorCompressMock).toHaveBeenCalledWith(
expect.any(Array),
'http://upstream',
undefined,
'session-1',
expect.objectContaining({
model: 'main-model',
provider: 'main-provider',
}),
)
})
it('uses the configured auxiliary compression provider and model when set', async () => {
const messages = Array.from({ length: 10 }, (_, index) => ({
id: index + 1,
session_id: 'session-1',
role: index === 9 ? 'user' : index % 2 === 0 ? 'user' : 'assistant',
content: `message ${index}`,
timestamp: index + 1,
tool_call_id: null,
tool_calls: null,
tool_name: null,
finish_reason: null,
reasoning_content: null,
}))
getSessionDetailMock.mockReturnValue({ messages })
getSessionMock.mockReturnValue({
id: 'session-1',
profile: 'default',
model: 'session-model',
provider: 'session-provider',
})
readConfigYamlForProfileMock.mockResolvedValue({
auxiliary: {
compression: {
provider: 'compress-provider',
model: 'compress-model',
},
},
})
calcAndUpdateUsageMock.mockResolvedValue({ inputTokens: 160_000, outputTokens: 0 })
compressorCompressMock.mockResolvedValue({
messages: [{ role: 'user', content: 'compressed' }],
meta: {
compressed: true,
llmCompressed: true,
totalMessages: 9,
summaryTokenEstimate: 1,
verbatimCount: 0,
compressedStartIndex: 0,
},
})
const { buildCompressedHistory } = await import('../../packages/server/src/services/hermes/run-chat/compression')
await buildCompressedHistory(
'session-1',
'default',
'http://upstream',
undefined,
vi.fn(),
new Map(),
)
expect(compressorCompressMock).toHaveBeenCalledWith(
expect.any(Array),
'http://upstream',
undefined,
'session-1',
expect.objectContaining({
model: 'compress-model',
provider: 'compress-provider',
}),
)
})
it('uses local context estimates for compression threshold decisions', async () => {
const messages = Array.from({ length: 10 }, (_, index) => ({
id: index + 1,
session_id: 'session-1',
role: index === 9 ? 'user' : index % 2 === 0 ? 'user' : 'assistant',
content: `message ${index}`,
timestamp: index + 1,
tool_call_id: null,
tool_calls: null,
tool_name: null,
finish_reason: null,
reasoning_content: null,
}))
getSessionDetailMock.mockReturnValue({ messages })
calcAndUpdateUsageMock.mockResolvedValue({ inputTokens: 1_000, outputTokens: 0 })
compressorCompressMock.mockResolvedValue({
messages: [{ role: 'user', content: 'compressed by local context estimate' }],
meta: {
compressed: true,
llmCompressed: true,
totalMessages: 9,
summaryTokenEstimate: 1,
verbatimCount: 0,
compressedStartIndex: 0,
},
})
const emit = vi.fn()
const { buildCompressedHistory } = await import('../../packages/server/src/services/hermes/run-chat/compression')
const history = await buildCompressedHistory(
'session-1',
'default',
'http://upstream',
undefined,
emit,
new Map(),
{},
vi.fn(async () => 160_000),
)
expect(history).toEqual([{ role: 'user', content: 'compressed by local context estimate' }])
expect(compressorCompressMock).toHaveBeenCalledTimes(1)
expect(updateMessageContextTokenUsageMock).toHaveBeenCalledWith(
'session-1',
expect.any(Object),
emit,
1_000,
{ inputTokens: 1_000, outputTokens: 0 },
)
})
it('emits local context token usage when the local estimate is under threshold', async () => {
const messages = Array.from({ length: 10 }, (_, index) => ({
id: index + 1,
session_id: 'session-1',
role: index === 9 ? 'user' : index % 2 === 0 ? 'user' : 'assistant',
content: `message ${index}`,
timestamp: index + 1,
tool_call_id: null,
tool_calls: null,
tool_name: null,
finish_reason: null,
reasoning_content: null,
}))
getSessionDetailMock.mockReturnValue({ messages })
calcAndUpdateUsageMock.mockResolvedValue({ inputTokens: 1_000, outputTokens: 900 })
const emit = vi.fn()
const contextTokenEstimator = vi.fn(async () => 19_379)
const { buildCompressedHistory } = await import('../../packages/server/src/services/hermes/run-chat/compression')
const history = await buildCompressedHistory(
'session-1',
'default',
'http://upstream',
undefined,
emit,
new Map(),
{},
contextTokenEstimator,
)
expect(history).toHaveLength(9)
expect(contextTokenEstimator).toHaveBeenCalledWith(
expect.arrayContaining([{ role: 'user', content: 'message 0' }]),
1_900,
)
expect(emit).toHaveBeenCalledWith('usage.updated', expect.objectContaining({
event: 'usage.updated',
session_id: 'session-1',
inputTokens: 1_000,
outputTokens: 900,
contextTokens: 19_379,
}))
expect(compressorCompressMock).not.toHaveBeenCalled()
})
it('includes current input tokens when estimating snapshot-aware context', async () => {
const messages = Array.from({ length: 10 }, (_, index) => ({
id: index + 1,
session_id: 'session-1',
role: index === 9 ? 'user' : index % 2 === 0 ? 'user' : 'assistant',
content: `message ${index}`,
timestamp: index + 1,
tool_call_id: null,
tool_calls: null,
tool_name: null,
finish_reason: null,
reasoning_content: null,
}))
getSessionDetailMock.mockReturnValue({ messages })
getCompressionSnapshotMock.mockReturnValue({
summary: 'previous summary',
lastMessageIndex: 4,
messageCountAtTime: 5,
})
calcAndUpdateUsageMock.mockResolvedValue({ inputTokens: 10, outputTokens: 0 })
estimateUsageTokensFromMessagesMock.mockReturnValue({ inputTokens: 1_000, outputTokens: 0 })
const emit = vi.fn()
const contextTokenEstimator = vi.fn(async (_messages, messageTokens: number) => 20_000 + messageTokens)
const { buildCompressedHistory } = await import('../../packages/server/src/services/hermes/run-chat/compression')
await buildCompressedHistory(
'session-1',
'default',
'http://upstream',
undefined,
emit,
new Map(),
{},
contextTokenEstimator,
700,
)
expect(contextTokenEstimator).toHaveBeenCalledWith(expect.any(Array), 1_700)
expect(emit).toHaveBeenCalledWith('usage.updated', expect.objectContaining({
contextTokens: 21_700,
}))
expect(compressorCompressMock).not.toHaveBeenCalled()
})
it('keeps current input tokens in the compression completed context total', async () => {
const messages = Array.from({ length: 10 }, (_, index) => ({
id: index + 1,
session_id: 'session-1',
role: index === 9 ? 'user' : index % 2 === 0 ? 'user' : 'assistant',
content: `message ${index}`,
timestamp: index + 1,
tool_call_id: null,
tool_calls: null,
tool_name: null,
finish_reason: null,
reasoning_content: null,
}))
getSessionDetailMock.mockReturnValue({ messages })
calcAndUpdateUsageMock.mockResolvedValue({ inputTokens: 100, outputTokens: 0 })
estimateUsageTokensFromMessagesMock.mockImplementation((items: any[]) => {
if (items?.[0]?.content === 'compressed result') return { inputTokens: 1_000, outputTokens: 0 }
return { inputTokens: 100, outputTokens: 0 }
})
compressorCompressMock.mockResolvedValue({
messages: [{ role: 'user', content: 'compressed result' }],
meta: {
compressed: true,
llmCompressed: true,
totalMessages: 9,
summaryTokenEstimate: 1,
verbatimCount: 0,
compressedStartIndex: 0,
},
})
const emit = vi.fn()
const { buildCompressedHistory } = await import('../../packages/server/src/services/hermes/run-chat/compression')
await buildCompressedHistory(
'session-1',
'default',
'http://upstream',
undefined,
emit,
new Map(),
{},
vi.fn(async () => 160_000),
700,
)
expect(updateMessageContextTokenUsageMock).toHaveBeenCalledWith(
'session-1',
expect.any(Object),
emit,
1_700,
{ inputTokens: 100, outputTokens: 0 },
)
expect(emit).toHaveBeenCalledWith('compression.completed', expect.objectContaining({
afterTokens: 1_700,
contextTokens: 1_700,
}))
})
it('throws when fixed prompt and tool schemas exceed threshold before any history exists', async () => {
getSessionDetailMock.mockReturnValue({ messages: [] })
const emit = vi.fn()
const { buildCompressedHistory, ContextWindowTooSmallError } = await import('../../packages/server/src/services/hermes/run-chat/compression')
await expect(buildCompressedHistory(
'session-1',
'default',
'http://upstream',
undefined,
emit,
new Map(),
{},
vi.fn(async () => 160_000),
)).rejects.toBeInstanceOf(ContextWindowTooSmallError)
expect(emit).not.toHaveBeenCalledWith('usage.updated', expect.anything())
expect(compressorCompressMock).not.toHaveBeenCalled()
})
it('throws instead of compressing when full context is over threshold but history is too short', async () => {
const messages = Array.from({ length: 5 }, (_, index) => ({
id: index + 1,
session_id: 'session-1',
role: index === 4 ? 'user' : index % 2 === 0 ? 'user' : 'assistant',
content: `message ${index}`,
timestamp: index + 1,
tool_call_id: null,
tool_calls: null,
tool_name: null,
finish_reason: null,
reasoning_content: null,
}))
getSessionDetailMock.mockReturnValue({ messages })
calcAndUpdateUsageMock.mockResolvedValue({ inputTokens: 1_000, outputTokens: 0 })
const { buildCompressedHistory, ContextWindowTooSmallError } = await import('../../packages/server/src/services/hermes/run-chat/compression')
await expect(buildCompressedHistory(
'session-1',
'default',
'http://upstream',
undefined,
vi.fn(),
new Map(),
{},
vi.fn(async () => 160_000),
)).rejects.toBeInstanceOf(ContextWindowTooSmallError)
expect(compressorCompressMock).not.toHaveBeenCalled()
})
it('merges partial compression config with defaults', async () => {
const messages = Array.from({ length: 10 }, (_, index) => ({
id: index + 1,
session_id: 'session-1',
role: index === 9 ? 'user' : index % 2 === 0 ? 'user' : 'assistant',
content: `message ${index}`,
timestamp: index + 1,
tool_call_id: null,
tool_calls: null,
tool_name: null,
finish_reason: null,
reasoning_content: null,
}))
getSessionDetailMock.mockReturnValue({ messages })
readConfigYamlForProfileMock.mockResolvedValue({
compression: { protect_last_n: 5 },
})
calcAndUpdateUsageMock.mockResolvedValue({ inputTokens: 160_000, outputTokens: 0 })
compressorCompressMock.mockResolvedValue({
messages: [{ role: 'user', content: 'compressed' }],
meta: {
compressed: true,
llmCompressed: true,
totalMessages: 9,
summaryTokenEstimate: 1,
verbatimCount: 0,
compressedStartIndex: 0,
},
})
const { buildCompressedHistory } = await import('../../packages/server/src/services/hermes/run-chat/compression')
await buildCompressedHistory(
'session-1',
'default',
'http://upstream',
undefined,
vi.fn(),
new Map(),
)
expect(compressorConstructorMock).toHaveBeenCalledWith({
config: {
triggerTokens: 128_000,
summaryBudget: 51_200,
headMessageCount: 3,
tailMessageCount: 5,
},
})
expect(compressorCompressMock).toHaveBeenCalledTimes(1)
})
it('uses stale snapshot summary plus safe tail instead of full history when under threshold', async () => {
const messages = Array.from({ length: 10 }, (_, index) => ({
id: index + 1,
session_id: 'session-1',
role: index === 9 ? 'user' : index % 2 === 0 ? 'user' : 'assistant',
content: `message ${index}`,
timestamp: index + 1,
tool_call_id: null,
tool_calls: null,
tool_name: null,
finish_reason: null,
reasoning_content: null,
}))
getSessionDetailMock.mockReturnValue({ messages })
getCompressionSnapshotMock.mockReturnValue({
summary: 'old summary',
lastMessageIndex: 99,
messageCountAtTime: 100,
})
readConfigYamlForProfileMock.mockResolvedValue({
compression: { protect_first_n: 2, protect_last_n: 3 },
})
estimateUsageTokensFromMessagesMock.mockReturnValue({ inputTokens: 1_000, outputTokens: 0 })
const { buildCompressedHistory } = await import('../../packages/server/src/services/hermes/run-chat/compression')
const history = await buildCompressedHistory(
'session-1',
'default',
'http://upstream',
undefined,
vi.fn(),
new Map(),
)
expect(history.map(m => m.content)).toEqual([
'message 0',
'message 1',
'[Previous context summary]\n\nold summary',
'message 6',
'message 7',
'message 8',
])
expect(compressorCompressMock).not.toHaveBeenCalled()
})
it('compresses stale snapshot safe tail instead of full history when stale assembly exceeds threshold', async () => {
const messages = Array.from({ length: 10 }, (_, index) => ({
id: index + 1,
session_id: 'session-1',
role: index === 9 ? 'user' : index % 2 === 0 ? 'user' : 'assistant',
content: `message ${index}`,
timestamp: index + 1,
tool_call_id: null,
tool_calls: null,
tool_name: null,
finish_reason: null,
reasoning_content: null,
}))
getSessionDetailMock.mockReturnValue({ messages })
getCompressionSnapshotMock.mockReturnValue({
summary: 'old summary',
lastMessageIndex: 99,
messageCountAtTime: 100,
})
readConfigYamlForProfileMock.mockResolvedValue({
compression: { protect_first_n: 2, protect_last_n: 3 },
})
estimateUsageTokensFromMessagesMock.mockReturnValue({ inputTokens: 160_000, outputTokens: 0 })
compressorCompressMock.mockResolvedValue({
messages: [{ role: 'user', content: 'updated stale compressed' }],
meta: {
compressed: true,
llmCompressed: true,
totalMessages: 9,
summaryTokenEstimate: 1,
verbatimCount: 0,
compressedStartIndex: 8,
},
})
const { buildCompressedHistory } = await import('../../packages/server/src/services/hermes/run-chat/compression')
const history = await buildCompressedHistory(
'session-1',
'default',
'http://upstream',
undefined,
vi.fn(),
new Map(),
)
expect(history).toEqual([{ role: 'user', content: 'updated stale compressed' }])
expect(compressorCompressMock).toHaveBeenCalledWith(
expect.arrayContaining([{ role: 'user', content: 'message 0' }]),
'http://upstream',
undefined,
'session-1',
expect.objectContaining({ profile: 'default' }),
)
})
it('does not compress when compression is disabled', async () => {
const messages = Array.from({ length: 10 }, (_, index) => ({
id: index + 1,
session_id: 'session-1',
role: index === 9 ? 'user' : index % 2 === 0 ? 'user' : 'assistant',
content: `message ${index}`,
timestamp: index + 1,
tool_call_id: null,
tool_calls: null,
tool_name: null,
finish_reason: null,
reasoning_content: null,
}))
getSessionDetailMock.mockReturnValue({ messages })
readConfigYamlForProfileMock.mockResolvedValue({
compression: { enabled: false, threshold: 0.01 },
})
calcAndUpdateUsageMock.mockResolvedValue({ inputTokens: 180_000, outputTokens: 0 })
const { buildCompressedHistory } = await import('../../packages/server/src/services/hermes/run-chat/compression')
const history = await buildCompressedHistory(
'session-1',
'default',
'http://upstream',
undefined,
vi.fn(),
new Map(),
)
expect(history).toHaveLength(9)
expect(compressorCompressMock).not.toHaveBeenCalled()
expect(calcAndUpdateUsageMock).not.toHaveBeenCalled()
})
})