Files
Synapsis/src/lib/bots/contentGenerator.property.test.ts
T
AskIt 8ad3b97b7e feat(bots): Implement comprehensive bot system with autonomous posting, content management, and API endpoints
- Add bot management system with creation, suspension, and reinstatement functionality
- Implement autonomous bot posting with scheduling, rate limiting, and content generation
- Add content fetching system supporting RSS feeds and multiple content sources
- Implement LLM-based content generation with customizable bot personalities
- Add mention handling and automated response system for bot interactions
- Implement API key management with encryption using AUTH_SECRET for simplified deployment
- Add comprehensive bot logging system for activity tracking and error monitoring
- Create bot administration pages and settings UI for managing bot configurations
- Add database migrations for bot system schema including users, sources, and content items
- Implement cron job system for automated bot operations and scheduled tasks
- Add extensive test coverage with unit and property-based tests for core bot modules
- Simplify encryption by deriving keys from AUTH_SECRET instead of separate environment variable
- Implement automatic content fetching on post trigger with retry logic
- Add Reddit-specific link preview handling using oEmbed API for reliable metadata extraction
- Create utility scripts for bot inspection and cleanup operations
- Add comprehensive bot system documentation and improvement tracking
2026-01-25 16:22:41 +01:00

2183 lines
75 KiB
TypeScript

/**
* Property-Based Tests for Content Generator Module
*
* Feature: bot-system
* - Property 10: Personality in LLM Prompts
*
* Tests that personality configuration is included in all LLM calls.
*
* **Validates: Requirements 3.2, 3.5**
*/
import { describe, it, expect, vi } from 'vitest';
import * as fc from 'fast-check';
import {
ContentGenerator,
Bot,
ContentItem,
Post,
buildPostSystemPrompt,
buildReplySystemPrompt,
buildPostUserMessage,
} from './contentGenerator';
import { LLMClient, LLMCompletionRequest, LLMCompletionResponse } from './llmClient';
import { PersonalityConfig } from './personality';
import { LLMProvider } from './encryption';
// ============================================
// GENERATORS
// ============================================
/**
* Generator for valid system prompts.
*/
const systemPromptArb = fc.string({
minLength: 10,
maxLength: 500,
}).filter(s => s.trim().length >= 10);
/**
* Generator for valid temperature values (0-2).
*/
const temperatureArb = fc.double({
min: 0,
max: 2,
noNaN: true,
noDefaultInfinity: true,
});
/**
* Generator for valid maxTokens values.
*/
const maxTokensArb = fc.integer({
min: 1,
max: 4000,
});
/**
* Generator for optional response styles.
*/
const responseStyleArb = fc.option(
fc.string({ minLength: 1, maxLength: 100 }).filter(s => s.trim().length > 0),
{ nil: undefined }
);
/**
* Generator for valid personality configurations.
*/
const personalityConfigArb: fc.Arbitrary<PersonalityConfig> = fc.record({
systemPrompt: systemPromptArb,
temperature: temperatureArb,
maxTokens: maxTokensArb,
responseStyle: responseStyleArb,
});
/**
* Generator for LLM providers.
*/
const llmProviderArb: fc.Arbitrary<LLMProvider> = fc.constantFrom(
'openrouter' as LLMProvider,
'openai' as LLMProvider,
'anthropic' as LLMProvider
);
/**
* Generator for LLM model names.
*/
const llmModelArb = fc.oneof(
fc.constant('gpt-3.5-turbo'),
fc.constant('gpt-4'),
fc.constant('claude-3-haiku-20240307'),
fc.constant('claude-3-sonnet-20240229'),
fc.constant('openai/gpt-3.5-turbo')
);
/**
* Generator for bot configurations.
*/
const botArb: fc.Arbitrary<Bot> = fc.record({
id: fc.uuid(),
name: fc.string({ minLength: 1, maxLength: 50 }),
handle: fc.string({ minLength: 3, maxLength: 30 }).map(s => s.toLowerCase().replace(/[^a-z0-9]/g, '')),
personalityConfig: personalityConfigArb,
llmProvider: llmProviderArb,
llmModel: llmModelArb,
llmApiKeyEncrypted: fc.string({ minLength: 20, maxLength: 100 }),
});
/**
* Generator for content items.
*/
const contentItemArb: fc.Arbitrary<ContentItem> = fc.record({
id: fc.uuid(),
sourceId: fc.uuid(),
title: fc.string({ minLength: 5, maxLength: 200 }),
content: fc.option(fc.string({ minLength: 10, maxLength: 5000 }), { nil: null }),
url: fc.webUrl(),
publishedAt: fc.date(),
});
/**
* Generator for posts.
*/
const postArb: fc.Arbitrary<Post> = fc.record({
id: fc.uuid(),
userId: fc.uuid(),
content: fc.string({ minLength: 1, maxLength: 500 }),
createdAt: fc.date(),
author: fc.option(
fc.record({
handle: fc.string({ minLength: 3, maxLength: 30 }),
displayName: fc.option(fc.string({ minLength: 1, maxLength: 50 }), { nil: null }),
}),
{ nil: undefined }
),
});
// ============================================
// MOCK LLM CLIENT
// ============================================
/**
* Create a mock LLM client that captures requests.
*/
function createMockLLMClient(capturedRequests: LLMCompletionRequest[]): LLMClient {
const mockClient = {
generateCompletion: vi.fn(async (request: LLMCompletionRequest): Promise<LLMCompletionResponse> => {
// Capture the request for inspection
capturedRequests.push(request);
// Return a mock response
return {
content: 'Mock generated content',
tokensUsed: {
prompt: 100,
completion: 50,
total: 150,
},
model: 'mock-model',
provider: 'openai',
};
}),
getProvider: vi.fn(() => 'openai' as LLMProvider),
getModel: vi.fn(() => 'mock-model'),
} as unknown as LLMClient;
return mockClient;
}
// ============================================
// PROPERTY TESTS
// ============================================
describe('Feature: bot-system, Property 10: Personality in LLM Prompts', () => {
/**
* Property 10: Personality in LLM Prompts
*
* *For any* bot with a configured personality, all LLM calls (posts and replies)
* SHALL include the personality system prompt in the request.
*
* **Validates: Requirements 3.2, 3.5**
*/
it('generatePost includes personality system prompt in LLM request (Requirement 3.2)', async () => {
await fc.assert(
fc.asyncProperty(
botArb,
fc.option(contentItemArb, { nil: undefined }),
fc.option(fc.string({ maxLength: 200 }), { nil: undefined }),
async (bot, sourceContent, context) => {
const capturedRequests: LLMCompletionRequest[] = [];
const mockClient = createMockLLMClient(capturedRequests);
const generator = new ContentGenerator(bot, mockClient);
// Generate a post
await generator.generatePost(sourceContent, context);
// Verify that a request was made
expect(capturedRequests.length).toBe(1);
const request = capturedRequests[0];
// Verify that the request has messages
expect(request.messages).toBeDefined();
expect(request.messages.length).toBeGreaterThan(0);
// Find the system message
const systemMessage = request.messages.find(msg => msg.role === 'system');
// Verify that a system message exists
expect(systemMessage).toBeDefined();
expect(systemMessage?.content).toBeDefined();
// Verify that the system message includes the personality system prompt
// The system message should contain the bot's personality system prompt
expect(systemMessage?.content).toContain(bot.personalityConfig.systemPrompt);
}
),
{ numRuns: 100 }
);
});
it('generateReply includes personality system prompt in LLM request (Requirement 3.5)', async () => {
await fc.assert(
fc.asyncProperty(
botArb,
postArb,
fc.array(postArb, { maxLength: 5 }),
async (bot, mentionPost, conversationContext) => {
const capturedRequests: LLMCompletionRequest[] = [];
const mockClient = createMockLLMClient(capturedRequests);
const generator = new ContentGenerator(bot, mockClient);
// Generate a reply
await generator.generateReply(mentionPost, conversationContext);
// Verify that a request was made
expect(capturedRequests.length).toBe(1);
const request = capturedRequests[0];
// Verify that the request has messages
expect(request.messages).toBeDefined();
expect(request.messages.length).toBeGreaterThan(0);
// Find the system message
const systemMessage = request.messages.find(msg => msg.role === 'system');
// Verify that a system message exists
expect(systemMessage).toBeDefined();
expect(systemMessage?.content).toBeDefined();
// Verify that the system message includes the personality system prompt
expect(systemMessage?.content).toContain(bot.personalityConfig.systemPrompt);
}
),
{ numRuns: 100 }
);
});
it('personality system prompt is always the first message in post generation', async () => {
await fc.assert(
fc.asyncProperty(
botArb,
fc.option(contentItemArb, { nil: undefined }),
async (bot, sourceContent) => {
const capturedRequests: LLMCompletionRequest[] = [];
const mockClient = createMockLLMClient(capturedRequests);
const generator = new ContentGenerator(bot, mockClient);
// Generate a post
await generator.generatePost(sourceContent);
const request = capturedRequests[0];
// The first message should be a system message
expect(request.messages[0].role).toBe('system');
// The system message should contain the personality prompt
expect(request.messages[0].content).toContain(bot.personalityConfig.systemPrompt);
}
),
{ numRuns: 100 }
);
});
it('personality system prompt is always the first message in reply generation', async () => {
await fc.assert(
fc.asyncProperty(
botArb,
postArb,
async (bot, mentionPost) => {
const capturedRequests: LLMCompletionRequest[] = [];
const mockClient = createMockLLMClient(capturedRequests);
const generator = new ContentGenerator(bot, mockClient);
// Generate a reply
await generator.generateReply(mentionPost, []);
const request = capturedRequests[0];
// The first message should be a system message
expect(request.messages[0].role).toBe('system');
// The system message should contain the personality prompt
expect(request.messages[0].content).toContain(bot.personalityConfig.systemPrompt);
}
),
{ numRuns: 100 }
);
});
it('personality temperature is included in post generation request', async () => {
await fc.assert(
fc.asyncProperty(
botArb,
fc.option(contentItemArb, { nil: undefined }),
async (bot, sourceContent) => {
const capturedRequests: LLMCompletionRequest[] = [];
const mockClient = createMockLLMClient(capturedRequests);
const generator = new ContentGenerator(bot, mockClient);
// Generate a post
await generator.generatePost(sourceContent);
const request = capturedRequests[0];
// The request should include the personality temperature
expect(request.temperature).toBe(bot.personalityConfig.temperature);
}
),
{ numRuns: 100 }
);
});
it('personality temperature is included in reply generation request', async () => {
await fc.assert(
fc.asyncProperty(
botArb,
postArb,
async (bot, mentionPost) => {
const capturedRequests: LLMCompletionRequest[] = [];
const mockClient = createMockLLMClient(capturedRequests);
const generator = new ContentGenerator(bot, mockClient);
// Generate a reply
await generator.generateReply(mentionPost, []);
const request = capturedRequests[0];
// The request should include the personality temperature
expect(request.temperature).toBe(bot.personalityConfig.temperature);
}
),
{ numRuns: 100 }
);
});
it('personality maxTokens is respected in post generation request', async () => {
await fc.assert(
fc.asyncProperty(
botArb,
fc.option(contentItemArb, { nil: undefined }),
async (bot, sourceContent) => {
const capturedRequests: LLMCompletionRequest[] = [];
const mockClient = createMockLLMClient(capturedRequests);
const generator = new ContentGenerator(bot, mockClient);
// Generate a post
await generator.generatePost(sourceContent);
const request = capturedRequests[0];
// The request should include maxTokens from personality config or default
expect(request.maxTokens).toBeDefined();
// If bot has maxTokens configured, it should be used
if (bot.personalityConfig.maxTokens) {
expect(request.maxTokens).toBe(bot.personalityConfig.maxTokens);
}
}
),
{ numRuns: 100 }
);
});
it('personality responseStyle is included in system prompt when present', async () => {
// Use a bot generator that always has responseStyle
const botWithStyleArb = fc.record({
id: fc.uuid(),
name: fc.string({ minLength: 1, maxLength: 50 }),
handle: fc.string({ minLength: 3, maxLength: 30 }),
personalityConfig: fc.record({
systemPrompt: systemPromptArb,
temperature: temperatureArb,
maxTokens: maxTokensArb,
responseStyle: fc.string({ minLength: 1, maxLength: 100 }).filter(s => s.trim().length > 0),
}),
llmProvider: llmProviderArb,
llmModel: llmModelArb,
llmApiKeyEncrypted: fc.string({ minLength: 20, maxLength: 100 }),
});
await fc.assert(
fc.asyncProperty(
botWithStyleArb,
fc.option(contentItemArb, { nil: undefined }),
async (bot, sourceContent) => {
const capturedRequests: LLMCompletionRequest[] = [];
const mockClient = createMockLLMClient(capturedRequests);
const generator = new ContentGenerator(bot, mockClient);
// Generate a post
await generator.generatePost(sourceContent);
const request = capturedRequests[0];
const systemMessage = request.messages.find(msg => msg.role === 'system');
// The system message should include the response style
expect(systemMessage?.content).toContain(bot.personalityConfig.responseStyle!);
}
),
{ numRuns: 100 }
);
});
it('different personalities produce different system prompts', async () => {
await fc.assert(
fc.asyncProperty(
botArb,
botArb,
fc.option(contentItemArb, { nil: undefined }),
async (bot1, bot2, sourceContent) => {
// Skip if the bots have the same personality
if (bot1.personalityConfig.systemPrompt === bot2.personalityConfig.systemPrompt) {
return;
}
const capturedRequests1: LLMCompletionRequest[] = [];
const mockClient1 = createMockLLMClient(capturedRequests1);
const generator1 = new ContentGenerator(bot1, mockClient1);
const capturedRequests2: LLMCompletionRequest[] = [];
const mockClient2 = createMockLLMClient(capturedRequests2);
const generator2 = new ContentGenerator(bot2, mockClient2);
// Generate posts with both bots
await generator1.generatePost(sourceContent);
await generator2.generatePost(sourceContent);
const systemMessage1 = capturedRequests1[0].messages.find(msg => msg.role === 'system');
const systemMessage2 = capturedRequests2[0].messages.find(msg => msg.role === 'system');
// The system messages should be different
expect(systemMessage1?.content).not.toBe(systemMessage2?.content);
}
),
{ numRuns: 100 }
);
});
it('buildPostSystemPrompt includes personality system prompt', async () => {
await fc.assert(
fc.asyncProperty(
personalityConfigArb,
async (personality) => {
const systemPrompt = buildPostSystemPrompt(personality);
// The built system prompt should include the personality system prompt
expect(systemPrompt).toContain(personality.systemPrompt);
}
),
{ numRuns: 100 }
);
});
it('buildReplySystemPrompt includes personality system prompt', async () => {
await fc.assert(
fc.asyncProperty(
personalityConfigArb,
async (personality) => {
const systemPrompt = buildReplySystemPrompt(personality);
// The built system prompt should include the personality system prompt
expect(systemPrompt).toContain(personality.systemPrompt);
}
),
{ numRuns: 100 }
);
});
it('personality system prompt is preserved exactly in LLM requests', async () => {
await fc.assert(
fc.asyncProperty(
botArb,
fc.option(contentItemArb, { nil: undefined }),
async (bot, sourceContent) => {
const capturedRequests: LLMCompletionRequest[] = [];
const mockClient = createMockLLMClient(capturedRequests);
const generator = new ContentGenerator(bot, mockClient);
// Generate a post
await generator.generatePost(sourceContent);
const request = capturedRequests[0];
const systemMessage = request.messages.find(msg => msg.role === 'system');
// The system message should contain the exact personality prompt
// (not modified or truncated)
expect(systemMessage?.content).toContain(bot.personalityConfig.systemPrompt);
// Verify the personality prompt appears as a complete substring
const promptIndex = systemMessage?.content.indexOf(bot.personalityConfig.systemPrompt);
expect(promptIndex).toBeGreaterThanOrEqual(0);
// Verify the full prompt is present (not truncated)
const extractedPrompt = systemMessage?.content.substring(
promptIndex!,
promptIndex! + bot.personalityConfig.systemPrompt.length
);
expect(extractedPrompt).toBe(bot.personalityConfig.systemPrompt);
}
),
{ numRuns: 100 }
);
});
it('evaluateContentInterest includes personality system prompt', async () => {
await fc.assert(
fc.asyncProperty(
botArb,
contentItemArb,
async (bot, content) => {
const capturedRequests: LLMCompletionRequest[] = [];
const mockClient = createMockLLMClient(capturedRequests);
const generator = new ContentGenerator(bot, mockClient);
// Evaluate content interest
await generator.evaluateContentInterest(content);
// Verify that a request was made
expect(capturedRequests.length).toBe(1);
const request = capturedRequests[0];
const systemMessage = request.messages.find(msg => msg.role === 'system');
// The system message should include the personality system prompt
expect(systemMessage).toBeDefined();
expect(systemMessage?.content).toContain(bot.personalityConfig.systemPrompt);
}
),
{ numRuns: 100 }
);
});
it('all LLM calls include personality regardless of call type', async () => {
await fc.assert(
fc.asyncProperty(
botArb,
contentItemArb,
postArb,
async (bot, content, mentionPost) => {
// Test all three types of LLM calls
const capturedPostRequests: LLMCompletionRequest[] = [];
const capturedReplyRequests: LLMCompletionRequest[] = [];
const capturedEvalRequests: LLMCompletionRequest[] = [];
const mockPostClient = createMockLLMClient(capturedPostRequests);
const mockReplyClient = createMockLLMClient(capturedReplyRequests);
const mockEvalClient = createMockLLMClient(capturedEvalRequests);
const postGenerator = new ContentGenerator(bot, mockPostClient);
const replyGenerator = new ContentGenerator(bot, mockReplyClient);
const evalGenerator = new ContentGenerator(bot, mockEvalClient);
// Make all three types of calls
await postGenerator.generatePost(content);
await replyGenerator.generateReply(mentionPost, []);
await evalGenerator.evaluateContentInterest(content);
// All three should have made requests
expect(capturedPostRequests.length).toBe(1);
expect(capturedReplyRequests.length).toBe(1);
expect(capturedEvalRequests.length).toBe(1);
// All three should include the personality system prompt
const postSystemMsg = capturedPostRequests[0].messages.find(msg => msg.role === 'system');
const replySystemMsg = capturedReplyRequests[0].messages.find(msg => msg.role === 'system');
const evalSystemMsg = capturedEvalRequests[0].messages.find(msg => msg.role === 'system');
expect(postSystemMsg?.content).toContain(bot.personalityConfig.systemPrompt);
expect(replySystemMsg?.content).toContain(bot.personalityConfig.systemPrompt);
expect(evalSystemMsg?.content).toContain(bot.personalityConfig.systemPrompt);
}
),
{ numRuns: 100 }
);
});
});
// ============================================
// PROPERTY 35: LLM PROMPT CONSTRUCTION
// ============================================
describe('Feature: bot-system, Property 35: LLM Prompt Construction', () => {
/**
* Property 35: LLM Prompt Construction
*
* *For any* post generation request, the LLM prompt SHALL combine source content
* with personality context and configured parameters.
*
* **Validates: Requirements 11.1, 11.2**
*/
it('post generation combines source content with personality context (Requirements 11.1, 11.2)', async () => {
await fc.assert(
fc.asyncProperty(
botArb,
contentItemArb,
async (bot, sourceContent) => {
const capturedRequests: LLMCompletionRequest[] = [];
const mockClient = createMockLLMClient(capturedRequests);
const generator = new ContentGenerator(bot, mockClient);
// Generate a post with source content
await generator.generatePost(sourceContent);
// Verify that a request was made
expect(capturedRequests.length).toBe(1);
const request = capturedRequests[0];
// Verify the request has messages
expect(request.messages).toBeDefined();
expect(request.messages.length).toBeGreaterThanOrEqual(2);
// Find system and user messages
const systemMessage = request.messages.find(msg => msg.role === 'system');
const userMessage = request.messages.find(msg => msg.role === 'user');
// Verify system message includes personality context
expect(systemMessage).toBeDefined();
expect(systemMessage?.content).toContain(bot.personalityConfig.systemPrompt);
// Verify user message includes source content
expect(userMessage).toBeDefined();
expect(userMessage?.content).toContain(sourceContent.title);
expect(userMessage?.content).toContain(sourceContent.url);
// If source has content, it should be included (possibly truncated)
if (sourceContent.content && sourceContent.content.trim().length > 0) {
// The content should appear in the user message
// (it may be truncated, so we check for a substring)
const contentPreview = sourceContent.content.slice(0, 100);
expect(userMessage?.content).toContain(contentPreview);
}
}
),
{ numRuns: 100 }
);
});
it('post generation includes configured temperature parameter (Requirement 11.2)', async () => {
await fc.assert(
fc.asyncProperty(
botArb,
fc.option(contentItemArb, { nil: undefined }),
async (bot, sourceContent) => {
const capturedRequests: LLMCompletionRequest[] = [];
const mockClient = createMockLLMClient(capturedRequests);
const generator = new ContentGenerator(bot, mockClient);
// Generate a post
await generator.generatePost(sourceContent);
const request = capturedRequests[0];
// Verify temperature from personality config is used
expect(request.temperature).toBe(bot.personalityConfig.temperature);
}
),
{ numRuns: 100 }
);
});
it('post generation includes configured maxTokens parameter (Requirement 11.2)', async () => {
await fc.assert(
fc.asyncProperty(
botArb,
fc.option(contentItemArb, { nil: undefined }),
async (bot, sourceContent) => {
const capturedRequests: LLMCompletionRequest[] = [];
const mockClient = createMockLLMClient(capturedRequests);
const generator = new ContentGenerator(bot, mockClient);
// Generate a post
await generator.generatePost(sourceContent);
const request = capturedRequests[0];
// Verify maxTokens is included
expect(request.maxTokens).toBeDefined();
// Should use bot's configured maxTokens or default
if (bot.personalityConfig.maxTokens) {
expect(request.maxTokens).toBe(bot.personalityConfig.maxTokens);
}
}
),
{ numRuns: 100 }
);
});
it('post generation with additional context combines all elements', async () => {
await fc.assert(
fc.asyncProperty(
botArb,
contentItemArb,
fc.string({ minLength: 10, maxLength: 200 }),
async (bot, sourceContent, additionalContext) => {
const capturedRequests: LLMCompletionRequest[] = [];
const mockClient = createMockLLMClient(capturedRequests);
const generator = new ContentGenerator(bot, mockClient);
// Generate a post with source content and additional context
await generator.generatePost(sourceContent, additionalContext);
const request = capturedRequests[0];
const systemMessage = request.messages.find(msg => msg.role === 'system');
const userMessage = request.messages.find(msg => msg.role === 'user');
// Verify all three elements are present:
// 1. Personality context in system message
expect(systemMessage?.content).toContain(bot.personalityConfig.systemPrompt);
// 2. Source content in user message
expect(userMessage?.content).toContain(sourceContent.title);
expect(userMessage?.content).toContain(sourceContent.url);
// 3. Additional context in user message
expect(userMessage?.content).toContain(additionalContext);
}
),
{ numRuns: 100 }
);
});
it('post generation without source content still includes personality', async () => {
await fc.assert(
fc.asyncProperty(
botArb,
fc.option(fc.string({ minLength: 10, maxLength: 200 }), { nil: undefined }),
async (bot, context) => {
const capturedRequests: LLMCompletionRequest[] = [];
const mockClient = createMockLLMClient(capturedRequests);
const generator = new ContentGenerator(bot, mockClient);
// Generate a post without source content
await generator.generatePost(undefined, context);
const request = capturedRequests[0];
const systemMessage = request.messages.find(msg => msg.role === 'system');
// Verify personality context is still included
expect(systemMessage).toBeDefined();
expect(systemMessage?.content).toContain(bot.personalityConfig.systemPrompt);
// Verify configured parameters are used
expect(request.temperature).toBe(bot.personalityConfig.temperature);
expect(request.maxTokens).toBeDefined();
}
),
{ numRuns: 100 }
);
});
it('prompt construction preserves source content structure', async () => {
await fc.assert(
fc.asyncProperty(
botArb,
contentItemArb,
async (bot, sourceContent) => {
const capturedRequests: LLMCompletionRequest[] = [];
const mockClient = createMockLLMClient(capturedRequests);
const generator = new ContentGenerator(bot, mockClient);
// Generate a post
await generator.generatePost(sourceContent);
const request = capturedRequests[0];
const userMessage = request.messages.find(msg => msg.role === 'user');
// Verify the user message contains structured information
expect(userMessage?.content).toBeDefined();
// Should include title label
expect(userMessage?.content).toMatch(/Title:/i);
// Should include URL label
expect(userMessage?.content).toMatch(/URL:/i);
// Should include the actual values
expect(userMessage?.content).toContain(sourceContent.title);
expect(userMessage?.content).toContain(sourceContent.url);
}
),
{ numRuns: 100 }
);
});
it('prompt construction uses personality responseStyle when present', async () => {
// Use a bot generator that always has responseStyle
const botWithStyleArb = fc.record({
id: fc.uuid(),
name: fc.string({ minLength: 1, maxLength: 50 }),
handle: fc.string({ minLength: 3, maxLength: 30 }),
personalityConfig: fc.record({
systemPrompt: systemPromptArb,
temperature: temperatureArb,
maxTokens: maxTokensArb,
responseStyle: fc.string({ minLength: 1, maxLength: 100 }).filter(s => s.trim().length > 0),
}),
llmProvider: llmProviderArb,
llmModel: llmModelArb,
llmApiKeyEncrypted: fc.string({ minLength: 20, maxLength: 100 }),
});
await fc.assert(
fc.asyncProperty(
botWithStyleArb,
contentItemArb,
async (bot, sourceContent) => {
const capturedRequests: LLMCompletionRequest[] = [];
const mockClient = createMockLLMClient(capturedRequests);
const generator = new ContentGenerator(bot, mockClient);
// Generate a post
await generator.generatePost(sourceContent);
const request = capturedRequests[0];
const systemMessage = request.messages.find(msg => msg.role === 'system');
// Verify responseStyle is included in system prompt
expect(systemMessage?.content).toContain(bot.personalityConfig.responseStyle!);
}
),
{ numRuns: 100 }
);
});
it('different source content produces different user messages', async () => {
await fc.assert(
fc.asyncProperty(
botArb,
contentItemArb,
contentItemArb,
async (bot, content1, content2) => {
// Skip if content is identical
if (content1.title === content2.title &&
content1.url === content2.url &&
content1.content === content2.content) {
return;
}
const capturedRequests1: LLMCompletionRequest[] = [];
const mockClient1 = createMockLLMClient(capturedRequests1);
const generator1 = new ContentGenerator(bot, mockClient1);
const capturedRequests2: LLMCompletionRequest[] = [];
const mockClient2 = createMockLLMClient(capturedRequests2);
const generator2 = new ContentGenerator(bot, mockClient2);
// Generate posts with different content
await generator1.generatePost(content1);
await generator2.generatePost(content2);
const userMessage1 = capturedRequests1[0].messages.find(msg => msg.role === 'user');
const userMessage2 = capturedRequests2[0].messages.find(msg => msg.role === 'user');
// User messages should be different
expect(userMessage1?.content).not.toBe(userMessage2?.content);
}
),
{ numRuns: 100 }
);
});
it('prompt construction maintains consistent message structure', async () => {
await fc.assert(
fc.asyncProperty(
botArb,
fc.option(contentItemArb, { nil: undefined }),
async (bot, sourceContent) => {
const capturedRequests: LLMCompletionRequest[] = [];
const mockClient = createMockLLMClient(capturedRequests);
const generator = new ContentGenerator(bot, mockClient);
// Generate a post
await generator.generatePost(sourceContent);
const request = capturedRequests[0];
// Verify message structure
expect(request.messages.length).toBeGreaterThanOrEqual(2);
// First message should be system
expect(request.messages[0].role).toBe('system');
// Second message should be user
expect(request.messages[1].role).toBe('user');
// All messages should have content
for (const message of request.messages) {
expect(message.content).toBeDefined();
expect(typeof message.content).toBe('string');
expect(message.content.length).toBeGreaterThan(0);
}
}
),
{ numRuns: 100 }
);
});
it('buildPostUserMessage combines source content correctly', async () => {
await fc.assert(
fc.asyncProperty(
contentItemArb,
fc.option(fc.string({ minLength: 10, maxLength: 200 }), { nil: undefined }),
async (sourceContent, context) => {
const userMessage = buildPostUserMessage(sourceContent, context);
// Verify source content is included
expect(userMessage).toContain(sourceContent.title);
expect(userMessage).toContain(sourceContent.url);
// If content exists, verify it's included (possibly truncated)
if (sourceContent.content && sourceContent.content.trim().length > 0) {
const contentPreview = sourceContent.content.slice(0, 100);
expect(userMessage).toContain(contentPreview);
}
// If context exists, verify it's included
if (context) {
expect(userMessage).toContain(context);
}
}
),
{ numRuns: 100 }
);
});
it('buildPostSystemPrompt combines personality with instructions', async () => {
await fc.assert(
fc.asyncProperty(
personalityConfigArb,
async (personality) => {
const systemPrompt = buildPostSystemPrompt(personality);
// Verify personality system prompt is included
expect(systemPrompt).toContain(personality.systemPrompt);
// Verify instructions are included
expect(systemPrompt).toMatch(/instructions/i);
// If responseStyle exists, verify it's included
if (personality.responseStyle) {
expect(systemPrompt).toContain(personality.responseStyle);
}
}
),
{ numRuns: 100 }
);
});
it('all configured parameters are passed to LLM client', async () => {
await fc.assert(
fc.asyncProperty(
botArb,
fc.option(contentItemArb, { nil: undefined }),
async (bot, sourceContent) => {
const capturedRequests: LLMCompletionRequest[] = [];
const mockClient = createMockLLMClient(capturedRequests);
const generator = new ContentGenerator(bot, mockClient);
// Generate a post
await generator.generatePost(sourceContent);
const request = capturedRequests[0];
// Verify all required parameters are present
expect(request.messages).toBeDefined();
expect(request.temperature).toBeDefined();
expect(request.maxTokens).toBeDefined();
// Verify parameters match bot configuration
expect(request.temperature).toBe(bot.personalityConfig.temperature);
// Verify temperature is within valid range
expect(request.temperature).toBeGreaterThanOrEqual(0);
expect(request.temperature).toBeLessThanOrEqual(2);
// Verify maxTokens is positive
expect(request.maxTokens).toBeGreaterThan(0);
}
),
{ numRuns: 100 }
);
});
});
// ============================================
// PROPERTY 36: CONTENT TRUNCATION
// ============================================
describe('Feature: bot-system, Property 36: Content Truncation', () => {
/**
* Property 36: Content Truncation
*
* *For any* source content exceeding the maximum length, the content SHALL be
* truncated or summarized before being sent to the LLM.
*
* **Validates: Requirements 11.3**
*/
it('content exceeding MAX_SOURCE_CONTENT_LENGTH is truncated (Requirement 11.3)', async () => {
// Import the constants we need
const { MAX_SOURCE_CONTENT_LENGTH, TRUNCATION_SUFFIX, truncateContent } =
await import('./contentGenerator');
await fc.assert(
fc.asyncProperty(
// Generate content that exceeds the maximum length
fc.string({ minLength: MAX_SOURCE_CONTENT_LENGTH + 1, maxLength: MAX_SOURCE_CONTENT_LENGTH + 5000 }),
async (longContent) => {
const truncated = truncateContent(longContent);
// Verify the truncated content is shorter than or equal to max length
expect(truncated.length).toBeLessThanOrEqual(MAX_SOURCE_CONTENT_LENGTH);
// Verify the truncation suffix is present
expect(truncated).toContain(TRUNCATION_SUFFIX);
// Verify the truncated content ends with the suffix
expect(truncated.endsWith(TRUNCATION_SUFFIX)).toBe(true);
}
),
{ numRuns: 100 }
);
});
it('content within MAX_SOURCE_CONTENT_LENGTH is not truncated', async () => {
const { MAX_SOURCE_CONTENT_LENGTH, TRUNCATION_SUFFIX, truncateContent } =
await import('./contentGenerator');
await fc.assert(
fc.asyncProperty(
// Generate content within the maximum length
fc.string({ minLength: 1, maxLength: MAX_SOURCE_CONTENT_LENGTH }),
async (content) => {
const result = truncateContent(content);
// Verify the content is unchanged
expect(result).toBe(content);
// Verify no truncation suffix is added
expect(result.endsWith(TRUNCATION_SUFFIX)).toBe(false);
}
),
{ numRuns: 100 }
);
});
it('truncated content preserves beginning of original content', async () => {
const { MAX_SOURCE_CONTENT_LENGTH, truncateContent } =
await import('./contentGenerator');
await fc.assert(
fc.asyncProperty(
fc.string({ minLength: MAX_SOURCE_CONTENT_LENGTH + 100, maxLength: MAX_SOURCE_CONTENT_LENGTH + 5000 })
.filter(s => s.trim().length > 100), // Filter out mostly whitespace strings
async (longContent) => {
const truncated = truncateContent(longContent);
// Extract the content without the suffix
const { TRUNCATION_SUFFIX } = await import('./contentGenerator');
const contentWithoutSuffix = truncated.slice(0, -TRUNCATION_SUFFIX.length).trim();
// Skip if content is empty after trimming
if (contentWithoutSuffix.length === 0) {
return;
}
// Verify the truncated content is a prefix of the original (after trimming)
const trimmedOriginal = longContent.trim();
expect(trimmedOriginal.startsWith(contentWithoutSuffix)).toBe(true);
}
),
{ numRuns: 100 }
);
});
it('generatePost truncates long source content before sending to LLM', async () => {
const { MAX_SOURCE_CONTENT_LENGTH } = await import('./contentGenerator');
await fc.assert(
fc.asyncProperty(
botArb,
fc.record({
id: fc.uuid(),
sourceId: fc.uuid(),
title: fc.string({ minLength: 5, maxLength: 200 }),
content: fc.string({ minLength: MAX_SOURCE_CONTENT_LENGTH + 100, maxLength: MAX_SOURCE_CONTENT_LENGTH + 2000 }),
url: fc.webUrl(),
publishedAt: fc.date(),
}),
async (bot, longContentItem) => {
const capturedRequests: LLMCompletionRequest[] = [];
const mockClient = createMockLLMClient(capturedRequests);
const generator = new ContentGenerator(bot, mockClient);
// Generate a post with long content
await generator.generatePost(longContentItem);
// Verify a request was made
expect(capturedRequests.length).toBe(1);
const request = capturedRequests[0];
const userMessage = request.messages.find(msg => msg.role === 'user');
// Verify the user message exists
expect(userMessage).toBeDefined();
// The user message should not contain the full original content
// (it should be truncated)
const { TRUNCATION_SUFFIX } = await import('./contentGenerator');
expect(userMessage?.content).toContain(TRUNCATION_SUFFIX);
// Verify the original long content is not fully present
expect(userMessage?.content).not.toContain(longContentItem.content);
}
),
{ numRuns: 100 }
);
});
it('generateReply truncates long conversation context', async () => {
const { MAX_CONVERSATION_CONTEXT_LENGTH } = await import('./contentGenerator');
// Create a generator for very long posts
const longPostArb: fc.Arbitrary<Post> = fc.record({
id: fc.uuid(),
userId: fc.uuid(),
content: fc.string({ minLength: 500, maxLength: 1000 }),
createdAt: fc.date(),
author: fc.option(
fc.record({
handle: fc.string({ minLength: 3, maxLength: 30 }),
displayName: fc.option(fc.string({ minLength: 1, maxLength: 50 }), { nil: null }),
}),
{ nil: undefined }
),
});
await fc.assert(
fc.asyncProperty(
botArb,
postArb,
fc.array(longPostArb, { minLength: 5, maxLength: 10 }),
async (bot, mentionPost, longConversationContext) => {
const capturedRequests: LLMCompletionRequest[] = [];
const mockClient = createMockLLMClient(capturedRequests);
const generator = new ContentGenerator(bot, mockClient);
// Generate a reply with long conversation context
await generator.generateReply(mentionPost, longConversationContext);
// Verify a request was made
expect(capturedRequests.length).toBe(1);
const request = capturedRequests[0];
const userMessage = request.messages.find(msg => msg.role === 'user');
// Verify the user message exists
expect(userMessage).toBeDefined();
// Calculate total length of all conversation context
const totalContextLength = longConversationContext.reduce(
(sum, post) => sum + post.content.length,
0
);
// If the total context is very long, it should be truncated
if (totalContextLength > MAX_CONVERSATION_CONTEXT_LENGTH) {
// The user message should not contain all posts
const allPostsIncluded = longConversationContext.every(
post => userMessage?.content.includes(post.content)
);
expect(allPostsIncluded).toBe(false);
}
}
),
{ numRuns: 100 }
);
});
it('evaluateContentInterest truncates content before evaluation', async () => {
const { MAX_SOURCE_CONTENT_LENGTH } = await import('./contentGenerator');
await fc.assert(
fc.asyncProperty(
botArb,
fc.record({
id: fc.uuid(),
sourceId: fc.uuid(),
title: fc.string({ minLength: 5, maxLength: 200 }),
content: fc.string({ minLength: MAX_SOURCE_CONTENT_LENGTH + 100, maxLength: MAX_SOURCE_CONTENT_LENGTH + 2000 }),
url: fc.webUrl(),
publishedAt: fc.date(),
}),
async (bot, longContentItem) => {
const capturedRequests: LLMCompletionRequest[] = [];
const mockClient = createMockLLMClient(capturedRequests);
const generator = new ContentGenerator(bot, mockClient);
// Evaluate content interest with long content
await generator.evaluateContentInterest(longContentItem);
// Verify a request was made
expect(capturedRequests.length).toBe(1);
const request = capturedRequests[0];
const userMessage = request.messages.find(msg => msg.role === 'user');
// Verify the user message exists
expect(userMessage).toBeDefined();
// The user message should contain truncation suffix
const { TRUNCATION_SUFFIX } = await import('./contentGenerator');
expect(userMessage?.content).toContain(TRUNCATION_SUFFIX);
}
),
{ numRuns: 100 }
);
});
it('truncateContent with custom maxLength respects the limit', async () => {
const { truncateContent, TRUNCATION_SUFFIX } = await import('./contentGenerator');
await fc.assert(
fc.asyncProperty(
fc.string({ minLength: 100, maxLength: 5000 }),
fc.integer({ min: 50, max: 500 }),
async (content, customMaxLength) => {
const truncated = truncateContent(content, customMaxLength);
// Verify the truncated content respects the custom max length
expect(truncated.length).toBeLessThanOrEqual(customMaxLength);
// If content was longer than max, it should be truncated
if (content.length > customMaxLength) {
expect(truncated.endsWith(TRUNCATION_SUFFIX)).toBe(true);
} else {
expect(truncated).toBe(content);
}
}
),
{ numRuns: 100 }
);
});
it('truncateContent attempts to preserve sentence boundaries', async () => {
const { truncateContent, TRUNCATION_SUFFIX } = await import('./contentGenerator');
// Generate content with clear sentence boundaries - use actual words
const wordArb = fc.array(fc.constantFrom('a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j'), { minLength: 3, maxLength: 10 })
.map(chars => chars.join(''));
const sentenceArb = fc.array(wordArb, { minLength: 5, maxLength: 15 })
.map(words => words.join(' ') + '. ');
const contentWithSentencesArb = fc.array(sentenceArb, { minLength: 20, maxLength: 50 })
.map(sentences => sentences.join(''));
await fc.assert(
fc.asyncProperty(
contentWithSentencesArb,
fc.integer({ min: 200, max: 1000 }),
async (content, maxLength) => {
// Only test when content exceeds max length and has meaningful content
if (content.length <= maxLength || content.trim().length < 100) {
return;
}
const truncated = truncateContent(content, maxLength);
// Verify truncation occurred
expect(truncated.endsWith(TRUNCATION_SUFFIX)).toBe(true);
// Remove the suffix to check the content
const contentWithoutSuffix = truncated.slice(0, -TRUNCATION_SUFFIX.length).trim();
// Skip if truncated content is too short
if (contentWithoutSuffix.length < 50) {
return;
}
// If a sentence boundary was found, the content should end with a sentence terminator
// (This is a best-effort check - not all truncations will find a sentence boundary)
const endsWithSentence = /[.!?]$/.test(contentWithoutSuffix);
// If it ends with a sentence terminator, verify it's a complete sentence
if (endsWithSentence) {
// The truncated content should be a valid prefix of the original (after trimming)
const trimmedOriginal = content.trim();
expect(trimmedOriginal.startsWith(contentWithoutSuffix)).toBe(true);
}
}
),
{ numRuns: 100 }
);
});
it('truncateContent handles empty and null content gracefully', async () => {
const { truncateContent } = await import('./contentGenerator');
await fc.assert(
fc.asyncProperty(
fc.constantFrom('', null, undefined),
async (emptyContent) => {
const truncated = truncateContent(emptyContent as any);
// Empty content should return empty string
expect(truncated).toBe('');
}
),
{ numRuns: 100 }
);
});
it('isContentTruncated correctly identifies truncated content', async () => {
const { truncateContent, isContentTruncated, MAX_SOURCE_CONTENT_LENGTH } =
await import('./contentGenerator');
await fc.assert(
fc.asyncProperty(
fc.string({ minLength: 1, maxLength: MAX_SOURCE_CONTENT_LENGTH + 5000 }),
async (content) => {
const truncated = truncateContent(content);
const shouldBeTruncated = content.length > MAX_SOURCE_CONTENT_LENGTH;
// Verify isContentTruncated returns correct result
expect(isContentTruncated(truncated)).toBe(shouldBeTruncated);
}
),
{ numRuns: 100 }
);
});
it('all LLM calls with long content apply truncation', async () => {
const { MAX_SOURCE_CONTENT_LENGTH, TRUNCATION_SUFFIX } = await import('./contentGenerator');
const longContentItemArb: fc.Arbitrary<ContentItem> = fc.record({
id: fc.uuid(),
sourceId: fc.uuid(),
title: fc.string({ minLength: 5, maxLength: 200 }),
content: fc.string({ minLength: MAX_SOURCE_CONTENT_LENGTH + 100, maxLength: MAX_SOURCE_CONTENT_LENGTH + 2000 }),
url: fc.webUrl(),
publishedAt: fc.date(),
});
await fc.assert(
fc.asyncProperty(
botArb,
longContentItemArb,
async (bot, longContent) => {
// Test both generatePost and evaluateContentInterest
const capturedPostRequests: LLMCompletionRequest[] = [];
const capturedEvalRequests: LLMCompletionRequest[] = [];
const mockPostClient = createMockLLMClient(capturedPostRequests);
const mockEvalClient = createMockLLMClient(capturedEvalRequests);
const postGenerator = new ContentGenerator(bot, mockPostClient);
const evalGenerator = new ContentGenerator(bot, mockEvalClient);
// Make both types of calls
await postGenerator.generatePost(longContent);
await evalGenerator.evaluateContentInterest(longContent);
// Both should have made requests
expect(capturedPostRequests.length).toBe(1);
expect(capturedEvalRequests.length).toBe(1);
// Both should have truncated the content
const postUserMsg = capturedPostRequests[0].messages.find(msg => msg.role === 'user');
const evalUserMsg = capturedEvalRequests[0].messages.find(msg => msg.role === 'user');
expect(postUserMsg?.content).toContain(TRUNCATION_SUFFIX);
expect(evalUserMsg?.content).toContain(TRUNCATION_SUFFIX);
// Neither should contain the full original content
expect(postUserMsg?.content).not.toContain(longContent.content);
expect(evalUserMsg?.content).not.toContain(longContent.content);
}
),
{ numRuns: 100 }
);
});
it('truncation preserves content integrity for LLM processing', async () => {
const { MAX_SOURCE_CONTENT_LENGTH, truncateContent } = await import('./contentGenerator');
await fc.assert(
fc.asyncProperty(
fc.string({ minLength: MAX_SOURCE_CONTENT_LENGTH + 100, maxLength: MAX_SOURCE_CONTENT_LENGTH + 5000 })
.filter(s => s.trim().length > 200), // Filter out mostly whitespace strings
async (longContent) => {
const truncated = truncateContent(longContent);
// Verify the truncated content is still meaningful
// (not just the suffix)
const { TRUNCATION_SUFFIX } = await import('./contentGenerator');
const contentWithoutSuffix = truncated.slice(0, -TRUNCATION_SUFFIX.length).trim();
// Should have substantial content remaining
expect(contentWithoutSuffix.length).toBeGreaterThan(100);
// Should be at least 40% of max length (accounting for boundary finding and whitespace)
expect(contentWithoutSuffix.length).toBeGreaterThan(MAX_SOURCE_CONTENT_LENGTH * 0.4);
// Should be a valid prefix of original (after trimming)
const trimmedOriginal = longContent.trim();
expect(trimmedOriginal.startsWith(contentWithoutSuffix)).toBe(true);
}
),
{ numRuns: 100 }
);
});
});
// ============================================
// PROPERTY 37: LLM RETRY LOGIC
// ============================================
describe('Feature: bot-system, Property 37: LLM Retry Logic', () => {
/**
* Property 37: LLM Retry Logic
*
* *For any* LLM call that fails, the system SHALL retry up to 3 times
* before logging an error.
*
* **Validates: Requirements 11.4**
*/
it('LLM client retries up to 3 times on retryable errors (Requirement 11.4)', async () => {
await fc.assert(
fc.asyncProperty(
llmProviderArb,
llmModelArb,
fc.integer({ min: 1, max: 3 }), // Number of failures before success
async (provider, model, failuresBeforeSuccess) => {
const { LLMClient } = await import('./llmClient');
let attemptCount = 0;
// Create a real LLM client with mocked fetch
const originalFetch = global.fetch;
global.fetch = vi.fn(async () => {
attemptCount++;
if (attemptCount <= failuresBeforeSuccess) {
// Return a retryable error response
return {
ok: false,
status: 503,
json: async () => ({ error: 'Service temporarily unavailable' }),
} as Response;
}
// Success on final attempt - format depends on provider
if (provider === 'anthropic') {
return {
ok: true,
status: 200,
json: async () => ({
content: [{ type: 'text', text: 'Mock generated content' }],
usage: { input_tokens: 100, output_tokens: 50 },
model: model,
}),
} as Response;
} else {
// OpenAI/OpenRouter format
return {
ok: true,
status: 200,
json: async () => ({
choices: [{ message: { content: 'Mock generated content' } }],
usage: { prompt_tokens: 100, completion_tokens: 50, total_tokens: 150 },
model: model,
}),
} as Response;
}
}) as any;
try {
// Use minimal retry delays for testing
const client = new LLMClient(
{
provider,
apiKey: 'test-key',
model,
},
{
maxRetries: 3,
initialDelayMs: 1,
maxDelayMs: 10,
backoffMultiplier: 2,
}
);
// Make a request - should succeed after retries
const result = await client.generateCompletion({
messages: [{ role: 'user', content: 'test' }],
});
// Verify the result is successful
expect(result).toBeDefined();
expect(result.content).toBe('Mock generated content');
// Verify the correct number of attempts were made
expect(attemptCount).toBe(failuresBeforeSuccess + 1);
} finally {
global.fetch = originalFetch;
}
}
),
{ numRuns: 100 }
);
});
it('LLM client fails after 3 retries on persistent retryable errors', async () => {
await fc.assert(
fc.asyncProperty(
llmProviderArb,
llmModelArb,
async (provider, model) => {
const { LLMClient } = await import('./llmClient');
let attemptCount = 0;
// Create a real LLM client with mocked fetch that always fails
const originalFetch = global.fetch;
global.fetch = vi.fn(async () => {
attemptCount++;
// Always return a retryable error response
return {
ok: false,
status: 503,
json: async () => ({ error: 'Service unavailable' }),
} as Response;
}) as any;
try {
// Use minimal retry delays for testing
const client = new LLMClient(
{
provider,
apiKey: 'test-key',
model,
},
{
maxRetries: 3,
initialDelayMs: 1,
maxDelayMs: 10,
backoffMultiplier: 2,
}
);
// Make a request - should fail after retries
await expect(client.generateCompletion({
messages: [{ role: 'user', content: 'test' }],
})).rejects.toThrow();
// Verify 4 attempts were made (1 initial + 3 retries)
expect(attemptCount).toBe(4);
} finally {
global.fetch = originalFetch;
}
}
),
{ numRuns: 100 }
);
});
it('LLM client does not retry on non-retryable errors', async () => {
await fc.assert(
fc.asyncProperty(
llmProviderArb,
llmModelArb,
fc.constantFrom(
{ code: 401, error: 'AUTHENTICATION_ERROR' },
{ code: 400, error: 'INVALID_REQUEST' },
{ code: 403, error: 'AUTHENTICATION_ERROR' }
),
async (provider, model, errorInfo) => {
const { LLMClient } = await import('./llmClient');
let attemptCount = 0;
// Create a real LLM client with mocked fetch that returns non-retryable error
const originalFetch = global.fetch;
global.fetch = vi.fn(async () => {
attemptCount++;
// Return a non-retryable error response
return {
ok: false,
status: errorInfo.code,
json: async () => ({ error: errorInfo.error }),
} as Response;
}) as any;
try {
const client = new LLMClient({
provider,
apiKey: 'test-key',
model,
});
// Make a request - should fail immediately
await expect(client.generateCompletion({
messages: [{ role: 'user', content: 'test' }],
})).rejects.toThrow();
// Verify only 1 attempt was made (no retries)
expect(attemptCount).toBe(1);
} finally {
global.fetch = originalFetch;
}
}
),
{ numRuns: 100 }
);
});
it('retry delay increases exponentially with each attempt', async () => {
const { calculateRetryDelay, DEFAULT_RETRY_CONFIG } = await import('./llmClient');
await fc.assert(
fc.asyncProperty(
fc.integer({ min: 0, max: 10 }),
async (attempt) => {
const delay = calculateRetryDelay(attempt, DEFAULT_RETRY_CONFIG);
// Verify delay is calculated correctly
const expectedDelay = Math.min(
DEFAULT_RETRY_CONFIG.initialDelayMs * Math.pow(DEFAULT_RETRY_CONFIG.backoffMultiplier, attempt),
DEFAULT_RETRY_CONFIG.maxDelayMs
);
expect(delay).toBe(expectedDelay);
// Verify delay is within bounds
expect(delay).toBeGreaterThanOrEqual(DEFAULT_RETRY_CONFIG.initialDelayMs);
expect(delay).toBeLessThanOrEqual(DEFAULT_RETRY_CONFIG.maxDelayMs);
}
),
{ numRuns: 100 }
);
});
it('retry delay respects maximum delay cap', async () => {
const { calculateRetryDelay } = await import('./llmClient');
await fc.assert(
fc.asyncProperty(
fc.integer({ min: 10, max: 100 }), // Very high attempt numbers
fc.record({
maxRetries: fc.integer({ min: 1, max: 10 }),
initialDelayMs: fc.integer({ min: 100, max: 2000 }),
maxDelayMs: fc.integer({ min: 5000, max: 30000 }),
backoffMultiplier: fc.integer({ min: 2, max: 5 }),
}),
async (attempt, retryConfig) => {
const delay = calculateRetryDelay(attempt, retryConfig);
// Verify delay never exceeds max
expect(delay).toBeLessThanOrEqual(retryConfig.maxDelayMs);
}
),
{ numRuns: 100 }
);
});
it('isRetryableError correctly identifies retryable errors', async () => {
const { isRetryableError, LLMClientError } = await import('./llmClient');
await fc.assert(
fc.asyncProperty(
fc.constantFrom('SERVER_ERROR', 'RATE_LIMIT_ERROR', 'NETWORK_ERROR', 'TIMEOUT_ERROR'),
llmProviderArb,
async (errorCode, provider) => {
const error = new LLMClientError(
'Test error',
errorCode as any,
provider,
500,
true
);
// Verify retryable errors are identified correctly
expect(isRetryableError(error)).toBe(true);
}
),
{ numRuns: 100 }
);
});
it('isRetryableError correctly identifies non-retryable errors', async () => {
const { isRetryableError, LLMClientError } = await import('./llmClient');
await fc.assert(
fc.asyncProperty(
fc.constantFrom('AUTHENTICATION_ERROR', 'INVALID_REQUEST', 'CONTENT_POLICY_VIOLATION'),
llmProviderArb,
async (errorCode, provider) => {
const error = new LLMClientError(
'Test error',
errorCode as any,
provider,
400,
false
);
// Verify non-retryable errors are identified correctly
expect(isRetryableError(error)).toBe(false);
}
),
{ numRuns: 100 }
);
});
it('all LLM call types (post, reply, evaluate) use retry logic', async () => {
await fc.assert(
fc.asyncProperty(
llmProviderArb,
llmModelArb,
async (provider, model) => {
const { LLMClient } = await import('./llmClient');
// Track attempts for each call
let attemptCount = 0;
// Create a real LLM client with mocked fetch that fails once then succeeds
const originalFetch = global.fetch;
global.fetch = vi.fn(async () => {
attemptCount++;
if (attemptCount === 1 || attemptCount === 3 || attemptCount === 5) {
// Fail on first attempt of each call
return {
ok: false,
status: 503,
json: async () => ({ error: 'Temporary error' }),
} as Response;
}
// Success on retry - format depends on provider
if (provider === 'anthropic') {
return {
ok: true,
status: 200,
json: async () => ({
content: [{ type: 'text', text: 'Mock content' }],
usage: { input_tokens: 100, output_tokens: 50 },
model: model,
}),
} as Response;
} else {
// OpenAI/OpenRouter format
return {
ok: true,
status: 200,
json: async () => ({
choices: [{ message: { content: 'Mock content' } }],
usage: { prompt_tokens: 100, completion_tokens: 50, total_tokens: 150 },
model: model,
}),
} as Response;
}
}) as any;
try {
// Use minimal retry delays for testing
const client = new LLMClient(
{
provider,
apiKey: 'test-key',
model,
},
{
maxRetries: 3,
initialDelayMs: 1,
maxDelayMs: 10,
backoffMultiplier: 2,
}
);
// Make three different calls - all should retry once
await client.generateCompletion({
messages: [{ role: 'user', content: 'post test' }],
});
await client.generateCompletion({
messages: [{ role: 'user', content: 'reply test' }],
});
await client.generateCompletion({
messages: [{ role: 'user', content: 'eval test' }],
});
// Verify 6 attempts total (2 per call: 1 failure + 1 success)
expect(attemptCount).toBe(6);
} finally {
global.fetch = originalFetch;
}
}
),
{ numRuns: 100 }
);
});
it('retry logic preserves request parameters across attempts', async () => {
await fc.assert(
fc.asyncProperty(
llmProviderArb,
llmModelArb,
temperatureArb,
maxTokensArb,
async (provider, model, temperature, maxTokens) => {
const { LLMClient } = await import('./llmClient');
const capturedBodies: any[] = [];
let attemptCount = 0;
// Create a real LLM client with mocked fetch that fails twice then succeeds
const originalFetch = global.fetch;
global.fetch = vi.fn(async (url: string, options: any) => {
attemptCount++;
// Capture the request body
const body = JSON.parse(options.body);
capturedBodies.push(body);
if (attemptCount <= 2) {
// Fail first two attempts
return {
ok: false,
status: 503,
json: async () => ({ error: 'Temporary error' }),
} as Response;
}
// Success on third attempt - format depends on provider
if (provider === 'anthropic') {
return {
ok: true,
status: 200,
json: async () => ({
content: [{ type: 'text', text: 'Mock content' }],
usage: { input_tokens: 100, output_tokens: 50 },
model: model,
}),
} as Response;
} else {
// OpenAI/OpenRouter format
return {
ok: true,
status: 200,
json: async () => ({
choices: [{ message: { content: 'Mock content' } }],
usage: { prompt_tokens: 100, completion_tokens: 50, total_tokens: 150 },
model: model,
}),
} as Response;
}
}) as any;
try {
// Use minimal retry delays for testing
const client = new LLMClient(
{
provider,
apiKey: 'test-key',
model,
},
{
maxRetries: 3,
initialDelayMs: 1,
maxDelayMs: 10,
backoffMultiplier: 2,
}
);
// Make a request with specific parameters
await client.generateCompletion({
messages: [{ role: 'user', content: 'test' }],
temperature,
maxTokens,
});
// Verify 3 attempts were made
expect(capturedBodies.length).toBe(3);
// Verify all requests have the same parameters
const firstBody = capturedBodies[0];
for (let i = 1; i < capturedBodies.length; i++) {
const body = capturedBodies[i];
// Same temperature
expect(body.temperature).toBe(firstBody.temperature);
// Same max_tokens (or max_tokens for Anthropic)
if (provider === 'anthropic') {
expect(body.max_tokens).toBe(firstBody.max_tokens);
} else {
expect(body.max_tokens).toBe(firstBody.max_tokens);
}
// Same model
expect(body.model).toBe(firstBody.model);
// Same messages (or messages for Anthropic)
if (provider === 'anthropic') {
expect(body.messages).toEqual(firstBody.messages);
} else {
expect(body.messages).toEqual(firstBody.messages);
}
}
} finally {
global.fetch = originalFetch;
}
}
),
{ numRuns: 100 }
);
});
it('DEFAULT_RETRY_CONFIG specifies exactly 3 retries', async () => {
const { DEFAULT_RETRY_CONFIG } = await import('./llmClient');
// Verify the default configuration has exactly 3 retries
expect(DEFAULT_RETRY_CONFIG.maxRetries).toBe(3);
});
it('retry logic works with custom retry configuration', async () => {
await fc.assert(
fc.asyncProperty(
llmProviderArb,
llmModelArb,
fc.record({
maxRetries: fc.integer({ min: 1, max: 3 }), // Limit to 3 for faster tests
initialDelayMs: fc.constant(1), // Use minimal delays for testing
maxDelayMs: fc.constant(10),
backoffMultiplier: fc.constant(2),
}),
async (provider, model, customRetryConfig) => {
const { LLMClient } = await import('./llmClient');
let attemptCount = 0;
// Create a real LLM client with mocked fetch that always fails
const originalFetch = global.fetch;
global.fetch = vi.fn(async () => {
attemptCount++;
// Always return a retryable error
return {
ok: false,
status: 503,
json: async () => ({ error: 'Persistent error' }),
} as Response;
}) as any;
try {
const client = new LLMClient(
{
provider,
apiKey: 'test-key',
model,
},
customRetryConfig
);
// Try to generate completion - should fail after custom retries
await expect(client.generateCompletion({
messages: [{ role: 'user', content: 'test' }],
})).rejects.toThrow();
// Verify correct number of attempts (1 initial + maxRetries)
expect(attemptCount).toBe(1 + customRetryConfig.maxRetries);
} finally {
global.fetch = originalFetch;
}
}
),
{ numRuns: 100 }
);
}, 10000); // Increase timeout for this test
it('timeout errors are retryable', async () => {
const { isRetryableError, LLMClientError } = await import('./llmClient');
await fc.assert(
fc.asyncProperty(
llmProviderArb,
async (provider) => {
const timeoutError = new LLMClientError(
'Request timed out',
'TIMEOUT_ERROR',
provider,
undefined,
true
);
// Verify timeout errors are retryable
expect(isRetryableError(timeoutError)).toBe(true);
}
),
{ numRuns: 100 }
);
});
it('network errors are retryable', async () => {
const { isRetryableError, LLMClientError } = await import('./llmClient');
await fc.assert(
fc.asyncProperty(
llmProviderArb,
async (provider) => {
const networkError = new LLMClientError(
'Network error',
'NETWORK_ERROR',
provider,
undefined,
true
);
// Verify network errors are retryable
expect(isRetryableError(networkError)).toBe(true);
}
),
{ numRuns: 100 }
);
});
it('rate limit errors are retryable', async () => {
const { isRetryableError, LLMClientError } = await import('./llmClient');
await fc.assert(
fc.asyncProperty(
llmProviderArb,
async (provider) => {
const rateLimitError = new LLMClientError(
'Rate limit exceeded',
'RATE_LIMIT_ERROR',
provider,
429,
true
);
// Verify rate limit errors are retryable
expect(isRetryableError(rateLimitError)).toBe(true);
}
),
{ numRuns: 100 }
);
});
it('server errors (5xx) are retryable', async () => {
const { isRetryableError, LLMClientError } = await import('./llmClient');
await fc.assert(
fc.asyncProperty(
llmProviderArb,
fc.constantFrom(500, 502, 503, 504),
async (provider, statusCode) => {
const serverError = new LLMClientError(
'Server error',
'SERVER_ERROR',
provider,
statusCode,
true
);
// Verify server errors are retryable
expect(isRetryableError(serverError)).toBe(true);
}
),
{ numRuns: 100 }
);
});
it('authentication errors are not retryable', async () => {
const { isRetryableError, LLMClientError } = await import('./llmClient');
await fc.assert(
fc.asyncProperty(
llmProviderArb,
fc.constantFrom(401, 403),
async (provider, statusCode) => {
const authError = new LLMClientError(
'Authentication failed',
'AUTHENTICATION_ERROR',
provider,
statusCode,
false
);
// Verify authentication errors are not retryable
expect(isRetryableError(authError)).toBe(false);
}
),
{ numRuns: 100 }
);
});
it('content policy violations are not retryable', async () => {
const { isRetryableError, LLMClientError } = await import('./llmClient');
await fc.assert(
fc.asyncProperty(
llmProviderArb,
async (provider) => {
const policyError = new LLMClientError(
'Content policy violation',
'CONTENT_POLICY_VIOLATION',
provider,
400,
false
);
// Verify content policy violations are not retryable
expect(isRetryableError(policyError)).toBe(false);
}
),
{ numRuns: 100 }
);
});
it('mapStatusToErrorCode correctly identifies retryable status codes', async () => {
const { mapStatusToErrorCode } = await import('./llmClient');
await fc.assert(
fc.asyncProperty(
fc.constantFrom(429, 500, 502, 503, 504),
async (statusCode) => {
const result = mapStatusToErrorCode(statusCode);
// Verify these status codes are marked as retryable
expect(result.retryable).toBe(true);
}
),
{ numRuns: 100 }
);
});
it('mapStatusToErrorCode correctly identifies non-retryable status codes', async () => {
const { mapStatusToErrorCode } = await import('./llmClient');
await fc.assert(
fc.asyncProperty(
fc.constantFrom(400, 401, 403),
async (statusCode) => {
const result = mapStatusToErrorCode(statusCode);
// Verify these status codes are marked as non-retryable
expect(result.retryable).toBe(false);
}
),
{ numRuns: 100 }
);
});
});