Files
Hermes-ui/tests/server/run-chat-compression.test.ts
T
ekko 0547fd6b6a [codex] Harden context compression history handling (#848)
* Use token threshold for chat compression

* Add compression settings controls

* Use config for chat compression

* Cover protected messages in compression tests

* Remove message-count compression limit

* Harden compression window fallback

* Rebuild stale compression snapshots

* Harden stale compression snapshots

* Update changelog for compression hardening

* Prefer local history session details
2026-05-19 17:58:39 +08:00

350 lines
11 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 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,
}))
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()
compressorCompressMock.mockReset()
compressorConstructorMock.mockReset()
readConfigYamlForProfileMock.mockReset()
getSessionMock.mockReturnValue({ id: 'session-1', profile: 'default' })
getModelContextLengthMock.mockReturnValue(200_000)
calcAndUpdateUsageMock.mockResolvedValue({ inputTokens: 1_000, outputTokens: 0 })
estimateUsageTokensFromMessagesMock.mockReturnValue({ inputTokens: 0, outputTokens: 0 })
getCompressionSnapshotMock.mockReturnValue(null)
readConfigYamlForProfileMock.mockResolvedValue({})
})
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: 60_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('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: 120_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: 100_000,
summaryBudget: 40_000,
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: 120_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()
})
})