Fix bridge compression history handling (#726)
* feat(bridge): refactor compression to use DB history and add structured logging - Extract buildDbHistory() to share message loading between buildCompressedHistory and forceCompressBridgeHistory - forceCompressBridgeHistory now reads from local DB instead of using Python-provided messages, ensuring consistency with api_server path - Pass sessionId to compressor for snapshot-aware compression - Add force_compress flag to bridge chat requests - Add bridgeLogger structured logging for compression lifecycle - Simplify schemas, session-sync, and providers Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix bridge compression history handling --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,112 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const getCompressionSnapshotMock = vi.fn()
|
||||
const saveCompressionSnapshotMock = vi.fn()
|
||||
const deleteCompressionSnapshotMock = vi.fn()
|
||||
|
||||
vi.mock('../../packages/server/src/services/logger', () => ({
|
||||
logger: {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../../packages/server/src/db/hermes/compression-snapshot', () => ({
|
||||
getCompressionSnapshot: getCompressionSnapshotMock,
|
||||
saveCompressionSnapshot: saveCompressionSnapshotMock,
|
||||
deleteCompressionSnapshot: deleteCompressionSnapshotMock,
|
||||
}))
|
||||
|
||||
describe('ChatContextCompressor', () => {
|
||||
let originalFetch: typeof global.fetch
|
||||
|
||||
beforeEach(() => {
|
||||
originalFetch = global.fetch
|
||||
getCompressionSnapshotMock.mockReset()
|
||||
saveCompressionSnapshotMock.mockReset()
|
||||
deleteCompressionSnapshotMock.mockReset()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch
|
||||
})
|
||||
|
||||
it('keeps full history when full summarization fails', async () => {
|
||||
const { ChatContextCompressor } = await import('../../packages/server/src/lib/context-compressor')
|
||||
const compressor = new ChatContextCompressor({ config: { tailMessageCount: 3 } })
|
||||
const messages = Array.from({ length: 8 }, (_, i) => ({
|
||||
role: i % 2 === 0 ? 'user' : 'assistant',
|
||||
content: `message ${i}`,
|
||||
}))
|
||||
|
||||
getCompressionSnapshotMock.mockReturnValue(null)
|
||||
global.fetch = vi.fn(async () => ({ ok: false, status: 500 })) as any
|
||||
|
||||
const result = await compressor.compress(messages, 'http://upstream', undefined, 's1')
|
||||
|
||||
expect(result.messages).toHaveLength(messages.length)
|
||||
expect(result.messages.map(m => m.content)).toEqual(messages.map(m => m.content))
|
||||
expect(result.meta.compressed).toBe(false)
|
||||
expect(result.meta.llmCompressed).toBe(false)
|
||||
expect(saveCompressionSnapshotMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps all new messages when incremental summarization fails', async () => {
|
||||
const { ChatContextCompressor, SUMMARY_PREFIX } = await import('../../packages/server/src/lib/context-compressor')
|
||||
const compressor = new ChatContextCompressor({ config: { tailMessageCount: 3 } })
|
||||
const messages = Array.from({ length: 8 }, (_, i) => ({
|
||||
role: i % 2 === 0 ? 'user' : 'assistant',
|
||||
content: `message ${i}`,
|
||||
}))
|
||||
|
||||
getCompressionSnapshotMock.mockReturnValue({
|
||||
summary: 'previous summary',
|
||||
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')
|
||||
|
||||
expect(result.messages).toHaveLength(7)
|
||||
expect(result.messages[0]).toEqual({
|
||||
role: 'user',
|
||||
content: `${SUMMARY_PREFIX}\n\nprevious summary`,
|
||||
})
|
||||
expect(result.messages.slice(1).map(m => m.content)).toEqual(messages.slice(2).map(m => m.content))
|
||||
expect(result.meta.compressed).toBe(true)
|
||||
expect(result.meta.llmCompressed).toBe(false)
|
||||
expect(result.meta.compressedStartIndex).toBe(1)
|
||||
expect(result.meta.verbatimCount).toBe(6)
|
||||
expect(saveCompressionSnapshotMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not call the summarizer when snapshot has only tail messages after it', async () => {
|
||||
const { ChatContextCompressor, SUMMARY_PREFIX } = await import('../../packages/server/src/lib/context-compressor')
|
||||
const compressor = new ChatContextCompressor({ config: { tailMessageCount: 10 } })
|
||||
const messages = Array.from({ length: 6 }, (_, i) => ({
|
||||
role: i % 2 === 0 ? 'user' : 'assistant',
|
||||
content: `message ${i}`,
|
||||
}))
|
||||
const fetchMock = vi.fn()
|
||||
|
||||
getCompressionSnapshotMock.mockReturnValue({
|
||||
summary: 'previous summary',
|
||||
lastMessageIndex: 3,
|
||||
messageCountAtTime: 4,
|
||||
})
|
||||
global.fetch = fetchMock as any
|
||||
|
||||
const result = await compressor.compress(messages, 'http://upstream', undefined, 's1')
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
expect(result.messages).toHaveLength(3)
|
||||
expect(result.messages[0].content).toBe(`${SUMMARY_PREFIX}\n\nprevious summary`)
|
||||
expect(result.messages.slice(1).map(m => m.content)).toEqual(['message 4', 'message 5'])
|
||||
expect(result.meta.llmCompressed).toBe(false)
|
||||
expect(result.meta.compressedStartIndex).toBe(3)
|
||||
expect(saveCompressionSnapshotMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -41,7 +41,7 @@ describe('Hermes schema initialization', () => {
|
||||
expect(usageCols.some(c => c.name === 'output_tokens')).toBe(true)
|
||||
})
|
||||
|
||||
it('preserves existing data when syncing schemas', async () => {
|
||||
it('preserves existing data when adding safe schema columns', async () => {
|
||||
const { initAllHermesTables, USAGE_TABLE, USAGE_SCHEMA } =
|
||||
await import('../../packages/server/src/db/hermes/schemas')
|
||||
|
||||
@@ -51,7 +51,7 @@ describe('Hermes schema initialization', () => {
|
||||
// Insert test data
|
||||
db.prepare(`INSERT INTO "${USAGE_TABLE}" (session_id, created_at) VALUES (?, ?)`).run('test-session', Date.now())
|
||||
|
||||
// Run initialization (should sync schema)
|
||||
// Run initialization (should add safe missing columns)
|
||||
expect(() => initAllHermesTables()).not.toThrow()
|
||||
|
||||
// Verify data is preserved
|
||||
@@ -59,7 +59,7 @@ describe('Hermes schema initialization', () => {
|
||||
expect(row).toBeTruthy()
|
||||
expect(row.session_id).toBe('test-session')
|
||||
|
||||
// Verify new columns were added
|
||||
// Verify safe new columns were added
|
||||
const cols = db.prepare(`PRAGMA table_info("${USAGE_TABLE}")`).all() as Array<{ name: string }>
|
||||
expect(cols.some(c => c.name === 'input_tokens')).toBe(true)
|
||||
expect(cols.some(c => c.name === 'output_tokens')).toBe(true)
|
||||
|
||||
@@ -136,8 +136,8 @@ describe('Database Schema Synchronization', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('Schema sync with column additions', () => {
|
||||
it('adds missing columns to existing table without rebuilding', async () => {
|
||||
describe('Safe additive schema changes', () => {
|
||||
it('adds missing safe columns to existing table without rebuilding', async () => {
|
||||
const { syncTable, USAGE_TABLE, USAGE_SCHEMA } = await import('../../packages/server/src/db/hermes/schemas')
|
||||
|
||||
// Create initial table without some columns
|
||||
@@ -150,7 +150,7 @@ describe('Database Schema Synchronization', () => {
|
||||
// Sync with full schema
|
||||
syncTable(USAGE_TABLE, USAGE_SCHEMA, { primaryKey: 'id' })
|
||||
|
||||
// Verify all columns now exist
|
||||
// Verify safe missing columns now exist
|
||||
const cols = getTableColumns(db, USAGE_TABLE)
|
||||
expect(cols.has('input_tokens')).toBe(true)
|
||||
expect(cols.has('output_tokens')).toBe(true)
|
||||
@@ -209,8 +209,8 @@ describe('Database Schema Synchronization', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('Primary key changes trigger table rebuild', () => {
|
||||
it('rebuilds table when primary key changes from single column to id column', async () => {
|
||||
describe('Destructive schema changes are not applied automatically', () => {
|
||||
it('does not rebuild table when primary key differs', async () => {
|
||||
const { syncTable, GC_ROOM_MEMBERS_TABLE, GC_ROOM_MEMBERS_SCHEMA } =
|
||||
await import('../../packages/server/src/db/hermes/schemas')
|
||||
|
||||
@@ -228,9 +228,9 @@ describe('Database Schema Synchronization', () => {
|
||||
primaryKey: 'id',
|
||||
})
|
||||
|
||||
// Verify id-based primary key
|
||||
const pk = getTablePrimaryKey(db, GC_ROOM_MEMBERS_TABLE)
|
||||
expect(pk).toBe('id')
|
||||
// Verify existing primary key was left untouched
|
||||
const tableCols = db.prepare(`PRAGMA table_info("${GC_ROOM_MEMBERS_TABLE}")`).all() as Array<{ name: string; pk: number }>
|
||||
expect(tableCols.find(c => c.name === 'roomId')?.pk).toBe(1)
|
||||
|
||||
// Verify data was preserved
|
||||
const row = db.prepare(`SELECT * FROM "${GC_ROOM_MEMBERS_TABLE}" WHERE roomId = ? AND userId = ?`).get('room-1', 'user-1')
|
||||
@@ -238,10 +238,8 @@ describe('Database Schema Synchronization', () => {
|
||||
expect(row.roomId).toBe('room-1')
|
||||
expect(row.userId).toBe('user-1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Schema sync with type changes', () => {
|
||||
it('rebuilds table when column types change', async () => {
|
||||
it('does not rebuild table when column types differ', async () => {
|
||||
const { syncTable, USAGE_TABLE, USAGE_SCHEMA } = await import('../../packages/server/src/db/hermes/schemas')
|
||||
|
||||
const db = getTestDb()
|
||||
@@ -255,17 +253,13 @@ describe('Database Schema Synchronization', () => {
|
||||
// Sync with correct schema
|
||||
syncTable(USAGE_TABLE, USAGE_SCHEMA, { primaryKey: 'id' })
|
||||
|
||||
// Verify column type is correct (should be TEXT now)
|
||||
// Verify column type was left untouched
|
||||
const cols = getTableColumns(db, USAGE_TABLE)
|
||||
expect(cols.get('session_id')).toBe('TEXT')
|
||||
expect(cols.get('session_id')).toBe('INTEGER')
|
||||
|
||||
// Verify data was preserved (SQLite can convert INTEGER to TEXT)
|
||||
// Verify data was preserved
|
||||
const rows = db.prepare(`SELECT COUNT(*) as count FROM "${USAGE_TABLE}"`).get() as { count: number }
|
||||
expect(rows.count).toBe(1)
|
||||
|
||||
// Verify the converted value
|
||||
const row = db.prepare(`SELECT session_id FROM "${USAGE_TABLE}"`).get() as { session_id: string }
|
||||
expect(row.session_id).toBe('12345')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -287,7 +281,7 @@ describe('Database Schema Synchronization', () => {
|
||||
expect(indexes).toBeTruthy()
|
||||
})
|
||||
|
||||
it('removes obsolete indexes', async () => {
|
||||
it('does not alter indexes on existing tables', async () => {
|
||||
const { syncTable, MESSAGES_TABLE, MESSAGES_SCHEMA } =
|
||||
await import('../../packages/server/src/db/hermes/schemas')
|
||||
|
||||
@@ -304,18 +298,18 @@ describe('Database Schema Synchronization', () => {
|
||||
},
|
||||
})
|
||||
|
||||
// Verify extra index was removed
|
||||
// Verify extra index remains
|
||||
const extraIndex = db.prepare(`SELECT name FROM sqlite_master WHERE type='index' AND name=?`).get('idx_extra')
|
||||
expect(extraIndex).toBeFalsy()
|
||||
expect(extraIndex).toBeTruthy()
|
||||
|
||||
// Verify correct index was created
|
||||
// Verify expected index was not added to an existing table
|
||||
const correctIndex = db.prepare(`SELECT name FROM sqlite_master WHERE type='index' AND name=?`).get('idx_messages_session_id')
|
||||
expect(correctIndex).toBeTruthy()
|
||||
expect(correctIndex).toBeFalsy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Data preservation during schema sync', () => {
|
||||
it('preserves data when only adding columns', async () => {
|
||||
it('preserves data when adding safe columns', async () => {
|
||||
const { syncTable, USAGE_TABLE, USAGE_SCHEMA } = await import('../../packages/server/src/db/hermes/schemas')
|
||||
|
||||
const db = getTestDb()
|
||||
@@ -327,16 +321,19 @@ describe('Database Schema Synchronization', () => {
|
||||
const sessionId = 'test-session-123'
|
||||
db.prepare(`INSERT INTO "${USAGE_TABLE}" (session_id, created_at) VALUES (?, ?)`).run(sessionId, Date.now())
|
||||
|
||||
// Sync with full schema (should add columns without rebuilding)
|
||||
// Sync with full schema (should add safe columns only)
|
||||
syncTable(USAGE_TABLE, USAGE_SCHEMA, { primaryKey: 'id' })
|
||||
|
||||
// Verify data is still there
|
||||
const row = db.prepare(`SELECT * FROM "${USAGE_TABLE}" WHERE session_id = ?`).get(sessionId)
|
||||
expect(row).toBeTruthy()
|
||||
expect(row.session_id).toBe(sessionId)
|
||||
|
||||
const cols = getTableColumns(db, USAGE_TABLE)
|
||||
expect(cols.has('input_tokens')).toBe(true)
|
||||
})
|
||||
|
||||
it('preserves data when rebuilding table with compatible columns', async () => {
|
||||
it('preserves data and existing table definition when primary key is missing', async () => {
|
||||
const { syncTable, GC_ROOM_AGENTS_TABLE, GC_ROOM_AGENTS_SCHEMA } =
|
||||
await import('../../packages/server/src/db/hermes/schemas')
|
||||
|
||||
@@ -349,11 +346,13 @@ describe('Database Schema Synchronization', () => {
|
||||
db.prepare(`INSERT INTO "${GC_ROOM_AGENTS_TABLE}" (id, roomId, agentId, profile, name, description, invited) VALUES (?, ?, ?, ?, ?, ?, ?)`)
|
||||
.run('agent-1', 'room-1', 'agent-1', 'default', 'Test Agent', '', 0)
|
||||
|
||||
// Sync with id primary key (triggers rebuild)
|
||||
// Sync with id primary key expectation; should not rebuild existing table
|
||||
syncTable(GC_ROOM_AGENTS_TABLE, GC_ROOM_AGENTS_SCHEMA, {
|
||||
primaryKey: 'id',
|
||||
})
|
||||
|
||||
expect(getTablePrimaryKey(db, GC_ROOM_AGENTS_TABLE)).toBe(null)
|
||||
|
||||
// Verify data was preserved
|
||||
const row = db.prepare(`SELECT * FROM "${GC_ROOM_AGENTS_TABLE}" WHERE id = ?`)
|
||||
.get('agent-1')
|
||||
@@ -365,8 +364,8 @@ describe('Database Schema Synchronization', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('Column deletion', () => {
|
||||
it('removes extra columns from existing table', async () => {
|
||||
describe('Column preservation', () => {
|
||||
it('keeps extra columns on existing table', async () => {
|
||||
const { syncTable, USAGE_TABLE, USAGE_SCHEMA } = await import('../../packages/server/src/db/hermes/schemas')
|
||||
|
||||
// Create table with extra columns
|
||||
@@ -377,13 +376,13 @@ describe('Database Schema Synchronization', () => {
|
||||
db.prepare(`INSERT INTO "${USAGE_TABLE}" (session_id, created_at, extra_col, another_extra) VALUES (?, ?, ?, ?)`)
|
||||
.run('test-1', Date.now(), 'value', 123)
|
||||
|
||||
// Sync with schema (should remove extra columns)
|
||||
// Sync with schema (should keep extra columns)
|
||||
syncTable(USAGE_TABLE, USAGE_SCHEMA, { primaryKey: 'id' })
|
||||
|
||||
// Verify extra columns are gone
|
||||
// Verify extra columns are preserved
|
||||
const cols = getTableColumns(db, USAGE_TABLE)
|
||||
expect(cols.has('extra_col')).toBe(false)
|
||||
expect(cols.has('another_extra')).toBe(false)
|
||||
expect(cols.has('extra_col')).toBe(true)
|
||||
expect(cols.has('another_extra')).toBe(true)
|
||||
|
||||
// Verify data is still there
|
||||
const row = db.prepare(`SELECT * FROM "${USAGE_TABLE}" WHERE session_id = ?`).get('test-1')
|
||||
|
||||
@@ -1,70 +1,60 @@
|
||||
/**
|
||||
* Tests for session-sync service
|
||||
* Tests for the disabled Hermes session import path.
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import { getDb } from '../../packages/server/src/db/index'
|
||||
import { initAllStores } from '../../packages/server/src/db/hermes/init'
|
||||
import { listSessionSummaries } from '../../packages/server/src/db/hermes/sessions-db'
|
||||
import { syncAllHermesSessionsOnStartup } from '../../packages/server/src/services/hermes/session-sync'
|
||||
|
||||
vi.mock('../../packages/server/src/db/hermes/sessions-db', () => ({
|
||||
listSessionSummaries: vi.fn().mockResolvedValue([]),
|
||||
getSessionDetailFromDbWithProfile: vi.fn(),
|
||||
}))
|
||||
|
||||
function resetSessionTables(): void {
|
||||
initAllStores()
|
||||
|
||||
const db = getDb()
|
||||
if (db) {
|
||||
db.exec('DELETE FROM messages')
|
||||
db.exec('DELETE FROM sessions')
|
||||
}
|
||||
}
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
describe('session-sync', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
resetSessionTables()
|
||||
let db: any = null
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules()
|
||||
const { DatabaseSync } = await import('node:sqlite')
|
||||
db = new DatabaseSync(':memory:')
|
||||
vi.doMock('../../packages/server/src/db/index', () => ({
|
||||
getDb: () => db,
|
||||
getStoragePath: () => ':memory:',
|
||||
}))
|
||||
vi.doMock('../../packages/server/src/db/hermes/sessions-db', () => ({
|
||||
listSessionSummaries: vi.fn().mockResolvedValue([]),
|
||||
getSessionDetailFromDbWithProfile: vi.fn(),
|
||||
}))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
resetSessionTables()
|
||||
db?.close()
|
||||
db = null
|
||||
vi.doUnmock('../../packages/server/src/db/index')
|
||||
vi.doUnmock('../../packages/server/src/db/hermes/sessions-db')
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
it('should skip sync when local DB is not empty', async () => {
|
||||
const db = getDb()
|
||||
expect(db).not.toBeNull()
|
||||
async function initTestDb() {
|
||||
const { initAllStores } = await import('../../packages/server/src/db/hermes/init')
|
||||
initAllStores()
|
||||
}
|
||||
|
||||
// Insert a test session
|
||||
db!.prepare(`
|
||||
it('does not import Hermes sessions when local DB is not empty', async () => {
|
||||
await initTestDb()
|
||||
const { syncAllHermesSessionsOnStartup } = await import('../../packages/server/src/services/hermes/session-sync')
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO sessions (id, profile, source, model, title, started_at, last_active)
|
||||
VALUES ('test-session-1', 'default', 'api_server', 'gpt-4', 'Test Session', ${Date.now()}, ${Date.now()})
|
||||
`).run()
|
||||
VALUES ('test-session-1', 'default', 'api_server', 'gpt-4', 'Test Session', ?, ?)
|
||||
`).run(Date.now(), Date.now())
|
||||
|
||||
// Check that session exists
|
||||
const countResult = db!.prepare('SELECT COUNT(*) as count FROM sessions').get() as { count: number }
|
||||
expect(countResult.count).toBe(1)
|
||||
|
||||
// Run sync - should skip because DB is not empty
|
||||
await syncAllHermesSessionsOnStartup()
|
||||
expect(vi.mocked(listSessionSummaries)).not.toHaveBeenCalled()
|
||||
|
||||
// Verify session still exists (no changes)
|
||||
const countAfter = db!.prepare('SELECT COUNT(*) as count FROM sessions').get() as { count: number }
|
||||
const countAfter = db.prepare('SELECT COUNT(*) as count FROM sessions').get() as { count: number }
|
||||
expect(countAfter.count).toBe(1)
|
||||
})
|
||||
|
||||
it('should attempt sync when local DB is empty', async () => {
|
||||
const db = getDb()
|
||||
expect(db).not.toBeNull()
|
||||
it('does not import Hermes sessions when local DB is empty', async () => {
|
||||
await initTestDb()
|
||||
const { syncAllHermesSessionsOnStartup } = await import('../../packages/server/src/services/hermes/session-sync')
|
||||
|
||||
// Verify DB is empty
|
||||
const countBefore = db!.prepare('SELECT COUNT(*) as count FROM sessions').get() as { count: number }
|
||||
expect(countBefore.count).toBe(0)
|
||||
|
||||
// Run sync - should attempt to sync from Hermes
|
||||
await expect(syncAllHermesSessionsOnStartup()).resolves.toBeUndefined()
|
||||
expect(vi.mocked(listSessionSummaries)).toHaveBeenCalledWith('api_server', 10000, 'default')
|
||||
|
||||
const countAfter = db.prepare('SELECT COUNT(*) as count FROM sessions').get() as { count: number }
|
||||
expect(countAfter.count).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user