/** * Tests for prompt section drill-down. * * Verifies that large prompt responses are split into sections via the * content-pipeline plugin, and that clients can drill into individual * sections using _resultId + _section parameters on subsequent prompts/get calls. */ import { describe, it, expect, vi } from 'vitest'; import { createContentPipelinePlugin } from '../src/proxymodel/plugins/content-pipeline.js'; import type { PluginSessionContext } from '../src/proxymodel/plugin.js'; import type { JsonRpcRequest, JsonRpcResponse } from '../src/types.js'; import type { Section, LLMProvider, CacheProvider, StageLogger, ContentType } from '../src/proxymodel/types.js'; // ── Helpers ── /** Generate a large markdown prompt with multiple sections. */ function generateLargePrompt(sectionCount: number): string { const sections: string[] = []; for (let i = 1; i <= sectionCount; i++) { const body = `This is the content of section ${i}. `.repeat(80); // ~3200 chars each sections.push(`## Section ${i}\n\n${body}`); } return sections.join('\n\n'); } /** Generate a large JSON prompt (array of objects). */ function generateLargeJsonPrompt(itemCount: number): string { const items = Array.from({ length: itemCount }, (_, i) => ({ name: `item-${i + 1}`, description: `Description for item ${i + 1}. `.repeat(60), config: { enabled: true, priority: i + 1 }, })); return JSON.stringify(items, null, 2); } function createMockCtx(): PluginSessionContext { const state = new Map(); const mockLlm: LLMProvider = { async complete(prompt) { return `Summary: ${prompt.slice(0, 30)}...`; }, available: () => 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 { sessionId: 'test-session', projectName: 'test-project', state, llm: mockLlm, cache: mockCache, log: mockLog, discoverTools: async () => [], registerVirtualTool: vi.fn(), registerVirtualServer: vi.fn(), routeToUpstream: async (req: JsonRpcRequest) => ({ jsonrpc: '2.0' as const, id: req.id, result: {} }), fetchPromptIndex: async () => [], getSystemPrompt: async (_name: string, fallback: string) => fallback, processContent: async (toolName: string, content: string, contentType: ContentType) => { // Use real section-split stage for realistic testing const sectionSplit = (await import('../src/proxymodel/stages/section-split.js')).default; const ctx = { contentType, sourceName: toolName, projectName: 'test', sessionId: 'test-session', originalContent: content, llm: mockLlm, cache: mockCache, log: mockLog, getSystemPrompt: async (_n: string, fb: string) => fb, config: { minSectionSize: 500 }, }; return sectionSplit(content, ctx); }, queueNotification: vi.fn(), postToMcpd: async () => ({}), }; } function makePromptGetRequest(name: string, args?: Record): JsonRpcRequest { return { jsonrpc: '2.0', id: 1, method: 'prompts/get', params: { name, ...(args ? { arguments: args } : {}) }, }; } function makePromptResponse(id: number | string, text: string): JsonRpcResponse { return { jsonrpc: '2.0', id, result: { prompt: { name: 'test-prompt', description: 'A test prompt' }, messages: [ { role: 'user', content: { type: 'text', text } }, ], }, }; } function extractResponseText(response: JsonRpcResponse): string { const result = response.result as Record; // Prompt format: { messages: [{ content: { text } }] } if (result['messages']) { const messages = result['messages'] as Array<{ role: string; content: unknown }>; const c = messages[0].content; if (typeof c === 'string') return c; return (c as { text: string }).text; } // Tool result format: { content: [{ type: 'text', text }] } if (Array.isArray(result['content'])) { const parts = result['content'] as Array<{ type: string; text: string }>; return parts.map((p) => p.text).join('\n'); } throw new Error(`Unexpected response format: ${JSON.stringify(result)}`); } // ── Tests ── describe('Prompt section drill-down', () => { describe('onPromptGet hook', () => { it('returns null when no _resultId/_section params', async () => { const plugin = createContentPipelinePlugin(); const ctx = createMockCtx(); const request = makePromptGetRequest('my-prompt'); const result = await plugin.onPromptGet!('my-prompt', request, ctx); expect(result).toBeNull(); }); it('intercepts _resultId + _section and returns cached section', async () => { const plugin = createContentPipelinePlugin(); const ctx = createMockCtx(); // Manually store sections in the shared store (simulating prior pipeline run) const sections: Section[] = [ { id: 'overview', title: 'Overview', content: 'This is the overview section with lots of detail.' }, { id: 'setup', title: 'Setup', content: 'Step-by-step setup instructions here.' }, { id: 'api', title: 'API Reference', content: 'Full API documentation content.' }, ]; const store = new Map(); store.set('pm-test123', { sections, createdAt: Date.now() }); ctx.state.set('_contentPipeline_sections', store); // Drill into 'setup' section const request = makePromptGetRequest('my-prompt', { _resultId: 'pm-test123', _section: 'setup' }); const result = await plugin.onPromptGet!('my-prompt', request, ctx); expect(result).not.toBeNull(); const text = extractResponseText(result!); expect(text).toBe('Step-by-step setup instructions here.'); }); it('returns error for expired/invalid _resultId', async () => { const plugin = createContentPipelinePlugin(); const ctx = createMockCtx(); const request = makePromptGetRequest('my-prompt', { _resultId: 'pm-nonexistent', _section: 'setup' }); const result = await plugin.onPromptGet!('my-prompt', request, ctx); expect(result).not.toBeNull(); const text = extractResponseText(result!); expect(text).toContain('Cached result not found'); }); it('returns error for unknown section ID', async () => { const plugin = createContentPipelinePlugin(); const ctx = createMockCtx(); const sections: Section[] = [ { id: 'intro', title: 'Intro', content: 'Introduction content.' }, ]; const store = new Map(); store.set('pm-abc', { sections, createdAt: Date.now() }); ctx.state.set('_contentPipeline_sections', store); const request = makePromptGetRequest('my-prompt', { _resultId: 'pm-abc', _section: 'nonexistent' }); const result = await plugin.onPromptGet!('my-prompt', request, ctx); expect(result).not.toBeNull(); const text = extractResponseText(result!); expect(text).toContain("Section 'nonexistent' not found"); expect(text).toContain('intro'); }); }); describe('shared section store with tools', () => { it('prompt drill-down can access sections stored by tool pipeline', async () => { const plugin = createContentPipelinePlugin(); const ctx = createMockCtx(); // Simulate a tool call that stored sections (onToolCallAfter would do this) const toolSections: Section[] = [ { id: 'nodes', title: 'Nodes', content: 'List of all flow nodes.' }, { id: 'connections', title: 'Connections', content: 'Node connection map.' }, ]; const store = new Map(); store.set('pm-fromtool', { sections: toolSections, createdAt: Date.now() }); ctx.state.set('_contentPipeline_sections', store); // Prompt drill-down using a resultId that came from a tool result const request = makePromptGetRequest('any-prompt', { _resultId: 'pm-fromtool', _section: 'connections' }); const result = await plugin.onPromptGet!('any-prompt', request, ctx); expect(result).not.toBeNull(); const text = extractResponseText(result!); expect(text).toBe('Node connection map.'); }); }); describe('large prompt → section-split → drill-down (full cycle)', () => { it('markdown prompt is split into sections with TOC', async () => { const largePrompt = generateLargePrompt(5); expect(largePrompt.length).toBeGreaterThan(10000); // Run through section-split stage directly const sectionSplit = (await import('../src/proxymodel/stages/section-split.js')).default; const mockLog: StageLogger = { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }; const mockLlm: LLMProvider = { complete: async () => '', available: () => false }; const mockCache: CacheProvider = { getOrCompute: async (_, c) => c(), hash: (s) => s.slice(0, 8), get: async () => null, set: async () => {}, }; const result = await sectionSplit(largePrompt, { contentType: 'prompt', sourceName: 'test-prompt', projectName: 'test', sessionId: 'sess-1', originalContent: largePrompt, llm: mockLlm, cache: mockCache, log: mockLog, getSystemPrompt: async (_n: string, fb: string) => fb, config: { minSectionSize: 500 }, }); // Should have produced sections expect(result.sections).toBeDefined(); expect(result.sections!.length).toBeGreaterThanOrEqual(3); // TOC should list sections expect(result.content).toContain('sections'); expect(result.content).toContain('Use section parameter'); // Original was ~16K, TOC should be much shorter expect(result.content.length).toBeLessThan(largePrompt.length); // Log the transformation for visibility process.stderr.write(`\n--- Original prompt size: ${largePrompt.length} chars ---\n`); process.stderr.write(`--- Transformed TOC size: ${result.content.length} chars ---\n`); process.stderr.write(`--- Sections: ${result.sections!.map((s) => `${s.id} (${s.content.length})`).join(', ')} ---\n`); }); it('JSON prompt is split into per-item sections', async () => { const largeJson = generateLargeJsonPrompt(8); expect(largeJson.length).toBeGreaterThan(5000); const sectionSplit = (await import('../src/proxymodel/stages/section-split.js')).default; const mockLog: StageLogger = { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }; const mockLlm: LLMProvider = { complete: async () => '', available: () => false }; const mockCache: CacheProvider = { getOrCompute: async (_, c) => c(), hash: (s) => s.slice(0, 8), get: async () => null, set: async () => {}, }; const result = await sectionSplit(largeJson, { contentType: 'prompt', sourceName: 'json-prompt', projectName: 'test', sessionId: 'sess-1', originalContent: largeJson, llm: mockLlm, cache: mockCache, log: mockLog, getSystemPrompt: async (_n: string, fb: string) => fb, config: { minSectionSize: 200 }, }); expect(result.sections).toBeDefined(); expect(result.sections!.length).toBeGreaterThanOrEqual(4); // JSON array items should use name as section id expect(result.sections!.some((s) => s.id.startsWith('item-'))).toBe(true); process.stderr.write(`\n--- JSON prompt: ${largeJson.length} chars → ${result.sections!.length} sections ---\n`); }); it('full cycle: pipeline → store → drill-down → content', async () => { const plugin = createContentPipelinePlugin(); const ctx = createMockCtx(); // Step 1: Generate a large prompt response const largePrompt = generateLargePrompt(4); const originalResponse = makePromptResponse(1, largePrompt); process.stderr.write(`\n=== Full cycle test ===\n`); process.stderr.write(`Original prompt size: ${largePrompt.length} chars\n`); // Step 2: Simulate the router post-processing (processContent + store) const pipelineResult = await ctx.processContent('test-prompt', largePrompt, 'prompt'); expect(pipelineResult.sections).toBeDefined(); expect(pipelineResult.sections!.length).toBeGreaterThan(1); // Store sections like the router would const resultId = `pm-${Date.now().toString(36)}`; const store = new Map(); store.set(resultId, { sections: pipelineResult.sections!, createdAt: Date.now() }); ctx.state.set('_contentPipeline_sections', store); process.stderr.write(`Pipeline output (TOC):\n${pipelineResult.content}\n`); process.stderr.write(`Stored ${pipelineResult.sections!.length} sections under ${resultId}\n`); // Step 3: Drill into first section const firstSection = pipelineResult.sections![0]; const drillRequest = makePromptGetRequest('test-prompt', { _resultId: resultId, _section: firstSection.id, }); const drillResult = await plugin.onPromptGet!('test-prompt', drillRequest, ctx); expect(drillResult).not.toBeNull(); const drillText = extractResponseText(drillResult!); expect(drillText).toBe(firstSection.content); process.stderr.write(`Drill-down into "${firstSection.id}": ${drillText.length} chars\n`); // Step 4: Drill into last section const lastSection = pipelineResult.sections![pipelineResult.sections!.length - 1]; const drillRequest2 = makePromptGetRequest('test-prompt', { _resultId: resultId, _section: lastSection.id, }); const drillResult2 = await plugin.onPromptGet!('test-prompt', drillRequest2, ctx); expect(drillResult2).not.toBeNull(); const drillText2 = extractResponseText(drillResult2!); expect(drillText2).toBe(lastSection.content); process.stderr.write(`Drill-down into "${lastSection.id}": ${drillText2.length} chars\n`); process.stderr.write(`=== Full cycle complete ===\n`); }); }); });