import { describe, it, expect, vi } from 'vitest'; import type { StageContext, LLMProvider, CacheProvider, StageLogger, SystemPromptFetcher } from '../src/proxymodel/types.js'; import paginate from '../src/proxymodel/stages/paginate.js'; import summarizeTree from '../src/proxymodel/stages/summarize-tree.js'; function mockCtx( original: string, config: Record = {}, opts: { llmAvailable?: boolean; getSystemPrompt?: SystemPromptFetcher } = {}, ): StageContext { const mockLlm: LLMProvider = { async complete(prompt) { // For paginate: return JSON array of titles if (prompt.includes('short descriptive titles') || prompt.includes('JSON array')) { return '["Title A", "Title B"]'; } // For summarize: return a summary return `Summary of: ${prompt.slice(0, 40)}...`; }, available: () => opts.llmAvailable ?? false, }; const cache = new Map(); const mockCache: CacheProvider = { async getOrCompute(key, compute) { if (cache.has(key)) return cache.get(key)!; const val = await compute(); cache.set(key, val); return val; }, hash(content) { return content.slice(0, 8); }, async get(key) { return cache.get(key) ?? null; }, async set(key, value) { cache.set(key, value); }, }; const mockLog: StageLogger = { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), }; return { contentType: 'toolResult', sourceName: 'test/tool', projectName: 'test', sessionId: 'sess-1', originalContent: original, llm: mockLlm, cache: mockCache, log: mockLog, getSystemPrompt: opts.getSystemPrompt ?? (async (_name, fallback) => fallback), config, }; } describe('System prompt fetching in stages', () => { describe('paginate stage', () => { it('uses getSystemPrompt to fetch paginate-titles prompt', async () => { const fetchSpy = vi.fn(async (_name: string, fallback: string) => fallback); const content = 'A'.repeat(9000); // Larger than default pageSize (8000) const ctx = mockCtx(content, {}, { llmAvailable: true, getSystemPrompt: fetchSpy }); await paginate(content, ctx); expect(fetchSpy).toHaveBeenCalledWith( 'llm-paginate-titles', expect.stringContaining('{{pageCount}}'), ); }); it('falls back to hardcoded default when fetcher returns fallback', async () => { const content = 'B'.repeat(9000); const ctx = mockCtx(content, {}, { llmAvailable: true }); const result = await paginate(content, ctx); // Should still produce paginated output (uses default prompt) expect(result.content).toContain('pages'); }); it('interpolates {{pageCount}} in the fetched template', async () => { let capturedPrompt = ''; const customFetcher: SystemPromptFetcher = async (name, fallback) => { if (name === 'llm-paginate-titles') { return 'Custom: generate {{pageCount}} titles please'; } return fallback; }; const mockLlm: LLMProvider = { async complete(prompt) { capturedPrompt = prompt; return '["A", "B"]'; }, available: () => true, }; const content = 'C'.repeat(9000); const ctx = mockCtx(content, {}, { llmAvailable: true, getSystemPrompt: customFetcher }); // Override llm to capture the prompt (ctx as { llm: LLMProvider }).llm = mockLlm; await paginate(content, ctx); expect(capturedPrompt).toContain('Custom: generate 2 titles please'); expect(capturedPrompt).not.toContain('{{pageCount}}'); }); }); describe('summarize-tree stage', () => { it('uses getSystemPrompt to fetch llm-summarize prompt', async () => { const fetchSpy = vi.fn(async (_name: string, fallback: string) => fallback); // Need prose content > 2000 chars with headers to trigger LLM summary const sections = [ '# Section 1\n' + 'Word '.repeat(500), '# Section 2\n' + 'Text '.repeat(500), ].join('\n\n'); const ctx = mockCtx(sections, {}, { llmAvailable: true, getSystemPrompt: fetchSpy }); await summarizeTree(sections, ctx); expect(fetchSpy).toHaveBeenCalledWith( 'llm-summarize', expect.stringContaining('{{maxTokens}}'), ); }); it('interpolates {{maxTokens}} in the fetched template', async () => { let capturedPrompt = ''; const customFetcher: SystemPromptFetcher = async (name, fallback) => { if (name === 'llm-summarize') { return 'Custom summary in {{maxTokens}} tokens max'; } return fallback; }; const mockLlm: LLMProvider = { async complete(prompt) { capturedPrompt = prompt; return 'A brief summary'; }, available: () => true, }; const sections = [ '# Part A\n' + 'Content '.repeat(500), '# Part B\n' + 'More '.repeat(500), ].join('\n\n'); const ctx = mockCtx(sections, {}, { llmAvailable: true, getSystemPrompt: customFetcher }); (ctx as { llm: LLMProvider }).llm = mockLlm; await summarizeTree(sections, ctx); expect(capturedPrompt).toContain('Custom summary in 200 tokens max'); expect(capturedPrompt).not.toContain('{{maxTokens}}'); }); }); });