[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
This commit is contained in:
@@ -3,6 +3,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
const getCompressionSnapshotMock = vi.fn()
|
||||
const saveCompressionSnapshotMock = vi.fn()
|
||||
const deleteCompressionSnapshotMock = vi.fn()
|
||||
const bridgeRequestMock = vi.fn()
|
||||
const bridgeDestroyMock = vi.fn()
|
||||
|
||||
vi.mock('../../packages/server/src/services/logger', () => ({
|
||||
logger: {
|
||||
@@ -19,6 +21,13 @@ vi.mock('../../packages/server/src/db/hermes/compression-snapshot', () => ({
|
||||
deleteCompressionSnapshot: deleteCompressionSnapshotMock,
|
||||
}))
|
||||
|
||||
vi.mock('../../packages/server/src/services/hermes/agent-bridge', () => ({
|
||||
AgentBridgeClient: class {
|
||||
request = bridgeRequestMock
|
||||
destroy = bridgeDestroyMock
|
||||
},
|
||||
}))
|
||||
|
||||
describe('ChatContextCompressor', () => {
|
||||
let originalFetch: typeof global.fetch
|
||||
|
||||
@@ -27,6 +36,10 @@ describe('ChatContextCompressor', () => {
|
||||
getCompressionSnapshotMock.mockReset()
|
||||
saveCompressionSnapshotMock.mockReset()
|
||||
deleteCompressionSnapshotMock.mockReset()
|
||||
bridgeRequestMock.mockReset()
|
||||
bridgeDestroyMock.mockReset()
|
||||
bridgeRequestMock.mockRejectedValue(new Error('summarizer failed'))
|
||||
bridgeDestroyMock.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@@ -42,7 +55,6 @@ describe('ChatContextCompressor', () => {
|
||||
}))
|
||||
|
||||
getCompressionSnapshotMock.mockReturnValue(null)
|
||||
global.fetch = vi.fn(async () => ({ ok: false, status: 500 })) as any
|
||||
|
||||
const result = await compressor.compress(messages, 'http://upstream', undefined, 's1')
|
||||
|
||||
@@ -66,7 +78,6 @@ describe('ChatContextCompressor', () => {
|
||||
lastMessageIndex: 1,
|
||||
messageCountAtTime: 2,
|
||||
})
|
||||
global.fetch = vi.fn(async () => ({ ok: false, status: 500 })) as any
|
||||
|
||||
const result = await compressor.compress(messages, 'http://upstream', undefined, 's1')
|
||||
|
||||
@@ -109,4 +120,331 @@ describe('ChatContextCompressor', () => {
|
||||
expect(result.meta.compressedStartIndex).toBe(3)
|
||||
expect(saveCompressionSnapshotMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps configured first and last messages during full compression', async () => {
|
||||
const { ChatContextCompressor, SUMMARY_PREFIX } = await import('../../packages/server/src/lib/context-compressor')
|
||||
const compressor = new ChatContextCompressor({
|
||||
config: { headMessageCount: 2, tailMessageCount: 3, summaryBudget: 1000 },
|
||||
})
|
||||
const messages = Array.from({ length: 10 }, (_, i) => ({
|
||||
role: i % 2 === 0 ? 'user' : 'assistant',
|
||||
content: `message ${i}`,
|
||||
}))
|
||||
|
||||
getCompressionSnapshotMock.mockReturnValue(null)
|
||||
bridgeRequestMock.mockResolvedValue({
|
||||
status: 'completed',
|
||||
result: { final_response: 'compressed summary' },
|
||||
})
|
||||
|
||||
const result = await compressor.compress(messages, 'http://upstream', undefined, 's1')
|
||||
|
||||
expect(result.messages.map(m => m.content)).toEqual([
|
||||
'message 0',
|
||||
'message 1',
|
||||
`${SUMMARY_PREFIX}\n\ncompressed summary`,
|
||||
'message 7',
|
||||
'message 8',
|
||||
'message 9',
|
||||
])
|
||||
expect(result.meta.compressed).toBe(true)
|
||||
expect(result.meta.llmCompressed).toBe(true)
|
||||
expect(result.meta.verbatimCount).toBe(5)
|
||||
expect(saveCompressionSnapshotMock).toHaveBeenCalledWith('s1', 'compressed summary', 6, 10)
|
||||
})
|
||||
|
||||
it('does not pre-prune tool results before sending them to the summarizer', async () => {
|
||||
const { ChatContextCompressor } = await import('../../packages/server/src/lib/context-compressor')
|
||||
const compressor = new ChatContextCompressor({
|
||||
config: { headMessageCount: 0, tailMessageCount: 1, summaryBudget: 1000 },
|
||||
})
|
||||
const longToolOutput = `${'x'.repeat(180)}KEEP_MARKER${'y'.repeat(180)}`
|
||||
const messages = [
|
||||
{
|
||||
role: 'assistant',
|
||||
content: 'calling terminal',
|
||||
tool_calls: [{ id: 'call_1', type: 'function', function: { name: 'terminal', arguments: '{}' } }],
|
||||
},
|
||||
{ role: 'tool', name: 'terminal', tool_call_id: 'call_1', content: longToolOutput },
|
||||
{ role: 'user', content: 'tail' },
|
||||
]
|
||||
|
||||
getCompressionSnapshotMock.mockReturnValue(null)
|
||||
bridgeRequestMock.mockResolvedValue({
|
||||
status: 'completed',
|
||||
result: { final_response: 'compressed summary' },
|
||||
})
|
||||
|
||||
await compressor.compress(messages, 'http://upstream', undefined, 's1')
|
||||
|
||||
const request = bridgeRequestMock.mock.calls[0][0]
|
||||
const serializedHistory = JSON.stringify(request.conversation_history)
|
||||
expect(serializedHistory).toContain('KEEP_MARKER')
|
||||
expect(serializedHistory).not.toContain('[terminal] ')
|
||||
})
|
||||
|
||||
it('keeps protected head tool results verbatim after successful full compression', async () => {
|
||||
const { ChatContextCompressor, SUMMARY_PREFIX } = await import('../../packages/server/src/lib/context-compressor')
|
||||
const compressor = new ChatContextCompressor({
|
||||
config: { headMessageCount: 2, tailMessageCount: 1, summaryBudget: 1000 },
|
||||
})
|
||||
const longToolOutput = `${'head-tool-output '.repeat(30)}KEEP_HEAD_TOOL`
|
||||
const messages = [
|
||||
{
|
||||
role: 'assistant',
|
||||
content: 'calling terminal',
|
||||
tool_calls: [{ id: 'call_1', type: 'function', function: { name: 'terminal', arguments: '{}' } }],
|
||||
},
|
||||
{ role: 'tool', name: 'terminal', tool_call_id: 'call_1', content: longToolOutput },
|
||||
{ role: 'user', content: 'middle' },
|
||||
{ role: 'assistant', content: 'tail' },
|
||||
]
|
||||
|
||||
getCompressionSnapshotMock.mockReturnValue(null)
|
||||
bridgeRequestMock.mockResolvedValue({
|
||||
status: 'completed',
|
||||
result: { final_response: 'compressed summary' },
|
||||
})
|
||||
|
||||
const result = await compressor.compress(messages, 'http://upstream', undefined, 's1')
|
||||
|
||||
expect(result.messages.map(m => m.content)).toEqual([
|
||||
'calling terminal',
|
||||
longToolOutput,
|
||||
`${SUMMARY_PREFIX}\n\ncompressed summary`,
|
||||
'tail',
|
||||
])
|
||||
})
|
||||
|
||||
it('uses the previous summary plus a safe tail when an existing snapshot index is stale', async () => {
|
||||
const { ChatContextCompressor, SUMMARY_PREFIX } = await import('../../packages/server/src/lib/context-compressor')
|
||||
const compressor = new ChatContextCompressor({
|
||||
config: { headMessageCount: 2, tailMessageCount: 3, summaryBudget: 1000 },
|
||||
})
|
||||
const messages = Array.from({ length: 8 }, (_, i) => ({
|
||||
role: i % 2 === 0 ? 'user' : 'assistant',
|
||||
content: `message ${i}`,
|
||||
}))
|
||||
|
||||
getCompressionSnapshotMock.mockReturnValue({
|
||||
summary: 'stale previous summary',
|
||||
lastMessageIndex: 20,
|
||||
messageCountAtTime: 21,
|
||||
})
|
||||
bridgeRequestMock.mockResolvedValue({
|
||||
status: 'completed',
|
||||
result: { final_response: 'rebuilt summary' },
|
||||
})
|
||||
|
||||
const result = await compressor.compress(messages, 'http://upstream', undefined, 's1')
|
||||
|
||||
expect(deleteCompressionSnapshotMock).not.toHaveBeenCalled()
|
||||
expect(bridgeRequestMock).not.toHaveBeenCalled()
|
||||
expect(result.messages.map(m => m.content)).toEqual([
|
||||
'message 0',
|
||||
'message 1',
|
||||
`${SUMMARY_PREFIX}\n\nstale previous summary`,
|
||||
'message 5',
|
||||
'message 6',
|
||||
'message 7',
|
||||
])
|
||||
expect(saveCompressionSnapshotMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('folds a stale snapshot safe tail into a new summary when it still exceeds budget', async () => {
|
||||
const { ChatContextCompressor, SUMMARY_PREFIX } = await import('../../packages/server/src/lib/context-compressor')
|
||||
const compressor = new ChatContextCompressor({
|
||||
config: { triggerTokens: 800, headMessageCount: 2, tailMessageCount: 3, summaryBudget: 1000 },
|
||||
})
|
||||
const largeTail = 'tail-token '.repeat(200)
|
||||
const messages = [
|
||||
{ role: 'user', content: 'message 0' },
|
||||
{ role: 'assistant', content: 'message 1' },
|
||||
{ role: 'user', content: 'message 2' },
|
||||
{ role: 'assistant', content: 'message 3' },
|
||||
{ role: 'user', content: 'message 4' },
|
||||
{ role: 'assistant', content: largeTail },
|
||||
{ role: 'user', content: largeTail },
|
||||
{ role: 'assistant', content: largeTail },
|
||||
]
|
||||
|
||||
getCompressionSnapshotMock.mockReturnValue({
|
||||
summary: 'stale previous summary',
|
||||
lastMessageIndex: 20,
|
||||
messageCountAtTime: 21,
|
||||
})
|
||||
bridgeRequestMock.mockResolvedValue({
|
||||
status: 'completed',
|
||||
result: { final_response: 'updated stale summary' },
|
||||
})
|
||||
|
||||
const result = await compressor.compress(messages, 'http://upstream', undefined, 's1')
|
||||
|
||||
expect(deleteCompressionSnapshotMock).not.toHaveBeenCalled()
|
||||
expect(bridgeRequestMock).toHaveBeenCalledTimes(1)
|
||||
expect(result.messages.map(m => m.content)).toEqual([
|
||||
'message 0',
|
||||
'message 1',
|
||||
`${SUMMARY_PREFIX}\n\nupdated stale summary`,
|
||||
])
|
||||
expect(saveCompressionSnapshotMock).toHaveBeenCalledWith('s1', 'updated stale summary', 7, 8)
|
||||
})
|
||||
|
||||
it('compresses the full history when protected windows cover all messages', async () => {
|
||||
const { ChatContextCompressor, SUMMARY_PREFIX } = await import('../../packages/server/src/lib/context-compressor')
|
||||
const compressor = new ChatContextCompressor({
|
||||
config: { headMessageCount: 3, tailMessageCount: 20, summaryBudget: 1000 },
|
||||
})
|
||||
const messages = Array.from({ length: 20 }, (_, i) => ({
|
||||
role: i % 2 === 0 ? 'user' : 'assistant',
|
||||
content: `message ${i}`,
|
||||
}))
|
||||
|
||||
getCompressionSnapshotMock.mockReturnValue(null)
|
||||
bridgeRequestMock.mockResolvedValue({
|
||||
status: 'completed',
|
||||
result: { final_response: 'compressed all messages' },
|
||||
})
|
||||
|
||||
const result = await compressor.compress(messages, 'http://upstream', undefined, 's1')
|
||||
|
||||
expect(bridgeRequestMock).toHaveBeenCalledTimes(1)
|
||||
expect(result.messages.map(m => m.content)).toEqual([
|
||||
`${SUMMARY_PREFIX}\n\ncompressed all messages`,
|
||||
])
|
||||
expect(result.meta.compressed).toBe(true)
|
||||
expect(result.meta.llmCompressed).toBe(true)
|
||||
expect(result.meta.verbatimCount).toBe(0)
|
||||
expect(result.meta.compressedStartIndex).toBe(19)
|
||||
expect(saveCompressionSnapshotMock).toHaveBeenCalledWith('s1', 'compressed all messages', 19, 20)
|
||||
})
|
||||
|
||||
it('drops protected messages when compressed output still exceeds the trigger budget', async () => {
|
||||
const { ChatContextCompressor, SUMMARY_PREFIX } = await import('../../packages/server/src/lib/context-compressor')
|
||||
const compressor = new ChatContextCompressor({
|
||||
config: { triggerTokens: 200, headMessageCount: 2, tailMessageCount: 2, summaryBudget: 100 },
|
||||
})
|
||||
const largeText = 'tail-token '.repeat(500)
|
||||
const messages = [
|
||||
{ role: 'user', content: 'head 0' },
|
||||
{ role: 'assistant', content: 'head 1' },
|
||||
{ role: 'user', content: 'middle 2' },
|
||||
{ role: 'assistant', content: 'middle 3' },
|
||||
{ role: 'user', content: largeText },
|
||||
{ role: 'assistant', content: largeText },
|
||||
]
|
||||
|
||||
getCompressionSnapshotMock.mockReturnValue(null)
|
||||
bridgeRequestMock.mockResolvedValue({
|
||||
status: 'completed',
|
||||
result: { final_response: 'short summary' },
|
||||
})
|
||||
|
||||
const result = await compressor.compress(messages, 'http://upstream', undefined, 's1')
|
||||
|
||||
expect(result.messages.map(m => m.content)).toEqual([
|
||||
`${SUMMARY_PREFIX}\n\nshort summary`,
|
||||
])
|
||||
expect(result.meta.compressed).toBe(true)
|
||||
expect(result.meta.llmCompressed).toBe(true)
|
||||
expect(result.meta.verbatimCount).toBe(0)
|
||||
})
|
||||
|
||||
it('truncates the summary when the summary alone exceeds the trigger budget', async () => {
|
||||
const { ChatContextCompressor, SUMMARY_PREFIX, countTokens } = await import('../../packages/server/src/lib/context-compressor')
|
||||
const compressor = new ChatContextCompressor({
|
||||
config: { triggerTokens: 120, headMessageCount: 2, tailMessageCount: 2, summaryBudget: 100 },
|
||||
})
|
||||
const messages = Array.from({ length: 6 }, (_, i) => ({
|
||||
role: i % 2 === 0 ? 'user' : 'assistant',
|
||||
content: `message ${i}`,
|
||||
}))
|
||||
const longSummary = 'summary-token '.repeat(500)
|
||||
|
||||
getCompressionSnapshotMock.mockReturnValue(null)
|
||||
bridgeRequestMock.mockResolvedValue({
|
||||
status: 'completed',
|
||||
result: { final_response: longSummary },
|
||||
})
|
||||
|
||||
const result = await compressor.compress(messages, 'http://upstream', undefined, 's1')
|
||||
|
||||
expect(result.messages).toHaveLength(1)
|
||||
expect(String(result.messages[0].content)).toContain('[Summary truncated to fit context budget]')
|
||||
expect(String(result.messages[0].content).startsWith(SUMMARY_PREFIX)).toBe(true)
|
||||
expect(countTokens(String(result.messages[0].content))).toBeLessThanOrEqual(140)
|
||||
expect(result.meta.verbatimCount).toBe(0)
|
||||
})
|
||||
|
||||
it('keeps configured first messages when incremental compression reuses an existing snapshot', async () => {
|
||||
const { ChatContextCompressor, SUMMARY_PREFIX } = await import('../../packages/server/src/lib/context-compressor')
|
||||
const compressor = new ChatContextCompressor({
|
||||
config: { headMessageCount: 2, tailMessageCount: 10 },
|
||||
})
|
||||
const messages = Array.from({ length: 6 }, (_, i) => ({
|
||||
role: i % 2 === 0 ? 'user' : 'assistant',
|
||||
content: `message ${i}`,
|
||||
}))
|
||||
|
||||
getCompressionSnapshotMock.mockReturnValue({
|
||||
summary: 'previous summary',
|
||||
lastMessageIndex: 3,
|
||||
messageCountAtTime: 4,
|
||||
})
|
||||
|
||||
const result = await compressor.compress(messages, 'http://upstream', undefined, 's1')
|
||||
|
||||
expect(bridgeRequestMock).not.toHaveBeenCalled()
|
||||
expect(result.messages.map(m => m.content)).toEqual([
|
||||
'message 0',
|
||||
'message 1',
|
||||
`${SUMMARY_PREFIX}\n\nprevious summary`,
|
||||
'message 4',
|
||||
'message 5',
|
||||
])
|
||||
expect(result.meta.verbatimCount).toBe(4)
|
||||
expect(saveCompressionSnapshotMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('folds all new messages into the summary when incremental tail protection would exceed budget', async () => {
|
||||
const { ChatContextCompressor, SUMMARY_PREFIX } = await import('../../packages/server/src/lib/context-compressor')
|
||||
const compressor = new ChatContextCompressor({
|
||||
config: { triggerTokens: 1000, headMessageCount: 3, tailMessageCount: 20, summaryBudget: 100 },
|
||||
})
|
||||
const largeText = 'new-token '.repeat(80)
|
||||
const messages = [
|
||||
{ role: 'user', content: 'head 0' },
|
||||
{ role: 'assistant', content: 'head 1' },
|
||||
{ role: 'user', content: 'head 2' },
|
||||
...Array.from({ length: 20 }, (_, i) => ({
|
||||
role: i % 2 === 0 ? 'user' : 'assistant',
|
||||
content: `${largeText}${i}`,
|
||||
})),
|
||||
]
|
||||
|
||||
getCompressionSnapshotMock.mockReturnValue({
|
||||
summary: 'previous summary',
|
||||
lastMessageIndex: 2,
|
||||
messageCountAtTime: 3,
|
||||
})
|
||||
bridgeRequestMock.mockResolvedValue({
|
||||
status: 'completed',
|
||||
result: { final_response: 'updated summary' },
|
||||
})
|
||||
|
||||
const result = await compressor.compress(messages, 'http://upstream', undefined, 's1')
|
||||
|
||||
expect(bridgeRequestMock).toHaveBeenCalledTimes(1)
|
||||
expect(result.messages.map(m => m.content)).toEqual([
|
||||
'head 0',
|
||||
'head 1',
|
||||
'head 2',
|
||||
`${SUMMARY_PREFIX}\n\nupdated summary`,
|
||||
])
|
||||
expect(result.meta.compressed).toBe(true)
|
||||
expect(result.meta.llmCompressed).toBe(true)
|
||||
expect(result.meta.verbatimCount).toBe(3)
|
||||
expect(result.meta.compressedStartIndex).toBe(22)
|
||||
expect(saveCompressionSnapshotMock).toHaveBeenCalledWith('s1', 'updated summary', 22, 23)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,349 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
@@ -206,6 +206,81 @@ describe('session conversations controller', () => {
|
||||
expect(ctx.body).toEqual({ error: 'Conversation not found' })
|
||||
})
|
||||
|
||||
it('prefers local session detail for Hermes history detail when available', async () => {
|
||||
localGetSessionDetailMock.mockReturnValue({
|
||||
id: 'cli-1',
|
||||
source: 'cli',
|
||||
title: 'Local complete',
|
||||
messages: [
|
||||
{ id: 1, session_id: 'cli-1', role: 'user', content: 'local full message', timestamp: 1 },
|
||||
],
|
||||
})
|
||||
getSessionDetailFromDbMock.mockResolvedValue({
|
||||
id: 'cli-1',
|
||||
source: 'cli',
|
||||
title: 'Hermes incomplete',
|
||||
messages: [],
|
||||
})
|
||||
|
||||
const mod = await import('../../packages/server/src/controllers/hermes/sessions')
|
||||
const ctx: any = { params: { id: 'cli-1' }, body: null }
|
||||
await mod.getHermesSession(ctx)
|
||||
|
||||
expect(localGetSessionDetailMock).toHaveBeenCalledWith('cli-1')
|
||||
expect(getSessionDetailFromDbMock).not.toHaveBeenCalled()
|
||||
expect(getSessionMock).not.toHaveBeenCalled()
|
||||
expect(ctx.body.session).toMatchObject({
|
||||
id: 'cli-1',
|
||||
title: 'Local complete',
|
||||
messages: [{ content: 'local full message' }],
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to Hermes state.db when local history detail is missing', async () => {
|
||||
localGetSessionDetailMock.mockReturnValue(null)
|
||||
getSessionDetailFromDbMock.mockResolvedValue({
|
||||
id: 'hermes-1',
|
||||
source: 'cli',
|
||||
title: 'Hermes detail',
|
||||
messages: [
|
||||
{ id: 1, session_id: 'hermes-1', role: 'user', content: 'from hermes', timestamp: 1 },
|
||||
],
|
||||
})
|
||||
|
||||
const mod = await import('../../packages/server/src/controllers/hermes/sessions')
|
||||
const ctx: any = { params: { id: 'hermes-1' }, body: null }
|
||||
await mod.getHermesSession(ctx)
|
||||
|
||||
expect(localGetSessionDetailMock).toHaveBeenCalledWith('hermes-1')
|
||||
expect(getSessionDetailFromDbMock).toHaveBeenCalledWith('hermes-1')
|
||||
expect(getSessionMock).not.toHaveBeenCalled()
|
||||
expect(ctx.body.session).toMatchObject({
|
||||
id: 'hermes-1',
|
||||
title: 'Hermes detail',
|
||||
messages: [{ content: 'from hermes' }],
|
||||
})
|
||||
})
|
||||
|
||||
it('does not return api_server sessions from the Hermes history detail endpoint', async () => {
|
||||
localGetSessionDetailMock.mockReturnValue({
|
||||
id: 'api-1',
|
||||
source: 'api_server',
|
||||
title: 'API Server',
|
||||
messages: [{ id: 1, session_id: 'api-1', role: 'user', content: 'local api', timestamp: 1 }],
|
||||
})
|
||||
getSessionDetailFromDbMock.mockResolvedValue(null)
|
||||
getSessionMock.mockResolvedValue(null)
|
||||
|
||||
const mod = await import('../../packages/server/src/controllers/hermes/sessions')
|
||||
const ctx: any = { params: { id: 'api-1' }, body: null }
|
||||
await mod.getHermesSession(ctx)
|
||||
|
||||
expect(localGetSessionDetailMock).toHaveBeenCalledWith('api-1')
|
||||
expect(getSessionDetailFromDbMock).toHaveBeenCalledWith('api-1')
|
||||
expect(ctx.status).toBe(404)
|
||||
expect(ctx.body).toEqual({ error: 'Session not found' })
|
||||
})
|
||||
|
||||
it('returns native state.db usage analytics for the requested period', async () => {
|
||||
const today = new Date().toISOString().slice(0, 10)
|
||||
getLocalUsageStatsMock.mockReturnValue({
|
||||
|
||||
Reference in New Issue
Block a user