Governed substrate for autonomous agents: scoped identity (passports), audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
338 lines
14 KiB
JavaScript
338 lines
14 KiB
JavaScript
/**
|
|
* ADR-024 Phase 2: chatChunkingService v2 Tests
|
|
*
|
|
* TDD: RED Phase - Tests for refactored service that uses DB tables
|
|
* instead of JSON blob for summaries.
|
|
*
|
|
* Key changes:
|
|
* - Uses `messages` table instead of JSON array
|
|
* - Uses `conversation_summaries` table for cached summaries
|
|
* - Provides efficient context building for AI
|
|
*/
|
|
|
|
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi } from 'vitest';
|
|
|
|
// Set env vars BEFORE imports
|
|
process.env.SKIP_DEV_USER = 'true';
|
|
|
|
import { dbRun, dbGet, dbAll, isPostgres } from '../../database/connection.js';
|
|
import { setupTestDatabase, cleanupTestDatabase } from '../../tests/helpers/test-db.js';
|
|
|
|
// Import service under test
|
|
import {
|
|
CHUNK_SIZE,
|
|
KEEP_RECENT_MESSAGES,
|
|
shouldCreateSummary,
|
|
createSummary,
|
|
buildAIContext,
|
|
getMessageCount,
|
|
getSummarizedMessageCount
|
|
} from '../chatChunkingService.js';
|
|
|
|
describe('ADR-024 Phase 2: chatChunkingService v2', () => {
|
|
let testUserId;
|
|
let testConversationId;
|
|
|
|
beforeAll(async () => {
|
|
await setupTestDatabase();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await cleanupTestDatabase();
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
const ts = Date.now();
|
|
|
|
// Create test user
|
|
const userResult = await dbRun(
|
|
`INSERT INTO users (email, password_hash, name, encryption_key_encrypted, created_at) VALUES ($1, 'hash', 'Test', 'enc', NOW())`,
|
|
[`chunking-test-${ts}@test.com`]
|
|
);
|
|
testUserId = userResult.lastInsertRowid;
|
|
|
|
// Create test conversation
|
|
const convResult = await dbRun(
|
|
`INSERT INTO conversations (type, created_by, created_at) VALUES ('ai_chat', $1, NOW())`,
|
|
[testUserId]
|
|
);
|
|
testConversationId = convResult.lastInsertRowid;
|
|
});
|
|
|
|
afterEach(async () => {
|
|
// Cleanup
|
|
if (testConversationId) {
|
|
await dbRun('DELETE FROM conversation_summaries WHERE conversation_id = $1', [testConversationId]);
|
|
await dbRun('DELETE FROM messages WHERE conversation_id = $1', [testConversationId]);
|
|
await dbRun('DELETE FROM conversations WHERE id = $1', [testConversationId]);
|
|
}
|
|
if (testUserId) {
|
|
await dbRun('DELETE FROM users WHERE id = $1', [testUserId]);
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Helper to insert test messages
|
|
*/
|
|
async function insertMessages(conversationId, senderId, count) {
|
|
const messageIds = [];
|
|
for (let i = 0; i < count; i++) {
|
|
const result = await dbRun(
|
|
`INSERT INTO messages (conversation_id, sender_id, role, content, created_at)
|
|
VALUES ($1, $2, 'user', $3, NOW())`,
|
|
[conversationId, senderId, `Test message ${i + 1}`]
|
|
);
|
|
messageIds.push(result.lastInsertRowid);
|
|
}
|
|
return messageIds;
|
|
}
|
|
|
|
describe('getMessageCount()', () => {
|
|
it('should return 0 for empty conversation', async () => {
|
|
const count = await getMessageCount(testConversationId);
|
|
expect(count).toBe(0);
|
|
});
|
|
|
|
it('should return correct count for conversation with messages', async () => {
|
|
await insertMessages(testConversationId, testUserId, 15);
|
|
const count = await getMessageCount(testConversationId);
|
|
expect(count).toBe(15);
|
|
});
|
|
});
|
|
|
|
describe('getSummarizedMessageCount()', () => {
|
|
it('should return 0 when no summaries exist', async () => {
|
|
const count = await getSummarizedMessageCount(testConversationId);
|
|
expect(count).toBe(0);
|
|
});
|
|
|
|
it('should return sum of messages_count from all summaries', async () => {
|
|
// Insert 2 summaries with 10 messages each
|
|
await dbRun(
|
|
isPostgres()
|
|
? `INSERT INTO conversation_summaries (conversation_id, chunk_number, messages_start_id, messages_end_id, messages_count, summary, created_at)
|
|
VALUES ($1, 1, 1, 10, 10, 'Summary 1', NOW())`
|
|
: `INSERT INTO conversation_summaries (conversation_id, chunk_number, messages_start_id, messages_end_id, messages_count, summary, created_at)
|
|
VALUES (?, 1, 1, 10, 10, 'Summary 1', datetime('now'))`,
|
|
[testConversationId]
|
|
);
|
|
await dbRun(
|
|
isPostgres()
|
|
? `INSERT INTO conversation_summaries (conversation_id, chunk_number, messages_start_id, messages_end_id, messages_count, summary, created_at)
|
|
VALUES ($1, 2, 11, 20, 10, 'Summary 2', NOW())`
|
|
: `INSERT INTO conversation_summaries (conversation_id, chunk_number, messages_start_id, messages_end_id, messages_count, summary, created_at)
|
|
VALUES (?, 2, 11, 20, 10, 'Summary 2', datetime('now'))`,
|
|
[testConversationId]
|
|
);
|
|
|
|
const count = await getSummarizedMessageCount(testConversationId);
|
|
expect(count).toBe(20);
|
|
});
|
|
});
|
|
|
|
describe('shouldCreateSummary()', () => {
|
|
it('returns false when less than CHUNK_SIZE + KEEP_RECENT messages', async () => {
|
|
// Less than 10 + 5 = 15 messages
|
|
await insertMessages(testConversationId, testUserId, 14);
|
|
|
|
const should = await shouldCreateSummary(testConversationId);
|
|
expect(should).toBe(false);
|
|
});
|
|
|
|
it('returns true when CHUNK_SIZE + KEEP_RECENT unsummarized messages exist', async () => {
|
|
// Exactly 15 messages = CHUNK_SIZE(10) + KEEP_RECENT(5)
|
|
await insertMessages(testConversationId, testUserId, 15);
|
|
|
|
const should = await shouldCreateSummary(testConversationId);
|
|
expect(should).toBe(true);
|
|
});
|
|
|
|
it('returns false when all eligible messages are summarized', async () => {
|
|
// 25 messages, but 20 are summarized
|
|
await insertMessages(testConversationId, testUserId, 25);
|
|
|
|
// Add summary for first 20 messages
|
|
await dbRun(
|
|
isPostgres()
|
|
? `INSERT INTO conversation_summaries (conversation_id, chunk_number, messages_start_id, messages_end_id, messages_count, summary, created_at)
|
|
VALUES ($1, 1, 1, 10, 10, 'Summary 1', NOW())`
|
|
: `INSERT INTO conversation_summaries (conversation_id, chunk_number, messages_start_id, messages_end_id, messages_count, summary, created_at)
|
|
VALUES (?, 1, 1, 10, 10, 'Summary 1', datetime('now'))`,
|
|
[testConversationId]
|
|
);
|
|
await dbRun(
|
|
isPostgres()
|
|
? `INSERT INTO conversation_summaries (conversation_id, chunk_number, messages_start_id, messages_end_id, messages_count, summary, created_at)
|
|
VALUES ($1, 2, 11, 20, 10, 'Summary 2', NOW())`
|
|
: `INSERT INTO conversation_summaries (conversation_id, chunk_number, messages_start_id, messages_end_id, messages_count, summary, created_at)
|
|
VALUES (?, 2, 11, 20, 10, 'Summary 2', datetime('now'))`,
|
|
[testConversationId]
|
|
);
|
|
|
|
// 25 total - 20 summarized = 5 unsummarized (= KEEP_RECENT, so no new summary needed)
|
|
const should = await shouldCreateSummary(testConversationId);
|
|
expect(should).toBe(false);
|
|
});
|
|
|
|
it('returns true when new chunk of unsummarized messages exists', async () => {
|
|
// 35 messages, first 20 summarized
|
|
await insertMessages(testConversationId, testUserId, 35);
|
|
|
|
// Add summary for first 20 messages
|
|
await dbRun(
|
|
isPostgres()
|
|
? `INSERT INTO conversation_summaries (conversation_id, chunk_number, messages_start_id, messages_end_id, messages_count, summary, created_at)
|
|
VALUES ($1, 1, 1, 10, 10, 'Summary 1', NOW())`
|
|
: `INSERT INTO conversation_summaries (conversation_id, chunk_number, messages_start_id, messages_end_id, messages_count, summary, created_at)
|
|
VALUES (?, 1, 1, 10, 10, 'Summary 1', datetime('now'))`,
|
|
[testConversationId]
|
|
);
|
|
await dbRun(
|
|
isPostgres()
|
|
? `INSERT INTO conversation_summaries (conversation_id, chunk_number, messages_start_id, messages_end_id, messages_count, summary, created_at)
|
|
VALUES ($1, 2, 11, 20, 10, 'Summary 2', NOW())`
|
|
: `INSERT INTO conversation_summaries (conversation_id, chunk_number, messages_start_id, messages_end_id, messages_count, summary, created_at)
|
|
VALUES (?, 2, 11, 20, 10, 'Summary 2', datetime('now'))`,
|
|
[testConversationId]
|
|
);
|
|
|
|
// 35 total - 20 summarized - 5 keep_recent = 10 unsummarized >= CHUNK_SIZE
|
|
const should = await shouldCreateSummary(testConversationId);
|
|
expect(should).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('createSummary()', () => {
|
|
it('creates summary for oldest unsummarized chunk and saves to DB', async () => {
|
|
// Create 20 messages
|
|
const messageIds = await insertMessages(testConversationId, testUserId, 20);
|
|
|
|
// Mock the AI summary generation (we'll inject this later)
|
|
const mockSummaryText = 'User discussed testing the chunking service.';
|
|
|
|
// Create summary (should summarize first 10 messages)
|
|
const summary = await createSummary(testConversationId, () => Promise.resolve(mockSummaryText));
|
|
|
|
expect(summary).toBeDefined();
|
|
expect(summary.chunk_number).toBe(1);
|
|
expect(summary.messages_count).toBe(CHUNK_SIZE);
|
|
expect(summary.summary).toBe(mockSummaryText);
|
|
|
|
// Verify it was saved to DB
|
|
const saved = await dbGet(
|
|
isPostgres()
|
|
? 'SELECT * FROM conversation_summaries WHERE conversation_id = $1 AND chunk_number = 1'
|
|
: 'SELECT * FROM conversation_summaries WHERE conversation_id = ? AND chunk_number = 1',
|
|
[testConversationId]
|
|
);
|
|
expect(saved).toBeDefined();
|
|
expect(saved.summary).toBe(mockSummaryText);
|
|
});
|
|
|
|
it('creates next chunk summary when previous chunks are summarized', async () => {
|
|
// Create 30 messages
|
|
await insertMessages(testConversationId, testUserId, 30);
|
|
|
|
// Add summary for first chunk
|
|
await dbRun(
|
|
isPostgres()
|
|
? `INSERT INTO conversation_summaries (conversation_id, chunk_number, messages_start_id, messages_end_id, messages_count, summary, created_at)
|
|
VALUES ($1, 1, 1, 10, 10, 'First chunk summary', NOW())`
|
|
: `INSERT INTO conversation_summaries (conversation_id, chunk_number, messages_start_id, messages_end_id, messages_count, summary, created_at)
|
|
VALUES (?, 1, 1, 10, 10, 'First chunk summary', datetime('now'))`,
|
|
[testConversationId]
|
|
);
|
|
|
|
// Create summary for second chunk
|
|
const summary = await createSummary(testConversationId, () => Promise.resolve('Second chunk summary'));
|
|
|
|
expect(summary.chunk_number).toBe(2);
|
|
expect(summary.summary).toBe('Second chunk summary');
|
|
});
|
|
|
|
it('returns null when no summarization needed', async () => {
|
|
// Only 10 messages (less than CHUNK_SIZE + KEEP_RECENT)
|
|
await insertMessages(testConversationId, testUserId, 10);
|
|
|
|
const summary = await createSummary(testConversationId, () => Promise.resolve('Should not be called'));
|
|
|
|
expect(summary).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('buildAIContext()', () => {
|
|
it('returns empty context for empty conversation', async () => {
|
|
const context = await buildAIContext(testConversationId);
|
|
|
|
expect(context.summaries).toEqual([]);
|
|
expect(context.recentMessages).toEqual([]);
|
|
expect(context.totalMessages).toBe(0);
|
|
});
|
|
|
|
it('returns only recent messages when no summaries exist', async () => {
|
|
// Create 8 messages (less than would trigger summary)
|
|
await insertMessages(testConversationId, testUserId, 8);
|
|
|
|
const context = await buildAIContext(testConversationId);
|
|
|
|
expect(context.summaries).toEqual([]);
|
|
expect(context.recentMessages).toHaveLength(8);
|
|
expect(context.totalMessages).toBe(8);
|
|
});
|
|
|
|
it('returns summaries + recent messages for long conversations', async () => {
|
|
// Create 25 messages
|
|
await insertMessages(testConversationId, testUserId, 25);
|
|
|
|
// Add summaries for first 20
|
|
await dbRun(
|
|
isPostgres()
|
|
? `INSERT INTO conversation_summaries (conversation_id, chunk_number, messages_start_id, messages_end_id, messages_count, summary, created_at)
|
|
VALUES ($1, 1, 1, 10, 10, 'Chunk 1 summary', NOW())`
|
|
: `INSERT INTO conversation_summaries (conversation_id, chunk_number, messages_start_id, messages_end_id, messages_count, summary, created_at)
|
|
VALUES (?, 1, 1, 10, 10, 'Chunk 1 summary', datetime('now'))`,
|
|
[testConversationId]
|
|
);
|
|
await dbRun(
|
|
isPostgres()
|
|
? `INSERT INTO conversation_summaries (conversation_id, chunk_number, messages_start_id, messages_end_id, messages_count, summary, created_at)
|
|
VALUES ($1, 2, 11, 20, 10, 'Chunk 2 summary', NOW())`
|
|
: `INSERT INTO conversation_summaries (conversation_id, chunk_number, messages_start_id, messages_end_id, messages_count, summary, created_at)
|
|
VALUES (?, 2, 11, 20, 10, 'Chunk 2 summary', datetime('now'))`,
|
|
[testConversationId]
|
|
);
|
|
|
|
const context = await buildAIContext(testConversationId);
|
|
|
|
// Should have 2 summaries
|
|
expect(context.summaries).toHaveLength(2);
|
|
expect(context.summaries[0]).toBe('Chunk 1 summary');
|
|
expect(context.summaries[1]).toBe('Chunk 2 summary');
|
|
|
|
// Should have recent messages (KEEP_RECENT + some buffer)
|
|
expect(context.recentMessages.length).toBeGreaterThanOrEqual(KEEP_RECENT_MESSAGES);
|
|
|
|
// Total messages count
|
|
expect(context.totalMessages).toBe(25);
|
|
expect(context.summarizedMessages).toBe(20);
|
|
});
|
|
|
|
it('returns system context string for AI prompt', async () => {
|
|
// Create messages and summaries
|
|
await insertMessages(testConversationId, testUserId, 15);
|
|
await dbRun(
|
|
isPostgres()
|
|
? `INSERT INTO conversation_summaries (conversation_id, chunk_number, messages_start_id, messages_end_id, messages_count, summary, created_at)
|
|
VALUES ($1, 1, 1, 10, 10, 'User asked about login bug', NOW())`
|
|
: `INSERT INTO conversation_summaries (conversation_id, chunk_number, messages_start_id, messages_end_id, messages_count, summary, created_at)
|
|
VALUES (?, 1, 1, 10, 10, 'User asked about login bug', datetime('now'))`,
|
|
[testConversationId]
|
|
);
|
|
|
|
const context = await buildAIContext(testConversationId);
|
|
|
|
expect(context.systemContext).toContain('Previous conversation summary');
|
|
expect(context.systemContext).toContain('User asked about login bug');
|
|
});
|
|
});
|
|
});
|