diff --git a/tests/unit/config/compactionConfig.test.ts b/tests/unit/config/compactionConfig.test.ts new file mode 100644 index 00000000..32ceee2d --- /dev/null +++ b/tests/unit/config/compactionConfig.test.ts @@ -0,0 +1,245 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { getCompactionConfig } from '../../../src/config/compactionConfig.js'; + +// Mock the dependencies +vi.mock('../../../src/gadgets/readTracking.js', () => ({ + clearReadTracking: vi.fn(), +})); + +vi.mock('../../../src/utils/logging.js', () => ({ + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, +})); + +import { clearReadTracking } from '../../../src/gadgets/readTracking.js'; +import { logger } from '../../../src/utils/logging.js'; + +describe('config/compactionConfig', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('getCompactionConfig', () => { + it('returns implementation agent config with lower threshold', () => { + const config = getCompactionConfig('implementation'); + + expect(config.enabled).toBe(true); + expect(config.strategy).toBe('hybrid'); + expect(config.triggerThresholdPercent).toBe(70); + expect(config.targetPercent).toBe(40); + expect(config.preserveRecentTurns).toBe(8); + expect(config.summarizationPrompt).toContain('Summarize this conversation history'); + expect(config.onCompaction).toBeTypeOf('function'); + }); + + it('returns default config for other agents with higher threshold', () => { + const agentTypes = ['briefing', 'planning', 'debug', 'respond-to-review', 'review']; + + for (const agentType of agentTypes) { + const config = getCompactionConfig(agentType); + + expect(config.enabled).toBe(true); + expect(config.strategy).toBe('hybrid'); + expect(config.triggerThresholdPercent).toBe(80); + expect(config.targetPercent).toBe(50); + expect(config.preserveRecentTurns).toBe(5); + } + }); + + it('implementation agent has more aggressive reduction targets', () => { + const implConfig = getCompactionConfig('implementation'); + const otherConfig = getCompactionConfig('briefing'); + + expect(implConfig.triggerThresholdPercent).toBeLessThan(otherConfig.triggerThresholdPercent); + expect(implConfig.targetPercent).toBeLessThan(otherConfig.targetPercent); + expect(implConfig.preserveRecentTurns).toBeGreaterThan(otherConfig.preserveRecentTurns); + }); + + it('implementation agent preserves more recent turns', () => { + const implConfig = getCompactionConfig('implementation'); + const otherConfig = getCompactionConfig('planning'); + + expect(implConfig.preserveRecentTurns).toBe(8); + expect(otherConfig.preserveRecentTurns).toBe(5); + }); + + it('includes failed approaches section in summarization prompt', () => { + const config = getCompactionConfig('implementation'); + + expect(config.summarizationPrompt).toContain('Failed Approaches'); + expect(config.summarizationPrompt).toContain('tried and FAILED'); + }); + + it('implementation prompt preserves task goals and files', () => { + const config = getCompactionConfig('implementation'); + + expect(config.summarizationPrompt).toContain('task goals and acceptance criteria'); + expect(config.summarizationPrompt).toContain('files that were created or modified'); + expect(config.summarizationPrompt).toContain('todo list status'); + }); + + it('default prompt preserves key decisions and progress', () => { + const config = getCompactionConfig('briefing'); + + expect(config.summarizationPrompt).toContain('Key decisions made'); + expect(config.summarizationPrompt).toContain('Current progress'); + expect(config.summarizationPrompt).toContain('Failed Approaches'); + }); + }); + + describe('onCompaction callback', () => { + it('logs compaction event with token savings', () => { + const config = getCompactionConfig('implementation'); + + const event = { + strategy: 'hybrid' as const, + iteration: 10, + tokensBefore: 50000, + tokensAfter: 20000, + messagesBefore: 40, + messagesAfter: 15, + }; + + config.onCompaction?.(event); + + expect(logger.info).toHaveBeenCalledWith('Context compaction performed', { + strategy: 'hybrid', + iteration: 10, + tokensBefore: 50000, + tokensAfter: 20000, + tokensSaved: 30000, + reductionPercent: 60, + messagesRemoved: 25, + }); + }); + + it('calculates reduction percentage correctly', () => { + const config = getCompactionConfig('planning'); + + const event = { + strategy: 'hybrid' as const, + iteration: 5, + tokensBefore: 100000, + tokensAfter: 40000, + messagesBefore: 30, + messagesAfter: 10, + }; + + config.onCompaction?.(event); + + const logCall = vi.mocked(logger.info).mock.calls[0]; + expect(logCall[1]).toMatchObject({ + tokensSaved: 60000, + reductionPercent: 60, // (60000 / 100000) * 100 + }); + }); + + it('clears read tracking after compaction', () => { + const config = getCompactionConfig('implementation'); + + const event = { + strategy: 'hybrid' as const, + iteration: 8, + tokensBefore: 80000, + tokensAfter: 30000, + messagesBefore: 50, + messagesAfter: 20, + }; + + config.onCompaction?.(event); + + expect(clearReadTracking).toHaveBeenCalledTimes(1); + }); + + it('logs messages removed count', () => { + const config = getCompactionConfig('debug'); + + const event = { + strategy: 'hybrid' as const, + iteration: 3, + tokensBefore: 60000, + tokensAfter: 25000, + messagesBefore: 35, + messagesAfter: 12, + }; + + config.onCompaction?.(event); + + const logCall = vi.mocked(logger.info).mock.calls[0]; + expect(logCall[1]).toMatchObject({ + messagesRemoved: 23, // 35 - 12 + }); + }); + + it('rounds reduction percentage to integer', () => { + const config = getCompactionConfig('implementation'); + + const event = { + strategy: 'hybrid' as const, + iteration: 7, + tokensBefore: 33333, + tokensAfter: 11111, + messagesBefore: 25, + messagesAfter: 10, + }; + + config.onCompaction?.(event); + + const logCall = vi.mocked(logger.info).mock.calls[0]; + // (22222 / 33333) * 100 = 66.666... -> should be rounded + expect(logCall[1].reductionPercent).toBe(67); + }); + }); + + describe('config consistency', () => { + it('all agent types return valid config structure', () => { + const agentTypes = [ + 'implementation', + 'briefing', + 'planning', + 'debug', + 'review', + 'respond-to-review', + 'respond-to-ci', + ]; + + for (const agentType of agentTypes) { + const config = getCompactionConfig(agentType); + + expect(config.enabled).toBe(true); + expect(config.strategy).toBe('hybrid'); + expect(config.triggerThresholdPercent).toBeGreaterThan(0); + expect(config.targetPercent).toBeGreaterThan(0); + expect(config.preserveRecentTurns).toBeGreaterThan(0); + expect(config.summarizationPrompt).toBeTruthy(); + expect(config.onCompaction).toBeTypeOf('function'); + } + }); + + it('target percent is less than trigger threshold', () => { + const agentTypes = ['implementation', 'briefing', 'planning']; + + for (const agentType of agentTypes) { + const config = getCompactionConfig(agentType); + + expect(config.targetPercent).toBeLessThan(config.triggerThresholdPercent); + } + }); + + it('thresholds are reasonable percentages', () => { + const implConfig = getCompactionConfig('implementation'); + const otherConfig = getCompactionConfig('briefing'); + + expect(implConfig.triggerThresholdPercent).toBeGreaterThanOrEqual(50); + expect(implConfig.triggerThresholdPercent).toBeLessThanOrEqual(100); + + expect(otherConfig.triggerThresholdPercent).toBeGreaterThanOrEqual(50); + expect(otherConfig.triggerThresholdPercent).toBeLessThanOrEqual(100); + }); + }); +}); diff --git a/tests/unit/config/customModels.test.ts b/tests/unit/config/customModels.test.ts new file mode 100644 index 00000000..0c4fddb9 --- /dev/null +++ b/tests/unit/config/customModels.test.ts @@ -0,0 +1,259 @@ +import { describe, expect, it } from 'vitest'; + +import { CUSTOM_MODELS } from '../../../src/config/customModels.js'; + +describe('config/customModels', () => { + describe('CUSTOM_MODELS array', () => { + it('is defined and is an array', () => { + expect(Array.isArray(CUSTOM_MODELS)).toBe(true); + expect(CUSTOM_MODELS.length).toBeGreaterThan(0); + }); + + it('contains expected model count', () => { + // As of current implementation: Gemini 3 Flash/Pro, Grok, DeepSeek variants, MiniMax, Gemini 2.5 Flash Lite + expect(CUSTOM_MODELS.length).toBeGreaterThanOrEqual(7); + }); + + it('all models use openrouter provider', () => { + for (const model of CUSTOM_MODELS) { + expect(model.provider).toBe('openrouter'); + } + }); + }); + + describe('model specifications', () => { + it('all models have required fields', () => { + const requiredFields = [ + 'provider', + 'modelId', + 'displayName', + 'contextWindow', + 'maxOutputTokens', + 'pricing', + 'features', + ]; + + for (const model of CUSTOM_MODELS) { + for (const field of requiredFields) { + expect(model).toHaveProperty(field); + } + } + }); + + it('all models have valid context windows', () => { + for (const model of CUSTOM_MODELS) { + expect(model.contextWindow).toBeGreaterThan(0); + expect(model.contextWindow).toBeGreaterThan(model.maxOutputTokens); + } + }); + + it('all models have valid max output tokens', () => { + for (const model of CUSTOM_MODELS) { + expect(model.maxOutputTokens).toBeGreaterThan(0); + expect(model.maxOutputTokens).toBeLessThanOrEqual(model.contextWindow); + } + }); + + it('all models have pricing information', () => { + for (const model of CUSTOM_MODELS) { + expect(model.pricing).toBeDefined(); + expect(model.pricing.input).toBeGreaterThanOrEqual(0); + expect(model.pricing.output).toBeGreaterThanOrEqual(0); + expect(model.pricing.output).toBeGreaterThanOrEqual(model.pricing.input); + } + }); + + it('all models have knowledge cutoff date', () => { + for (const model of CUSTOM_MODELS) { + if (model.knowledgeCutoff) { + expect(model.knowledgeCutoff).toMatch(/^\d{4}-\d{2}$/); + } + } + }); + + it('all models have feature flags', () => { + for (const model of CUSTOM_MODELS) { + expect(model.features).toBeDefined(); + expect(typeof model.features.streaming).toBe('boolean'); + expect(typeof model.features.functionCalling).toBe('boolean'); + } + }); + }); + + describe('specific models', () => { + it('includes Gemini 3 Flash Preview', () => { + const model = CUSTOM_MODELS.find((m) => m.modelId === 'google/gemini-3-flash-preview'); + + expect(model).toBeDefined(); + expect(model?.displayName).toBe('Gemini 3 Flash Preview'); + expect(model?.contextWindow).toBe(1_048_576); + expect(model?.features.streaming).toBe(true); + expect(model?.features.functionCalling).toBe(true); + expect(model?.features.vision).toBe(true); + }); + + it('includes Gemini 3 Pro Preview', () => { + const model = CUSTOM_MODELS.find((m) => m.modelId === 'google/gemini-3-pro-preview'); + + expect(model).toBeDefined(); + expect(model?.displayName).toBe('Gemini 3 Pro Preview'); + expect(model?.contextWindow).toBe(1_048_576); + expect(model?.pricing.input).toBeGreaterThan(0); + expect(model?.features.reasoning).toBe(true); + }); + + it('includes Grok Code Fast 1', () => { + const model = CUSTOM_MODELS.find((m) => m.modelId === 'x-ai/grok-code-fast-1'); + + expect(model).toBeDefined(); + expect(model?.displayName).toBe('Grok Code Fast 1'); + expect(model?.contextWindow).toBe(256_000); + expect(model?.maxOutputTokens).toBe(32_768); + expect(model?.features.vision).toBe(false); + }); + + it('includes DeepSeek V3 0324', () => { + const model = CUSTOM_MODELS.find((m) => m.modelId === 'deepseek/deepseek-chat-v3-0324'); + + expect(model).toBeDefined(); + expect(model?.displayName).toBe('DeepSeek V3 0324'); + expect(model?.contextWindow).toBe(163_840); + expect(model?.features.functionCalling).toBe(true); + }); + + it('includes DeepSeek V3.2', () => { + const model = CUSTOM_MODELS.find((m) => m.modelId === 'deepseek/deepseek-v3.2'); + + expect(model).toBeDefined(); + expect(model?.displayName).toBe('DeepSeek V3.2'); + expect(model?.features.reasoning).toBe(true); + }); + + it('includes DeepSeek V3.2 Speciale', () => { + const model = CUSTOM_MODELS.find((m) => m.modelId === 'deepseek/deepseek-v3.2-speciale'); + + expect(model).toBeDefined(); + expect(model?.displayName).toBe('DeepSeek V3.2 Speciale'); + }); + + it('includes MiniMax M2.1', () => { + const model = CUSTOM_MODELS.find((m) => m.modelId === 'minimax/minimax-m2.1'); + + expect(model).toBeDefined(); + expect(model?.displayName).toBe('MiniMax M2.1'); + expect(model?.contextWindow).toBe(196_608); + expect(model?.maxOutputTokens).toBe(65_536); + }); + + it('includes Gemini 2.5 Flash Lite', () => { + const model = CUSTOM_MODELS.find((m) => m.modelId === 'google/gemini-2.5-flash-lite'); + + expect(model).toBeDefined(); + expect(model?.displayName).toBe('Gemini 2.5 Flash Lite'); + expect(model?.contextWindow).toBe(1_048_576); + expect(model?.maxOutputTokens).toBe(8_192); + expect(model?.features.functionCalling).toBe(false); + expect(model?.features.vision).toBe(false); + }); + }); + + describe('model characteristics', () => { + it('Gemini models have large context windows', () => { + const geminiModels = CUSTOM_MODELS.filter((m) => m.modelId.includes('gemini')); + + for (const model of geminiModels) { + expect(model.contextWindow).toBeGreaterThanOrEqual(1_000_000); + } + }); + + it('vision models are correctly flagged', () => { + const visionModels = CUSTOM_MODELS.filter((m) => m.features.vision); + + // Gemini 3 Flash and Pro have vision + expect(visionModels.length).toBeGreaterThan(0); + + for (const model of visionModels) { + expect(model.modelId).toContain('gemini-3'); + } + }); + + it('reasoning models are correctly flagged', () => { + const reasoningModels = CUSTOM_MODELS.filter((m) => m.features.reasoning); + + expect(reasoningModels.length).toBeGreaterThan(0); + + // Gemini 3, Grok, DeepSeek V3.2, MiniMax should have reasoning + for (const model of reasoningModels) { + const isReasoningModel = + model.modelId.includes('gemini-3') || + model.modelId.includes('grok') || + model.modelId.includes('deepseek-v3') || + model.modelId.includes('minimax'); + expect(isReasoningModel).toBe(true); + } + }); + + it('all models support streaming', () => { + for (const model of CUSTOM_MODELS) { + expect(model.features.streaming).toBe(true); + } + }); + + it('most models support function calling', () => { + const withFunctionCalling = CUSTOM_MODELS.filter((m) => m.features.functionCalling); + + // All except Gemini 2.5 Flash Lite + expect(withFunctionCalling.length).toBe(CUSTOM_MODELS.length - 1); + }); + }); + + describe('pricing structure', () => { + it('output pricing is higher than input pricing', () => { + for (const model of CUSTOM_MODELS) { + expect(model.pricing.output).toBeGreaterThanOrEqual(model.pricing.input); + } + }); + + it('pricing is in reasonable range (per million tokens)', () => { + for (const model of CUSTOM_MODELS) { + // Input should be between $0 and $10 per million tokens + expect(model.pricing.input).toBeGreaterThanOrEqual(0); + expect(model.pricing.input).toBeLessThanOrEqual(10); + + // Output should be between $0 and $20 per million tokens + expect(model.pricing.output).toBeGreaterThanOrEqual(0); + expect(model.pricing.output).toBeLessThanOrEqual(20); + } + }); + + it('lite/flash models are cheaper than pro models', () => { + const flashLite = CUSTOM_MODELS.find((m) => m.modelId === 'google/gemini-2.5-flash-lite'); + const pro = CUSTOM_MODELS.find((m) => m.modelId === 'google/gemini-3-pro-preview'); + + expect(flashLite?.pricing.input).toBeLessThan(pro?.pricing.input || Number.POSITIVE_INFINITY); + }); + }); + + describe('model IDs', () => { + it('all model IDs are unique', () => { + const modelIds = CUSTOM_MODELS.map((m) => m.modelId); + const uniqueIds = new Set(modelIds); + + expect(uniqueIds.size).toBe(modelIds.length); + }); + + it('all display names are unique', () => { + const displayNames = CUSTOM_MODELS.map((m) => m.displayName); + const uniqueNames = new Set(displayNames); + + expect(uniqueNames.size).toBe(displayNames.length); + }); + + it('model IDs follow expected format', () => { + for (const model of CUSTOM_MODELS) { + // Should be in format: provider/model-name + expect(model.modelId).toMatch(/^[a-z0-9-]+\/[a-z0-9.-]+$/); + } + }); + }); +}); diff --git a/tests/unit/config/rateLimits.test.ts b/tests/unit/config/rateLimits.test.ts new file mode 100644 index 00000000..ead87e5b --- /dev/null +++ b/tests/unit/config/rateLimits.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, it } from 'vitest'; + +import { MODEL_RATE_LIMITS, getRateLimitForModel } from '../../../src/config/rateLimits.js'; + +describe('config/rateLimits', () => { + describe('getRateLimitForModel', () => { + it('returns exact match for known models', () => { + const result = getRateLimitForModel('gemini:gemini-2.5-flash'); + + expect(result).toEqual({ + requestsPerMinute: 15, + tokensPerMinute: 1_000_000, + tokensPerDay: 1_500_000, + safetyMargin: 0.8, + }); + }); + + it('returns exact match for Claude Sonnet 4.5', () => { + const result = getRateLimitForModel('anthropic:claude-sonnet-4-5'); + + expect(result).toEqual({ + requestsPerMinute: 50, + tokensPerMinute: 40_000, + safetyMargin: 0.9, + }); + }); + + it('returns exact match for Claude Opus 4.5', () => { + const result = getRateLimitForModel('anthropic:claude-opus-4-5'); + + expect(result).toEqual({ + requestsPerMinute: 50, + tokensPerMinute: 10_000, + safetyMargin: 0.85, + }); + }); + + it('returns prefix match for models with version suffix', () => { + // anthropic:claude-sonnet-4-5-20250929 should match anthropic:claude-sonnet-4-5 + const result = getRateLimitForModel('anthropic:claude-sonnet-4-5-20250929'); + + expect(result).toEqual({ + requestsPerMinute: 50, + tokensPerMinute: 40_000, + safetyMargin: 0.9, + }); + }); + + it('returns disabled config for unknown models', () => { + const result = getRateLimitForModel('unknown-provider:unknown-model'); + + expect(result).toEqual({ enabled: false }); + }); + + it('returns disabled config for empty string', () => { + const result = getRateLimitForModel(''); + + expect(result).toEqual({ enabled: false }); + }); + + it('returns exact match priority over prefix match', () => { + // Verify exact match takes precedence + const exactKey = 'openrouter:google/gemini-3-flash-preview'; + const exactMatch = getRateLimitForModel(exactKey); + + // Should match the exact config, not a generic openrouter prefix + expect(exactMatch).toEqual(MODEL_RATE_LIMITS[exactKey]); + }); + + it('returns prefix match for OpenRouter models with version suffix', () => { + // Test that prefix matching works for OpenRouter models + const result = getRateLimitForModel('openrouter:google/gemini-3-flash-preview-2025'); + + // Should match openrouter:google/gemini-3-flash-preview prefix + expect(result).toEqual({ + requestsPerMinute: 100, + tokensPerMinute: 500_000, + safetyMargin: 0.9, + }); + }); + + it('includes all expected config fields', () => { + const result = getRateLimitForModel('gemini:gemini-2.5-flash'); + + expect(result).toHaveProperty('requestsPerMinute'); + expect(result).toHaveProperty('tokensPerMinute'); + expect(result).toHaveProperty('safetyMargin'); + expect(typeof result.requestsPerMinute).toBe('number'); + expect(typeof result.tokensPerMinute).toBe('number'); + expect(typeof result.safetyMargin).toBe('number'); + }); + + it('safety margin is between 0 and 1', () => { + for (const [modelId, config] of Object.entries(MODEL_RATE_LIMITS)) { + expect(config.safetyMargin).toBeGreaterThan(0); + expect(config.safetyMargin).toBeLessThanOrEqual(1); + } + }); + }); + + describe('MODEL_RATE_LIMITS constants', () => { + it('includes Gemini free tier config', () => { + expect(MODEL_RATE_LIMITS['gemini:gemini-2.5-flash']).toBeDefined(); + expect(MODEL_RATE_LIMITS['gemini:gemini-2.5-flash'].tokensPerDay).toBe(1_500_000); + }); + + it('includes Claude Sonnet and Opus configs', () => { + expect(MODEL_RATE_LIMITS['anthropic:claude-sonnet-4-5']).toBeDefined(); + expect(MODEL_RATE_LIMITS['anthropic:claude-opus-4-5']).toBeDefined(); + }); + + it('includes OpenRouter models', () => { + expect(MODEL_RATE_LIMITS['openrouter:google/gemini-3-flash-preview']).toBeDefined(); + expect(MODEL_RATE_LIMITS['openrouter:deepseek/deepseek-chat-v3-0324']).toBeDefined(); + expect(MODEL_RATE_LIMITS['openrouter:x-ai/grok-code-fast-1']).toBeDefined(); + }); + + it('all configs have required fields', () => { + for (const [modelId, config] of Object.entries(MODEL_RATE_LIMITS)) { + expect(config.requestsPerMinute, `${modelId} missing RPM`).toBeDefined(); + expect(config.tokensPerMinute, `${modelId} missing TPM`).toBeDefined(); + expect(config.safetyMargin, `${modelId} missing safety margin`).toBeDefined(); + } + }); + }); +}); diff --git a/tests/unit/config/retryConfig.test.ts b/tests/unit/config/retryConfig.test.ts new file mode 100644 index 00000000..33fa48a4 --- /dev/null +++ b/tests/unit/config/retryConfig.test.ts @@ -0,0 +1,253 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { getRetryConfig } from '../../../src/config/retryConfig.js'; + +// Create a mock logger +const createMockLogger = () => ({ + warn: vi.fn(), + error: vi.fn(), + info: vi.fn(), + debug: vi.fn(), + trace: vi.fn(), +}); + +describe('config/retryConfig', () => { + describe('getRetryConfig', () => { + it('returns retry configuration with correct structure', () => { + const logger = createMockLogger(); + const config = getRetryConfig(logger); + + expect(config).toEqual({ + enabled: true, + retries: 5, + minTimeout: 1000, + maxTimeout: 60000, + factor: 2, + randomize: true, + respectRetryAfter: true, + maxRetryAfterMs: 120000, + shouldRetry: expect.any(Function), + onRetry: expect.any(Function), + onRetriesExhausted: expect.any(Function), + }); + }); + + it('has aggressive retry settings for long-running agents', () => { + const logger = createMockLogger(); + const config = getRetryConfig(logger); + + expect(config.retries).toBe(5); + expect(config.minTimeout).toBe(1000); + expect(config.maxTimeout).toBe(60000); + expect(config.factor).toBe(2); // Exponential backoff + }); + + it('enables jitter to prevent thundering herd', () => { + const logger = createMockLogger(); + const config = getRetryConfig(logger); + + expect(config.randomize).toBe(true); + }); + + it('respects Retry-After headers with cap', () => { + const logger = createMockLogger(); + const config = getRetryConfig(logger); + + expect(config.respectRetryAfter).toBe(true); + expect(config.maxRetryAfterMs).toBe(120000); // 2 minutes cap + }); + }); + + describe('shouldRetry', () => { + it('returns true for rate limit errors (429)', () => { + const logger = createMockLogger(); + const config = getRetryConfig(logger); + + const rateLimitError = new Error('Rate limit exceeded'); + Object.assign(rateLimitError, { status: 429 }); + + expect(config.shouldRetry?.(rateLimitError)).toBe(true); + }); + + it('returns true for 5xx server errors', () => { + const logger = createMockLogger(); + const config = getRetryConfig(logger); + + const serverError = new Error('Internal server error'); + Object.assign(serverError, { status: 500 }); + + expect(config.shouldRetry?.(serverError)).toBe(true); + }); + + it('returns true for stream termination errors', () => { + const logger = createMockLogger(); + const config = getRetryConfig(logger); + + const terminatedError = new Error('stream terminated'); + expect(config.shouldRetry?.(terminatedError)).toBe(true); + + const abortedError = new Error('request aborted'); + expect(config.shouldRetry?.(abortedError)).toBe(true); + + const hangUpError = new Error('socket hang up'); + expect(config.shouldRetry?.(hangUpError)).toBe(true); + + const fetchFailedError = new Error('fetch failed due to network error'); + expect(config.shouldRetry?.(fetchFailedError)).toBe(true); + }); + + it('returns true for stream errors case-insensitive', () => { + const logger = createMockLogger(); + const config = getRetryConfig(logger); + + const upperCaseError = new Error('STREAM TERMINATED'); + expect(config.shouldRetry?.(upperCaseError)).toBe(true); + + const mixedCaseError = new Error('Fetch Failed'); + expect(config.shouldRetry?.(mixedCaseError)).toBe(true); + }); + + it('returns false for non-retryable errors (4xx except 429)', () => { + const logger = createMockLogger(); + const config = getRetryConfig(logger); + + const badRequestError = new Error('Bad request'); + Object.assign(badRequestError, { status: 400 }); + + expect(config.shouldRetry?.(badRequestError)).toBe(false); + }); + + it('returns false for authentication errors (401)', () => { + const logger = createMockLogger(); + const config = getRetryConfig(logger); + + const authError = new Error('Unauthorized'); + Object.assign(authError, { status: 401 }); + + expect(config.shouldRetry?.(authError)).toBe(false); + }); + + it('returns false for non-stream generic errors', () => { + const logger = createMockLogger(); + const config = getRetryConfig(logger); + + const genericError = new Error('Something went wrong'); + expect(config.shouldRetry?.(genericError)).toBe(false); + }); + }); + + describe('onRetry callback', () => { + it('logs retry attempts with attempt number', () => { + const logger = createMockLogger(); + const config = getRetryConfig(logger); + + const error = new Error('Rate limit exceeded'); + config.onRetry?.(error, 2); + + expect(logger.warn).toHaveBeenCalledWith('LLM call retry', { + attempt: 2, + maxAttempts: 5, + error: 'Rate limit exceeded', + isStreamError: false, + nextRetryDelayMs: expect.any(Number), + }); + }); + + it('calculates exponential backoff delay correctly', () => { + const logger = createMockLogger(); + const config = getRetryConfig(logger); + + const error = new Error('Timeout'); + config.onRetry?.(error, 1); + + // Attempt 1: 1000 * 2^0 = 1000ms + expect(logger.warn).toHaveBeenCalledWith( + 'LLM call retry', + expect.objectContaining({ nextRetryDelayMs: 1000 }), + ); + + logger.warn.mockClear(); + config.onRetry?.(error, 2); + + // Attempt 2: 1000 * 2^1 = 2000ms + expect(logger.warn).toHaveBeenCalledWith( + 'LLM call retry', + expect.objectContaining({ nextRetryDelayMs: 2000 }), + ); + + logger.warn.mockClear(); + config.onRetry?.(error, 3); + + // Attempt 3: 1000 * 2^2 = 4000ms + expect(logger.warn).toHaveBeenCalledWith( + 'LLM call retry', + expect.objectContaining({ nextRetryDelayMs: 4000 }), + ); + }); + + it('caps delay at maxTimeout (60s)', () => { + const logger = createMockLogger(); + const config = getRetryConfig(logger); + + const error = new Error('Timeout'); + config.onRetry?.(error, 10); // Very high attempt + + // Should be capped at 60000ms + expect(logger.warn).toHaveBeenCalledWith( + 'LLM call retry', + expect.objectContaining({ nextRetryDelayMs: 60000 }), + ); + }); + + it('flags stream termination errors correctly', () => { + const logger = createMockLogger(); + const config = getRetryConfig(logger); + + const streamError = new Error('stream terminated'); + config.onRetry?.(streamError, 1); + + expect(logger.warn).toHaveBeenCalledWith( + 'LLM call retry', + expect.objectContaining({ isStreamError: true }), + ); + + logger.warn.mockClear(); + + const normalError = new Error('Rate limit'); + config.onRetry?.(normalError, 1); + + expect(logger.warn).toHaveBeenCalledWith( + 'LLM call retry', + expect.objectContaining({ isStreamError: false }), + ); + }); + }); + + describe('onRetriesExhausted callback', () => { + it('logs failure after all retries exhausted', () => { + const logger = createMockLogger(); + const config = getRetryConfig(logger); + + const error = new Error('Persistent failure'); + config.onRetriesExhausted?.(error, 5); + + expect(logger.error).toHaveBeenCalledWith('LLM call failed after all retries exhausted', { + attempts: 5, + error: 'Persistent failure', + totalWaitTimeMs: '~31000', // 1s + 2s + 4s + 8s + 16s + }); + }); + + it('includes total approximate wait time', () => { + const logger = createMockLogger(); + const config = getRetryConfig(logger); + + const error = new Error('Failed'); + config.onRetriesExhausted?.(error, 5); + + const call = logger.error.mock.calls[0]; + expect(call[1]).toHaveProperty('totalWaitTimeMs'); + expect(call[1].totalWaitTimeMs).toBe('~31000'); + }); + }); +}); diff --git a/tests/unit/config/reviewConfig.test.ts b/tests/unit/config/reviewConfig.test.ts new file mode 100644 index 00000000..3bb4ef2b --- /dev/null +++ b/tests/unit/config/reviewConfig.test.ts @@ -0,0 +1,160 @@ +import { describe, expect, it } from 'vitest'; + +import { + REVIEW_FILE_CONTENT_TOKEN_LIMIT, + estimateTokens, +} from '../../../src/config/reviewConfig.js'; + +describe('config/reviewConfig', () => { + describe('REVIEW_FILE_CONTENT_TOKEN_LIMIT', () => { + it('is defined as a number', () => { + expect(typeof REVIEW_FILE_CONTENT_TOKEN_LIMIT).toBe('number'); + }); + + it('is set to 25000 tokens', () => { + expect(REVIEW_FILE_CONTENT_TOKEN_LIMIT).toBe(25_000); + }); + + it('is a positive value', () => { + expect(REVIEW_FILE_CONTENT_TOKEN_LIMIT).toBeGreaterThan(0); + }); + }); + + describe('estimateTokens', () => { + it('estimates roughly 4 characters per token', () => { + const text = 'a'.repeat(400); + const tokens = estimateTokens(text); + + // 400 chars / 4 = 100 tokens + expect(tokens).toBe(100); + }); + + it('returns correct estimate for short text', () => { + const text = 'hello world'; // 11 chars + const tokens = estimateTokens(text); + + // 11 / 4 = 2.75 -> ceil = 3 + expect(tokens).toBe(3); + }); + + it('returns correct estimate for longer text', () => { + const text = 'a'.repeat(1000); + const tokens = estimateTokens(text); + + // 1000 / 4 = 250 + expect(tokens).toBe(250); + }); + + it('rounds up using ceil', () => { + const text = 'abc'; // 3 chars + const tokens = estimateTokens(text); + + // 3 / 4 = 0.75 -> ceil = 1 + expect(tokens).toBe(1); + }); + + it('handles empty string', () => { + const tokens = estimateTokens(''); + + // 0 / 4 = 0 -> ceil = 0 + expect(tokens).toBe(0); + }); + + it('handles single character', () => { + const tokens = estimateTokens('x'); + + // 1 / 4 = 0.25 -> ceil = 1 + expect(tokens).toBe(1); + }); + + it('handles exact multiples of 4', () => { + const text = 'a'.repeat(40); + const tokens = estimateTokens(text); + + // 40 / 4 = 10 (exact) + expect(tokens).toBe(10); + }); + + it('estimates tokens for realistic code snippet', () => { + const codeSnippet = ` +function greet(name: string): string { + return \`Hello, \${name}!\`; +} +`.trim(); + + const tokens = estimateTokens(codeSnippet); + + // Length is ~64 chars -> 64/4 = 16 tokens + expect(tokens).toBeGreaterThan(10); + expect(tokens).toBeLessThan(25); + }); + + it('estimates tokens for multiline text', () => { + const text = `This is line 1 +This is line 2 +This is line 3`; + + const tokens = estimateTokens(text); + + // ~42 chars (including newlines) -> 42/4 = 10.5 -> ceil = 11 + expect(tokens).toBeGreaterThan(8); + expect(tokens).toBeLessThan(15); + }); + + it('handles unicode characters as character length', () => { + const text = '๐Ÿ”ฅ'.repeat(100); // 100 emoji (each is 2 chars in JS) + const tokens = estimateTokens(text); + + // In JS, emoji are typically 2 chars each -> 200 / 4 = 50 tokens + expect(tokens).toBe(50); + }); + + it('returns consistent results for same input', () => { + const text = 'The quick brown fox jumps over the lazy dog'; + + const tokens1 = estimateTokens(text); + const tokens2 = estimateTokens(text); + + expect(tokens1).toBe(tokens2); + }); + + it('larger text has proportionally more tokens', () => { + const shortText = 'a'.repeat(100); + const longText = 'a'.repeat(1000); + + const shortTokens = estimateTokens(shortText); + const longTokens = estimateTokens(longText); + + expect(longTokens).toBe(shortTokens * 10); + }); + + it('approximates typical file within limit', () => { + // A file with ~100k characters should be ~25k tokens + const largeFile = 'x'.repeat(100_000); + const tokens = estimateTokens(largeFile); + + expect(tokens).toBe(25_000); + expect(tokens).toBe(REVIEW_FILE_CONTENT_TOKEN_LIMIT); + }); + }); + + describe('integration', () => { + it('can use estimateTokens to check against limit', () => { + const smallFile = 'a'.repeat(50_000); // ~12.5k tokens + const largeFile = 'a'.repeat(150_000); // ~37.5k tokens + + expect(estimateTokens(smallFile)).toBeLessThan(REVIEW_FILE_CONTENT_TOKEN_LIMIT); + expect(estimateTokens(largeFile)).toBeGreaterThan(REVIEW_FILE_CONTENT_TOKEN_LIMIT); + }); + + it('limit allows for reasonable amount of file content', () => { + // 25k tokens * 4 chars = 100k characters + // This is enough for ~3-5 medium TypeScript files + const estimatedChars = REVIEW_FILE_CONTENT_TOKEN_LIMIT * 4; + + expect(estimatedChars).toBe(100_000); + expect(estimatedChars).toBeGreaterThan(50_000); // Minimum reasonable + expect(estimatedChars).toBeLessThan(200_000); // Maximum to avoid context overflow + }); + }); +}); diff --git a/tests/unit/config/statusUpdateConfig.test.ts b/tests/unit/config/statusUpdateConfig.test.ts new file mode 100644 index 00000000..eb22ad66 --- /dev/null +++ b/tests/unit/config/statusUpdateConfig.test.ts @@ -0,0 +1,275 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { + formatGitHubProgressComment, + formatStatusMessage, + getStatusUpdateConfig, +} from '../../../src/config/statusUpdateConfig.js'; + +// Mock todo storage +vi.mock('../../../src/gadgets/todo/storage.js', () => ({ + loadTodos: vi.fn(() => []), + formatTodoList: vi.fn(() => '- [ ] Task 1\n- [x] Task 2'), +})); + +import { formatTodoList, loadTodos } from '../../../src/gadgets/todo/storage.js'; + +describe('config/statusUpdateConfig', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('getStatusUpdateConfig', () => { + it('returns enabled config for non-debug agents', () => { + const agentTypes = ['implementation', 'briefing', 'planning', 'review']; + + for (const agentType of agentTypes) { + const config = getStatusUpdateConfig(agentType); + + expect(config.enabled).toBe(true); + expect(config.intervalMinutes).toBe(5); + expect(config.progressModel).toBe('openrouter:google/gemini-2.5-flash-lite'); + } + }); + + it('returns disabled config for debug agent', () => { + const config = getStatusUpdateConfig('debug'); + + expect(config.enabled).toBe(false); + expect(config.intervalMinutes).toBe(5); + expect(config.progressModel).toBe('openrouter:google/gemini-2.5-flash-lite'); + }); + + it('uses fast, cheap model for progress summaries', () => { + const config = getStatusUpdateConfig('implementation'); + + expect(config.progressModel).toBe('openrouter:google/gemini-2.5-flash-lite'); + }); + + it('has reasonable update interval', () => { + const config = getStatusUpdateConfig('implementation'); + + expect(config.intervalMinutes).toBeGreaterThan(0); + expect(config.intervalMinutes).toBeLessThanOrEqual(10); + }); + }); + + describe('formatStatusMessage', () => { + it('includes agent type and progress bar', () => { + vi.mocked(loadTodos).mockReturnValue([]); + + const message = formatStatusMessage(5, 20, 'implementation'); + + expect(message).toContain('**implementation agent progress**'); + expect(message).toContain('25%'); // (5/20) * 100 + expect(message).toContain('iteration 5/20'); + }); + + it('renders progress bar correctly at 0%', () => { + vi.mocked(loadTodos).mockReturnValue([]); + + const message = formatStatusMessage(0, 20, 'planning'); + + expect(message).toContain('[โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘]'); + expect(message).toContain('0%'); + }); + + it('renders progress bar correctly at 50%', () => { + vi.mocked(loadTodos).mockReturnValue([]); + + const message = formatStatusMessage(10, 20, 'implementation'); + + expect(message).toContain('[โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘]'); + expect(message).toContain('50%'); + }); + + it('renders progress bar correctly at 100%', () => { + vi.mocked(loadTodos).mockReturnValue([]); + + const message = formatStatusMessage(20, 20, 'implementation'); + + expect(message).toContain('[โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ]'); + expect(message).toContain('100%'); + }); + + it('rounds progress percentage', () => { + vi.mocked(loadTodos).mockReturnValue([]); + + const message = formatStatusMessage(7, 20, 'planning'); + + expect(message).toContain('35%'); // 7/20 = 0.35 -> 35% + }); + + it('includes task counts when todos exist', () => { + vi.mocked(loadTodos).mockReturnValue([ + { id: '1', content: 'Task 1', status: 'done' }, + { id: '2', content: 'Task 2', status: 'done' }, + { id: '3', content: 'Task 3', status: 'pending' }, + ]); + + const message = formatStatusMessage(10, 20, 'implementation'); + + expect(message).toContain('**Tasks:** 2/3 complete'); + }); + + it('includes current in-progress task', () => { + vi.mocked(loadTodos).mockReturnValue([ + { id: '1', content: 'Write tests', status: 'in_progress' }, + { id: '2', content: 'Fix linting', status: 'pending' }, + ]); + + const message = formatStatusMessage(5, 20, 'implementation'); + + expect(message).toContain('**Working on:** Write tests'); + }); + + it('does not include task section when no todos', () => { + vi.mocked(loadTodos).mockReturnValue([]); + + const message = formatStatusMessage(5, 20, 'implementation'); + + expect(message).not.toContain('**Tasks:**'); + expect(message).not.toContain('**Working on:**'); + }); + + it('does not include "Working on" when no in-progress todo', () => { + vi.mocked(loadTodos).mockReturnValue([ + { id: '1', content: 'Task 1', status: 'done' }, + { id: '2', content: 'Task 2', status: 'pending' }, + ]); + + const message = formatStatusMessage(8, 20, 'planning'); + + expect(message).toContain('**Tasks:**'); + expect(message).not.toContain('**Working on:**'); + }); + + it('formats message with proper markdown structure', () => { + vi.mocked(loadTodos).mockReturnValue([]); + + const message = formatStatusMessage(10, 20, 'implementation'); + + const lines = message.split('\n'); + expect(lines[0]).toBe('**implementation agent progress**'); + expect(lines[1]).toBe(''); + expect(lines[2]).toContain('[โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘]'); + }); + }); + + describe('formatGitHubProgressComment', () => { + it('includes header message and progress bar', () => { + vi.mocked(loadTodos).mockReturnValue([]); + vi.mocked(formatTodoList).mockReturnValue('- [ ] Task 1'); + + const comment = formatGitHubProgressComment('๐Ÿ” Reviewing PR...', 8, 20, 'review'); + + expect(comment).toContain('๐Ÿ” Reviewing PR...'); + expect(comment).toContain('**Progress:**'); + expect(comment).toContain('40%'); // (8/20) * 100 + expect(comment).toContain('iteration 8/20'); + }); + + it('includes formatted todo list', () => { + vi.mocked(loadTodos).mockReturnValue([{ id: '1', content: 'Task 1', status: 'pending' }]); + vi.mocked(formatTodoList).mockReturnValue('- [ ] Task 1\n- [x] Task 2'); + + const comment = formatGitHubProgressComment('๐Ÿ” Reviewing PR...', 5, 20, 'review'); + + expect(comment).toContain('- [ ] Task 1'); + expect(comment).toContain('- [x] Task 2'); + }); + + it('includes metadata footer with iteration and agent type', () => { + vi.mocked(loadTodos).mockReturnValue([]); + vi.mocked(formatTodoList).mockReturnValue(''); + + const comment = formatGitHubProgressComment( + '๐Ÿš€ Implementing feature...', + 12, + 25, + 'implementation', + ); + + expect(comment).toContain('Last updated: iteration 12 ยท implementation agent'); + }); + + it('separates sections with horizontal rule', () => { + vi.mocked(loadTodos).mockReturnValue([]); + vi.mocked(formatTodoList).mockReturnValue(''); + + const comment = formatGitHubProgressComment('Header text', 5, 20, 'review'); + + const lines = comment.split('\n'); + expect(lines).toContain('---'); + }); + + it('preserves header message exactly as provided', () => { + vi.mocked(loadTodos).mockReturnValue([]); + vi.mocked(formatTodoList).mockReturnValue(''); + + const headerWithMarkdown = '๐Ÿ” **Reviewing PR** #123\n\nThis is a test.'; + const comment = formatGitHubProgressComment(headerWithMarkdown, 5, 20, 'review'); + + expect(comment.startsWith(headerWithMarkdown)).toBe(true); + }); + + it('loads todos and formats them via formatTodoList', () => { + const todos = [ + { id: '1', content: 'Test 1', status: 'done' as const }, + { id: '2', content: 'Test 2', status: 'pending' as const }, + ]; + vi.mocked(loadTodos).mockReturnValue(todos); + vi.mocked(formatTodoList).mockReturnValue('formatted todos'); + + const comment = formatGitHubProgressComment('Header', 10, 20, 'implementation'); + + expect(loadTodos).toHaveBeenCalled(); + expect(formatTodoList).toHaveBeenCalledWith(todos); + expect(comment).toContain('formatted todos'); + }); + + it('renders progress bar at different percentages', () => { + vi.mocked(loadTodos).mockReturnValue([]); + vi.mocked(formatTodoList).mockReturnValue(''); + + const comment25 = formatGitHubProgressComment('Header', 5, 20, 'review'); + expect(comment25).toContain('[โ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘]'); // 25% -> rounds to 3 blocks + + const comment75 = formatGitHubProgressComment('Header', 15, 20, 'review'); + expect(comment75).toContain('[โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–‘โ–‘]'); // 75% -> rounds to 8 blocks + }); + }); + + describe('progress bar rendering', () => { + it('progress bar has exactly 10 blocks', () => { + vi.mocked(loadTodos).mockReturnValue([]); + + const message = formatStatusMessage(7, 20, 'implementation'); + + const progressBarMatch = message.match(/\[([โ–ˆโ–‘]+)\]/); + expect(progressBarMatch).toBeTruthy(); + expect(progressBarMatch?.[1].length).toBe(10); + }); + + it('progress bar uses filled and empty blocks', () => { + vi.mocked(loadTodos).mockReturnValue([]); + + const message = formatStatusMessage(6, 20, 'planning'); // 30% + + // 30% -> 3 filled, 7 empty + expect(message).toContain('[โ–ˆโ–ˆโ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘]'); + }); + + it('handles edge case percentages', () => { + vi.mocked(loadTodos).mockReturnValue([]); + + // 1/20 = 5% -> 0.5 rounds to 1 + const message1 = formatStatusMessage(1, 20, 'planning'); + expect(message1).toContain('[โ–ˆโ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘โ–‘]'); + + // 19/20 = 95% -> 9.5 rounds to 10 + const message19 = formatStatusMessage(19, 20, 'planning'); + expect(message19).toContain('[โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ]'); + }); + }); +});