- Updated all package.json name fields and dependency references - Updated all TypeScript/JavaScript imports - Updated .woodpecker/publish.yml filters and registry paths - Updated tools/install.sh scope default - Updated .npmrc registry paths (worktree + host) - Enhanced update-checker.ts with checkForAllUpdates() multi-package support - Updated CLI update command to show table of all packages - Added KNOWN_PACKAGES, formatAllPackagesTable, getInstallAllCommand - Marked checkForUpdate() with @deprecated JSDoc Closes #391
606 lines
22 KiB
TypeScript
606 lines
22 KiB
TypeScript
/**
|
|
* Integration tests for conversation persistence and context resume (M1-008).
|
|
*
|
|
* Verifies the full flow end-to-end using in-memory mocks:
|
|
* 1. User messages are persisted when sent via ChatGateway.
|
|
* 2. Assistant responses are persisted with metadata on agent:end.
|
|
* 3. Conversation history is loaded and injected into context on session resume.
|
|
* 4. The search endpoint returns matching messages.
|
|
*/
|
|
|
|
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
|
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
|
import type { ConversationHistoryMessage } from '../agent/agent.service.js';
|
|
import { ConversationsController } from '../conversations/conversations.controller.js';
|
|
import type { Message } from '@mosaicstack/brain';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Shared test data
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const USER_ID = 'user-test-001';
|
|
const CONV_ID = 'conv-test-001';
|
|
|
|
function makeConversation(overrides?: Record<string, unknown>) {
|
|
return {
|
|
id: CONV_ID,
|
|
userId: USER_ID,
|
|
title: null,
|
|
projectId: null,
|
|
archived: false,
|
|
createdAt: new Date('2026-01-01T00:00:00Z'),
|
|
updatedAt: new Date('2026-01-01T00:00:00Z'),
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function makeMessage(
|
|
role: 'user' | 'assistant' | 'system',
|
|
content: string,
|
|
overrides?: Record<string, unknown>,
|
|
) {
|
|
return {
|
|
id: `msg-${role}-${Math.random().toString(36).slice(2)}`,
|
|
conversationId: CONV_ID,
|
|
role,
|
|
content,
|
|
metadata: null,
|
|
createdAt: new Date('2026-01-01T00:01:00Z'),
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helper: build a mock ConversationsRepo
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function createMockBrain(options?: {
|
|
conversation?: ReturnType<typeof makeConversation> | undefined;
|
|
messages?: ReturnType<typeof makeMessage>[];
|
|
searchResults?: Array<{
|
|
messageId: string;
|
|
conversationId: string;
|
|
conversationTitle: string | null;
|
|
role: 'user' | 'assistant' | 'system';
|
|
content: string;
|
|
createdAt: Date;
|
|
}>;
|
|
}) {
|
|
const conversation = options?.conversation;
|
|
const messages = options?.messages ?? [];
|
|
const searchResults = options?.searchResults ?? [];
|
|
|
|
return {
|
|
conversations: {
|
|
findAll: vi.fn().mockResolvedValue(conversation ? [conversation] : []),
|
|
findById: vi.fn().mockResolvedValue(conversation),
|
|
create: vi.fn().mockResolvedValue(conversation ?? makeConversation()),
|
|
update: vi.fn().mockResolvedValue(conversation),
|
|
remove: vi.fn().mockResolvedValue(true),
|
|
findMessages: vi.fn().mockResolvedValue(messages),
|
|
addMessage: vi.fn().mockImplementation((data: unknown) => {
|
|
const d = data as {
|
|
conversationId: string;
|
|
role: 'user' | 'assistant' | 'system';
|
|
content: string;
|
|
metadata?: Record<string, unknown>;
|
|
};
|
|
return Promise.resolve(makeMessage(d.role, d.content, { metadata: d.metadata ?? null }));
|
|
}),
|
|
searchMessages: vi.fn().mockResolvedValue(searchResults),
|
|
},
|
|
};
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 1. ConversationsRepo: addMessage persists user message
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('ConversationsRepo.addMessage — user message persistence', () => {
|
|
it('persists a user message and returns the saved record', async () => {
|
|
const brain = createMockBrain({ conversation: makeConversation() });
|
|
|
|
const result = await brain.conversations.addMessage(
|
|
{
|
|
conversationId: CONV_ID,
|
|
role: 'user',
|
|
content: 'Hello, agent!',
|
|
metadata: { timestamp: '2026-01-01T00:01:00.000Z' },
|
|
},
|
|
USER_ID,
|
|
);
|
|
|
|
expect(brain.conversations.addMessage).toHaveBeenCalledOnce();
|
|
expect(result).toBeDefined();
|
|
expect(result!.role).toBe('user');
|
|
expect(result!.content).toBe('Hello, agent!');
|
|
expect(result!.conversationId).toBe(CONV_ID);
|
|
});
|
|
|
|
it('returns undefined when conversation does not belong to the user', async () => {
|
|
// Simulate the repo enforcement: ownership mismatch returns undefined
|
|
const brain = createMockBrain({ conversation: undefined });
|
|
brain.conversations.addMessage = vi.fn().mockResolvedValue(undefined);
|
|
|
|
const result = await brain.conversations.addMessage(
|
|
{ conversationId: CONV_ID, role: 'user', content: 'Hello' },
|
|
'other-user',
|
|
);
|
|
|
|
expect(result).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 2. ConversationsRepo.addMessage — assistant response with metadata
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('ConversationsRepo.addMessage — assistant response metadata', () => {
|
|
it('persists assistant message with model, provider, tokens and toolCalls metadata', async () => {
|
|
const assistantMetadata = {
|
|
timestamp: '2026-01-01T00:02:00.000Z',
|
|
model: 'claude-3-5-sonnet-20241022',
|
|
provider: 'anthropic',
|
|
toolCalls: [
|
|
{
|
|
toolCallId: 'tc-001',
|
|
toolName: 'read_file',
|
|
args: { path: '/foo/bar.ts' },
|
|
isError: false,
|
|
},
|
|
],
|
|
tokenUsage: {
|
|
input: 1000,
|
|
output: 250,
|
|
cacheRead: 0,
|
|
cacheWrite: 0,
|
|
total: 1250,
|
|
},
|
|
};
|
|
|
|
const brain = createMockBrain({ conversation: makeConversation() });
|
|
|
|
const result = await brain.conversations.addMessage(
|
|
{
|
|
conversationId: CONV_ID,
|
|
role: 'assistant',
|
|
content: 'Here is the file content you requested.',
|
|
metadata: assistantMetadata,
|
|
},
|
|
USER_ID,
|
|
);
|
|
|
|
expect(result).toBeDefined();
|
|
expect(result!.role).toBe('assistant');
|
|
expect(result!.content).toBe('Here is the file content you requested.');
|
|
expect(result!.metadata).toMatchObject({
|
|
model: 'claude-3-5-sonnet-20241022',
|
|
provider: 'anthropic',
|
|
tokenUsage: { input: 1000, output: 250, total: 1250 },
|
|
});
|
|
expect((result!.metadata as Record<string, unknown>)['toolCalls']).toHaveLength(1);
|
|
expect(
|
|
(
|
|
(result!.metadata as Record<string, unknown>)['toolCalls'] as Array<Record<string, unknown>>
|
|
)[0]!['toolName'],
|
|
).toBe('read_file');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 3. ChatGateway.loadConversationHistory — session resume loads history
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('Conversation resume — history loading', () => {
|
|
it('maps DB messages to ConversationHistoryMessage shape', () => {
|
|
// Simulate what ChatGateway.loadConversationHistory does:
|
|
// convert DB Message rows to ConversationHistoryMessage for context injection.
|
|
const dbMessages = [
|
|
makeMessage('user', 'What is the capital of France?', {
|
|
createdAt: new Date('2026-01-01T00:01:00Z'),
|
|
}),
|
|
makeMessage('assistant', 'The capital of France is Paris.', {
|
|
createdAt: new Date('2026-01-01T00:01:05Z'),
|
|
}),
|
|
makeMessage('user', 'And Germany?', { createdAt: new Date('2026-01-01T00:02:00Z') }),
|
|
makeMessage('assistant', 'The capital of Germany is Berlin.', {
|
|
createdAt: new Date('2026-01-01T00:02:05Z'),
|
|
}),
|
|
];
|
|
|
|
// Replicate the mapping logic from ChatGateway
|
|
const history: ConversationHistoryMessage[] = dbMessages.map((msg) => ({
|
|
role: msg.role as 'user' | 'assistant' | 'system',
|
|
content: msg.content,
|
|
createdAt: msg.createdAt,
|
|
}));
|
|
|
|
expect(history).toHaveLength(4);
|
|
expect(history[0]).toEqual({
|
|
role: 'user',
|
|
content: 'What is the capital of France?',
|
|
createdAt: new Date('2026-01-01T00:01:00Z'),
|
|
});
|
|
expect(history[1]).toEqual({
|
|
role: 'assistant',
|
|
content: 'The capital of France is Paris.',
|
|
createdAt: new Date('2026-01-01T00:01:05Z'),
|
|
});
|
|
expect(history[2]!.role).toBe('user');
|
|
expect(history[3]!.role).toBe('assistant');
|
|
});
|
|
|
|
it('returns empty array when conversation has no messages', async () => {
|
|
const brain = createMockBrain({ conversation: makeConversation(), messages: [] });
|
|
|
|
const messages = await brain.conversations.findMessages(CONV_ID, USER_ID);
|
|
expect(messages).toHaveLength(0);
|
|
|
|
// Gateway produces empty history → no context injection
|
|
const history: ConversationHistoryMessage[] = (messages as Message[]).map((msg) => ({
|
|
role: msg.role as 'user' | 'assistant' | 'system',
|
|
content: msg.content,
|
|
createdAt: msg.createdAt,
|
|
}));
|
|
expect(history).toHaveLength(0);
|
|
});
|
|
|
|
it('returns empty array when conversation does not belong to the user', async () => {
|
|
const brain = createMockBrain({ conversation: undefined });
|
|
brain.conversations.findMessages = vi.fn().mockResolvedValue([]);
|
|
|
|
const messages = await brain.conversations.findMessages(CONV_ID, 'other-user');
|
|
expect(messages).toHaveLength(0);
|
|
});
|
|
|
|
it('preserves message order (ascending by createdAt)', async () => {
|
|
const ordered = [
|
|
makeMessage('user', 'First', { createdAt: new Date('2026-01-01T00:01:00Z') }),
|
|
makeMessage('assistant', 'Second', { createdAt: new Date('2026-01-01T00:01:05Z') }),
|
|
makeMessage('user', 'Third', { createdAt: new Date('2026-01-01T00:02:00Z') }),
|
|
];
|
|
const brain = createMockBrain({ conversation: makeConversation(), messages: ordered });
|
|
|
|
const messages = await brain.conversations.findMessages(CONV_ID, USER_ID);
|
|
expect(messages[0]!.content).toBe('First');
|
|
expect(messages[1]!.content).toBe('Second');
|
|
expect(messages[2]!.content).toBe('Third');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 4. AgentService.buildHistoryPromptSection — context injection format
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('AgentService — buildHistoryPromptSection (context injection)', () => {
|
|
/**
|
|
* Replicate the private method logic to test it in isolation.
|
|
* The real method lives in AgentService but is private; we mirror the
|
|
* exact logic here so the test is independent of the service's constructor.
|
|
*/
|
|
function buildHistoryPromptSection(
|
|
history: ConversationHistoryMessage[],
|
|
contextWindow: number,
|
|
_sessionId: string,
|
|
): string {
|
|
const TOKEN_BUDGET = Math.floor(contextWindow * 0.8);
|
|
const HISTORY_HEADER = '## Conversation History (resumed session)\n\n';
|
|
|
|
const formatMessage = (msg: ConversationHistoryMessage): string => {
|
|
const roleLabel =
|
|
msg.role === 'user' ? 'User' : msg.role === 'assistant' ? 'Assistant' : 'System';
|
|
return `**${roleLabel}:** ${msg.content}`;
|
|
};
|
|
|
|
const estimateTokens = (text: string) => Math.ceil(text.length / 4);
|
|
|
|
const formatted = history.map((msg) => formatMessage(msg));
|
|
const fullHistory = formatted.join('\n\n');
|
|
const fullTokens = estimateTokens(HISTORY_HEADER + fullHistory);
|
|
|
|
if (fullTokens <= TOKEN_BUDGET) {
|
|
return HISTORY_HEADER + fullHistory;
|
|
}
|
|
|
|
// History exceeds budget — summarize oldest messages, keep recent verbatim
|
|
const SUMMARY_RESERVE = Math.floor(TOKEN_BUDGET * 0.2);
|
|
const verbatimBudget = TOKEN_BUDGET - SUMMARY_RESERVE;
|
|
|
|
let verbatimTokens = 0;
|
|
let verbatimCutIndex = history.length;
|
|
for (let i = history.length - 1; i >= 0; i--) {
|
|
const t = estimateTokens(formatted[i]!);
|
|
if (verbatimTokens + t > verbatimBudget) break;
|
|
verbatimTokens += t;
|
|
verbatimCutIndex = i;
|
|
}
|
|
|
|
const summarizedMessages = history.slice(0, verbatimCutIndex);
|
|
const verbatimMessages = history.slice(verbatimCutIndex);
|
|
|
|
let summaryText = '';
|
|
if (summarizedMessages.length > 0) {
|
|
const topics = summarizedMessages
|
|
.filter((m) => m.role === 'user')
|
|
.map((m) => m.content.slice(0, 120).replace(/\n/g, ' '))
|
|
.join('; ');
|
|
summaryText =
|
|
`**Previous conversation summary** (${summarizedMessages.length} messages omitted for brevity):\n` +
|
|
`Topics discussed: ${topics || '(no user messages in summarized portion)'}`;
|
|
}
|
|
|
|
const verbatimSection = verbatimMessages.map((m) => formatMessage(m)).join('\n\n');
|
|
|
|
const parts: string[] = [HISTORY_HEADER];
|
|
if (summaryText) parts.push(summaryText);
|
|
if (verbatimSection) parts.push(verbatimSection);
|
|
|
|
return parts.join('\n\n');
|
|
}
|
|
|
|
it('includes header and all messages when history fits within context budget', () => {
|
|
const history: ConversationHistoryMessage[] = [
|
|
{ role: 'user', content: 'Hello', createdAt: new Date() },
|
|
{ role: 'assistant', content: 'Hi there!', createdAt: new Date() },
|
|
];
|
|
|
|
const result = buildHistoryPromptSection(history, 8192, 'session-1');
|
|
|
|
expect(result).toContain('## Conversation History (resumed session)');
|
|
expect(result).toContain('**User:** Hello');
|
|
expect(result).toContain('**Assistant:** Hi there!');
|
|
});
|
|
|
|
it('labels roles correctly (user, assistant, system)', () => {
|
|
const history: ConversationHistoryMessage[] = [
|
|
{ role: 'system', content: 'You are helpful.', createdAt: new Date() },
|
|
{ role: 'user', content: 'Ping', createdAt: new Date() },
|
|
{ role: 'assistant', content: 'Pong', createdAt: new Date() },
|
|
];
|
|
|
|
const result = buildHistoryPromptSection(history, 8192, 'session-2');
|
|
|
|
expect(result).toContain('**System:** You are helpful.');
|
|
expect(result).toContain('**User:** Ping');
|
|
expect(result).toContain('**Assistant:** Pong');
|
|
});
|
|
|
|
it('summarizes old messages when history exceeds 80% of context window', () => {
|
|
// Create enough messages to exceed a tiny context window budget
|
|
const longContent = 'A'.repeat(200);
|
|
const history: ConversationHistoryMessage[] = Array.from({ length: 20 }, (_, i) => ({
|
|
role: (i % 2 === 0 ? 'user' : 'assistant') as 'user' | 'assistant',
|
|
content: `${longContent} message ${i}`,
|
|
createdAt: new Date(),
|
|
}));
|
|
|
|
// Use a small context window so history definitely exceeds 80%
|
|
const result = buildHistoryPromptSection(history, 512, 'session-3');
|
|
|
|
// Should contain the summary prefix
|
|
expect(result).toContain('messages omitted for brevity');
|
|
expect(result).toContain('Topics discussed:');
|
|
});
|
|
|
|
it('returns only header for empty history', () => {
|
|
const result = buildHistoryPromptSection([], 8192, 'session-4');
|
|
// With empty history, the full history join is '' and the section is just the header
|
|
expect(result).toContain('## Conversation History (resumed session)');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 5. ConversationsController.search — GET /api/conversations/search
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('ConversationsController — search endpoint', () => {
|
|
let brain: ReturnType<typeof createMockBrain>;
|
|
let controller: ConversationsController;
|
|
|
|
beforeEach(() => {
|
|
const searchResults = [
|
|
{
|
|
messageId: 'msg-001',
|
|
conversationId: CONV_ID,
|
|
conversationTitle: 'Test Chat',
|
|
role: 'user' as const,
|
|
content: 'What is the capital of France?',
|
|
createdAt: new Date('2026-01-01T00:01:00Z'),
|
|
},
|
|
{
|
|
messageId: 'msg-002',
|
|
conversationId: CONV_ID,
|
|
conversationTitle: 'Test Chat',
|
|
role: 'assistant' as const,
|
|
content: 'The capital of France is Paris.',
|
|
createdAt: new Date('2026-01-01T00:01:05Z'),
|
|
},
|
|
];
|
|
brain = createMockBrain({ searchResults });
|
|
controller = new ConversationsController(brain as never);
|
|
});
|
|
|
|
it('returns matching messages for a valid search query', async () => {
|
|
const results = await controller.search({ q: 'France' }, { id: USER_ID });
|
|
|
|
expect(brain.conversations.searchMessages).toHaveBeenCalledWith(USER_ID, 'France', 20, 0);
|
|
expect(results).toHaveLength(2);
|
|
expect(results[0]).toMatchObject({
|
|
messageId: 'msg-001',
|
|
role: 'user',
|
|
content: 'What is the capital of France?',
|
|
});
|
|
expect(results[1]).toMatchObject({
|
|
messageId: 'msg-002',
|
|
role: 'assistant',
|
|
content: 'The capital of France is Paris.',
|
|
});
|
|
});
|
|
|
|
it('uses custom limit and offset when provided', async () => {
|
|
await controller.search({ q: 'Paris', limit: 5, offset: 10 }, { id: USER_ID });
|
|
|
|
expect(brain.conversations.searchMessages).toHaveBeenCalledWith(USER_ID, 'Paris', 5, 10);
|
|
});
|
|
|
|
it('throws BadRequestException when query is empty', async () => {
|
|
await expect(controller.search({ q: '' }, { id: USER_ID })).rejects.toBeInstanceOf(
|
|
BadRequestException,
|
|
);
|
|
await expect(controller.search({ q: ' ' }, { id: USER_ID })).rejects.toBeInstanceOf(
|
|
BadRequestException,
|
|
);
|
|
});
|
|
|
|
it('trims whitespace from query before passing to repo', async () => {
|
|
await controller.search({ q: ' Berlin ' }, { id: USER_ID });
|
|
|
|
expect(brain.conversations.searchMessages).toHaveBeenCalledWith(
|
|
USER_ID,
|
|
'Berlin',
|
|
expect.any(Number),
|
|
expect.any(Number),
|
|
);
|
|
});
|
|
|
|
it('returns empty array when no messages match', async () => {
|
|
brain.conversations.searchMessages = vi.fn().mockResolvedValue([]);
|
|
|
|
const results = await controller.search({ q: 'xyzzy-no-match' }, { id: USER_ID });
|
|
|
|
expect(results).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 6. ConversationsController — messages CRUD
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('ConversationsController — message CRUD', () => {
|
|
it('listMessages returns 404 when conversation is not owned by user', async () => {
|
|
const brain = createMockBrain({ conversation: undefined });
|
|
const controller = new ConversationsController(brain as never);
|
|
|
|
await expect(controller.listMessages(CONV_ID, { id: USER_ID })).rejects.toBeInstanceOf(
|
|
NotFoundException,
|
|
);
|
|
});
|
|
|
|
it('listMessages returns the messages for an owned conversation', async () => {
|
|
const msgs = [makeMessage('user', 'Test message'), makeMessage('assistant', 'Test reply')];
|
|
const brain = createMockBrain({ conversation: makeConversation(), messages: msgs });
|
|
const controller = new ConversationsController(brain as never);
|
|
|
|
const result = await controller.listMessages(CONV_ID, { id: USER_ID });
|
|
|
|
expect(result).toHaveLength(2);
|
|
expect(result[0]!.role).toBe('user');
|
|
expect(result[1]!.role).toBe('assistant');
|
|
});
|
|
|
|
it('addMessage returns the persisted message', async () => {
|
|
const brain = createMockBrain({ conversation: makeConversation() });
|
|
const controller = new ConversationsController(brain as never);
|
|
|
|
const result = await controller.addMessage(
|
|
CONV_ID,
|
|
{ role: 'user', content: 'Persisted content' },
|
|
{ id: USER_ID },
|
|
);
|
|
|
|
expect(result).toBeDefined();
|
|
expect(result.role).toBe('user');
|
|
expect(result.content).toBe('Persisted content');
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 7. End-to-end persistence flow simulation
|
|
// ---------------------------------------------------------------------------
|
|
|
|
describe('End-to-end persistence flow', () => {
|
|
it('simulates a full conversation: persist user message → persist assistant response → resume with history', async () => {
|
|
// ── Step 1: Conversation is created ────────────────────────────────────
|
|
const brain = createMockBrain({ conversation: makeConversation() });
|
|
|
|
await brain.conversations.create({ id: CONV_ID, userId: USER_ID });
|
|
expect(brain.conversations.create).toHaveBeenCalledOnce();
|
|
|
|
// ── Step 2: User message is persisted ──────────────────────────────────
|
|
const userMsg = await brain.conversations.addMessage(
|
|
{
|
|
conversationId: CONV_ID,
|
|
role: 'user',
|
|
content: 'Explain monads in simple terms.',
|
|
metadata: { timestamp: '2026-01-01T00:01:00.000Z' },
|
|
},
|
|
USER_ID,
|
|
);
|
|
|
|
expect(userMsg).toBeDefined();
|
|
expect(userMsg!.role).toBe('user');
|
|
|
|
// ── Step 3: Assistant response is persisted with metadata ───────────────
|
|
const assistantMeta = {
|
|
timestamp: '2026-01-01T00:01:10.000Z',
|
|
model: 'claude-3-5-sonnet-20241022',
|
|
provider: 'anthropic',
|
|
toolCalls: [],
|
|
tokenUsage: { input: 500, output: 120, cacheRead: 0, cacheWrite: 0, total: 620 },
|
|
};
|
|
|
|
const assistantMsg = await brain.conversations.addMessage(
|
|
{
|
|
conversationId: CONV_ID,
|
|
role: 'assistant',
|
|
content: 'A monad is a design pattern that wraps values in a context...',
|
|
metadata: assistantMeta,
|
|
},
|
|
USER_ID,
|
|
);
|
|
|
|
expect(assistantMsg).toBeDefined();
|
|
expect(assistantMsg!.role).toBe('assistant');
|
|
|
|
// ── Step 4: On session resume, history is loaded ────────────────────────
|
|
const storedMessages = [
|
|
makeMessage('user', 'Explain monads in simple terms.', {
|
|
createdAt: new Date('2026-01-01T00:01:00Z'),
|
|
metadata: { timestamp: '2026-01-01T00:01:00.000Z' },
|
|
}),
|
|
makeMessage('assistant', 'A monad is a design pattern that wraps values in a context...', {
|
|
createdAt: new Date('2026-01-01T00:01:10Z'),
|
|
metadata: assistantMeta,
|
|
}),
|
|
];
|
|
|
|
brain.conversations.findMessages = vi.fn().mockResolvedValue(storedMessages);
|
|
|
|
const dbMessages = await brain.conversations.findMessages(CONV_ID, USER_ID);
|
|
expect(dbMessages).toHaveLength(2);
|
|
|
|
// ── Step 5: History is mapped for context injection ─────────────────────
|
|
const history: ConversationHistoryMessage[] = (dbMessages as Message[]).map((msg) => ({
|
|
role: msg.role as 'user' | 'assistant' | 'system',
|
|
content: msg.content,
|
|
createdAt: msg.createdAt,
|
|
}));
|
|
|
|
expect(history[0]).toMatchObject({
|
|
role: 'user',
|
|
content: 'Explain monads in simple terms.',
|
|
});
|
|
expect(history[1]).toMatchObject({
|
|
role: 'assistant',
|
|
content: 'A monad is a design pattern that wraps values in a context...',
|
|
});
|
|
|
|
// ── Step 6: History roles are valid for injection ───────────────────────
|
|
for (const msg of history) {
|
|
expect(['user', 'assistant', 'system']).toContain(msg.role);
|
|
expect(typeof msg.content).toBe('string');
|
|
expect(msg.createdAt).toBeInstanceOf(Date);
|
|
}
|
|
});
|
|
});
|