diff --git a/server/src/index.ts b/server/src/index.ts index fa9154e..d877413 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -219,6 +219,8 @@ async function runLlmChat(params: { const availableProviders = providerOrder().filter(providerIsConfigured) let fullText = '' let usedProvider: ProviderName | null = null + let providerMeta: { provider: string; model: string } | null = null + let byokSucceeded = false const providerErrors: string[] = [] if (byok?.apiKey && byok?.baseUrl) { @@ -230,7 +232,17 @@ async function runLlmChat(params: { } else { fullText = await chatWithOpenAiCompatible(byokParams, messages) } - usedProvider = 'openrouter' // label it generically + let byokHost = 'custom' + try { + byokHost = new URL(byok.baseUrl).hostname + } catch { + // keep custom + } + providerMeta = { + provider: byok.provider?.trim() || byokHost || 'byok', + model: byok.model?.trim() || byokHost, + } + byokSucceeded = true } catch (err) { const msg = err instanceof Error ? err.message : String(err) providerErrors.push(`byok(${byok.provider}): ${msg}`) @@ -239,7 +251,7 @@ async function runLlmChat(params: { } } - if (!usedProvider) { + if (!byokSucceeded) { for (const provider of availableProviders) { try { const providerOpts = { jsonMode: !llmOnly, maxTokens: llmOnly ? undefined : 2048 } @@ -251,6 +263,7 @@ async function runLlmChat(params: { fullText = response.text } usedProvider = provider + providerMeta = { provider, model: getModelName(provider) } break } catch (err) { const msg = err instanceof Error ? err.message : String(err) @@ -265,7 +278,7 @@ async function runLlmChat(params: { } } - if (!usedProvider) { + if (!byokSucceeded && !usedProvider) { if (providerErrors.length) { console.warn('[llm] all providers failed:', providerErrors.join(' | ')) } @@ -291,7 +304,7 @@ async function runLlmChat(params: { message: text || 'I could not generate a response. Try rephrasing your question.', actions: [], source: 'llm', - meta: usedProvider ? { provider: usedProvider, model: getModelName(usedProvider) } : undefined, + meta: providerMeta ?? undefined, } } @@ -301,6 +314,7 @@ async function runLlmChat(params: { // If the LLM returned text that doesn't parse to valid actions, retry once // with a correction hint. This catches the common case where the model // returns prose instead of JSON, or malformed JSON. + // Only retry against server providers (not BYOK) to avoid wrong credentials. if (!stream && parsed.actions.length === 0 && fullText.trim().length > 0 && usedProvider) { const retryHint: Array<{ role: 'system' | 'user' | 'assistant'; content: string }> = [ ...messages, @@ -329,7 +343,7 @@ async function runLlmChat(params: { message: parsed.message, actions: parsed.actions, source: 'llm', - meta: usedProvider ? { provider: usedProvider, model: getModelName(usedProvider) } : undefined, + meta: providerMeta ?? undefined, } } diff --git a/src/ai/agentClient.test.ts b/src/ai/agentClient.test.ts new file mode 100644 index 0000000..70c8687 --- /dev/null +++ b/src/ai/agentClient.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest' +import { parseCompleteSseEvent, serverResponseToChatMessage } from './agentClient' + +describe('parseCompleteSseEvent', () => { + it('retains provider meta from complete events', () => { + const raw = JSON.stringify({ + type: 'complete', + message: 'Sorted column B', + actions: [], + source: 'llm', + meta: { provider: 'groq', model: 'llama-3.3-70b-versatile' }, + }) + const parsed = parseCompleteSseEvent(raw) + expect(parsed).not.toBeNull() + expect(parsed!.meta).toEqual({ + provider: 'groq', + model: 'llama-3.3-70b-versatile', + }) + }) + + it('returns null for token events', () => { + expect(parseCompleteSseEvent(JSON.stringify({ type: 'token', content: 'hi' }))).toBeNull() + }) + + it('returns null for malformed JSON', () => { + expect(parseCompleteSseEvent('{not-json')).toBeNull() + }) +}) + +describe('serverResponseToChatMessage', () => { + it('copies provider meta onto ChatMessage', () => { + const msg = serverResponseToChatMessage({ + message: 'Done', + actions: [], + source: 'llm', + meta: { provider: 'groq', model: 'llama-3.3-70b-versatile' }, + }) + expect(msg.providerMeta).toEqual({ + provider: 'groq', + model: 'llama-3.3-70b-versatile', + }) + }) +}) diff --git a/src/ai/agentClient.ts b/src/ai/agentClient.ts index 392fd9a..6e90f10 100644 --- a/src/ai/agentClient.ts +++ b/src/ai/agentClient.ts @@ -1,5 +1,5 @@ import { v4 as uuid } from 'uuid' -import type { AgentAction, ChatMessage } from '@/types' +import type { AgentAction, ChatMessage, ProviderMeta } from '@/types' import type { SpreadsheetContextPayload } from '@/ai/buildContext' import { getAuthHeaders } from '@/lib/cloudSync' import { getByokPayload } from '@/lib/userApiKey' @@ -18,6 +18,33 @@ export interface ServerChatResponse { source: 'llm' | 'fallback' | 'template' reasoning?: string suggestions?: string[] + meta?: ProviderMeta +} + +/** Parse an SSE `data:` JSON payload into a ServerChatResponse when type=complete. */ +export function parseCompleteSseEvent(jsonStr: string): ServerChatResponse | null { + try { + const event = JSON.parse(jsonStr) as { + type?: string + message?: string + actions?: ServerAgentAction[] + source?: string + reasoning?: string + suggestions?: string[] + meta?: ProviderMeta + } + if (event.type !== 'complete' || typeof event.message !== 'string') return null + return { + message: event.message, + actions: Array.isArray(event.actions) ? event.actions : [], + source: (event.source as ServerChatResponse['source']) ?? 'llm', + reasoning: event.reasoning, + suggestions: event.suggestions, + meta: event.meta, + } + } catch { + return null + } } export interface ServerHealth { @@ -106,21 +133,13 @@ export async function chatWithAgentServerStream( if (!jsonStr) continue try { - const event = JSON.parse(jsonStr) as - | { type: 'token'; content: string } - | { type: 'complete'; message: string; actions: ServerAgentAction[]; source: string; reasoning?: string; suggestions?: string[] } - - if (event.type === 'token') { - onToken(event.content) - } else if (event.type === 'complete') { - finalResponse = { - message: event.message, - actions: event.actions, - source: event.source as ServerChatResponse['source'], - reasoning: event.reasoning, - suggestions: event.suggestions, - } + const parsed = JSON.parse(jsonStr) as { type?: string; content?: string } + if (parsed.type === 'token' && typeof parsed.content === 'string') { + onToken(parsed.content) + continue } + const complete = parseCompleteSseEvent(jsonStr) + if (complete) finalResponse = complete } catch { // Skip malformed events } @@ -153,5 +172,6 @@ export function serverResponseToChatMessage( timestamp, suggestions: response.suggestions, actions: actions.length > 0 ? actions : undefined, + providerMeta: response.meta, } } diff --git a/src/ai/brain.ts b/src/ai/brain.ts index faa46e9..7d8739a 100644 --- a/src/ai/brain.ts +++ b/src/ai/brain.ts @@ -692,6 +692,7 @@ export async function processMessage(input: ProcessMessageInput): Promise 0 ? contextualSuggestions : (deterministic?.suggestions ?? serverResult.suggestions), diff --git a/src/ai/pipeline/__tests__/pipeline.integration.test.ts b/src/ai/pipeline/__tests__/pipeline.integration.test.ts index 54cb5f0..f2805b5 100644 --- a/src/ai/pipeline/__tests__/pipeline.integration.test.ts +++ b/src/ai/pipeline/__tests__/pipeline.integration.test.ts @@ -8,9 +8,9 @@ * handles each input type: * - "sort by amount" → AgentParser claims (stage 1) * - "Create a monthly budget" → TemplateResolver claims (stage 2) - * - "analyze my expenses" → BrainDispatcher claims (stage 4 — deterministic path) - * - "Explain my data" → BrainDispatcher claims (stage 5 — LLM path) - * - Unknown gibberish → BrainDispatcher claims (final fallback) + * - "analyze my expenses" → DeterministicDispatcher claims (deterministic path) + * - "Explain my data" → LLMGateway claims (LLM path) + * - Unknown gibberish → LLMGateway claims (final fallback) */ import { describe, it, expect, vi, beforeEach } from 'vitest' @@ -40,7 +40,20 @@ vi.mock('@/lib/deleteRowPreview', () => ({ })) vi.mock('@/ai/buildContext', () => ({ - buildSpreadsheetContext: () => ({ profile: { columns: [] }, insights: {} }), + buildSpreadsheetContext: () => ({ + workbookName: 'Test', + activeSheet: 'Sheet1', + sheetNames: ['Sheet1'], + sheetSummaries: [], + selectedCells: [], + dimensions: { rows: 1, cols: 1, populatedCells: 0 }, + headers: [], + sampleRows: [], + sampleRowsTruncated: false, + selectionSnapshot: {}, + insights: { headers: [], columnStats: [], outliers: [] }, + profile: { name: 'S', rowCount: 1, colCount: 1, columns: [], detectedPurpose: 'generic' }, + }), })) vi.mock('@shared/toolRegistry', () => ({ @@ -53,21 +66,116 @@ vi.mock('@/templates', () => ({ executeTemplateTool: vi.fn(), })) -// BrainDispatcher depends on @/ai/brain -vi.mock('@/ai/brain', () => ({ - processMessage: vi.fn(), +vi.mock('@/ai/agentClient', () => ({ + chatWithAgentServerStream: vi.fn(), +})) + +vi.mock('@shared/intentParser', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + parseUserIntent: vi.fn(), + isQueryIntent: vi.fn(() => false), + } +}) + +vi.mock('@shared/mode', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + classifyMode: vi.fn(), + isBudgetExplainQuery: vi.fn(() => false), + } +}) + +vi.mock('@/ai/analysis/budget', () => ({ + analyzeBudget: vi.fn(() => ({})), + budgetAnalysisToToolResult: vi.fn(() => ({ + success: true, + message: 'Here is your expense analysis: Total expenses $2,450...', + })), + savingsRecommendation: vi.fn(), +})) + +vi.mock('@/ai/analysisTarget', () => ({ + resolveAnalysisTarget: () => ({ + sheet: { cells: {} }, + workbook: { sheets: [], name: 'Test' }, + workbookName: 'Test', + getComputedValue: () => '', + getSheetComputedValue: () => '', + context: { + insights: {}, + profile: { columns: [], detectedPurpose: 'generic', name: 'S', rowCount: 0, colCount: 0 }, + sampleRows: [], + }, + isAttached: false, + }), +})) + +vi.mock('@/ai/sheetProfile', () => ({ + buildSheetProfile: () => ({ + name: 'S', + rowCount: 1, + colCount: 1, + columns: [], + detectedPurpose: 'budget', + hasHeaders: true, + }), +})) + +vi.mock('@/ai/analysis/reporting', () => ({ + generateReport: vi.fn(), +})) + +vi.mock('@/ai/analysis/cleaning', () => ({ + runCleaningSkill: vi.fn(), +})) + +vi.mock('@/ai/queryEngine', () => ({ + runQueryFromIntent: vi.fn(), +})) + +vi.mock('@/ai/comparison', () => ({ + queryComparison: vi.fn(), +})) + +vi.mock('@/ai/responseBuilder', () => ({ + explainOutliers: vi.fn(() => ''), + formatInsights: vi.fn(() => ''), + mergeToolResultContent: vi.fn((parts: string[]) => parts.filter(Boolean).join('\n\n')), +})) + +vi.mock('@/ai/outliers', () => ({ + isOutlierFollowUp: vi.fn(() => false), +})) + +vi.mock('@/ai/mode', () => ({ + isLlmOnlyMode: vi.fn(() => false), +})) + +vi.mock('@/auditor', () => ({ + runAudit: vi.fn(() => ({ findings: [], score: 100 })), + formatAuditForContext: vi.fn(() => ''), +})) + +vi.mock('@/ai/contextualSuggestions', () => ({ + getContextualSuggestions: vi.fn(() => []), })) // ─── Imports (after mocks) ────────────────────────────────────────────────── import { parseMessage, executeToolAsync } from '@/agent' import { resolveGalleryTemplate, executeTemplateTool } from '@/templates' -import { processMessage } from '@/ai/brain' +import { chatWithAgentServerStream } from '@/ai/agentClient' +import { parseUserIntent } from '@shared/intentParser' +import { classifyMode } from '@shared/mode' import { createPipelineRouter } from '../router' import { createAgentParserStage } from '../stages/agentParser' import { createTemplateResolverStage } from '../stages/templateResolver' import { createIntentClassifierStage } from '../stages/intentClassifier' -import { createBrainDispatcherStage } from '../stages/brainDispatcher' +import { createDeterministicDispatcherStage } from '../stages/deterministicDispatcher' +import { createLLMGatewayStage } from '../stages/llmGateway' // ─── Helpers ──────────────────────────────────────────────────────────────── @@ -94,11 +202,21 @@ function buildPipeline() { createAgentParserStage(deps), createTemplateResolverStage(deps), createIntentClassifierStage(), - createBrainDispatcherStage(), + createDeterministicDispatcherStage(), + createLLMGatewayStage(), ]) return { router, deps } } +function defaultIntent() { + return { + intentType: 'general' as const, + confidence: 0.3, + routingSource: 'regex' as const, + parameters: {}, + } +} + // ─── Tests ────────────────────────────────────────────────────────────────── describe('Pipeline Integration: end-to-end routing', () => { @@ -112,9 +230,12 @@ describe('Pipeline Integration: end-to-end routing', () => { explanation: undefined, }) vi.mocked(resolveGalleryTemplate).mockReturnValue(null) - vi.mocked(processMessage).mockResolvedValue({ - success: true, - message: 'Processed by brain', + vi.mocked(parseUserIntent).mockReturnValue(defaultIntent() as ReturnType) + vi.mocked(classifyMode).mockReturnValue('chat') + vi.mocked(chatWithAgentServerStream).mockResolvedValue({ + message: 'Processed by LLM', + actions: [], + source: 'llm', }) }) @@ -136,9 +257,9 @@ describe('Pipeline Integration: end-to-end routing', () => { expect(result.stageName).toBe('agent-parser') expect(result.success).toBe(true) - // TemplateResolver and BrainDispatcher should NOT have been called + // TemplateResolver and LLMGateway should NOT have been called expect(resolveGalleryTemplate).not.toHaveBeenCalled() - expect(processMessage).not.toHaveBeenCalled() + expect(chatWithAgentServerStream).not.toHaveBeenCalled() }) it('"Create a monthly budget" → TemplateResolver claims (stage 2)', async () => { @@ -168,11 +289,11 @@ describe('Pipeline Integration: end-to-end routing', () => { expect(result.stageName).toBe('template-resolver') expect(result.success).toBe(true) expect(result.message).toContain('Monthly Budget template applied') - // BrainDispatcher should NOT have been called - expect(processMessage).not.toHaveBeenCalled() + // LLMGateway should NOT have been called + expect(chatWithAgentServerStream).not.toHaveBeenCalled() }) - it('"analyze my expenses" → BrainDispatcher claims (stage 4 — deterministic path)', async () => { + it('"analyze my expenses" → DeterministicDispatcher claims (deterministic path)', async () => { // AgentParser doesn't understand vi.mocked(parseMessage).mockReturnValue({ understood: false, @@ -182,24 +303,24 @@ describe('Pipeline Integration: end-to-end routing', () => { // TemplateResolver doesn't match vi.mocked(resolveGalleryTemplate).mockReturnValue(null) - // BrainDispatcher handles it (deterministic skill internally) - vi.mocked(processMessage).mockResolvedValue({ - success: true, - message: 'Here is your expense analysis: Total expenses $2,450...', - toolUsed: 'budget', - suggestions: ['Show me spending by category', 'Compare to last month'], - }) + vi.mocked(parseUserIntent).mockReturnValue({ + intentType: 'budget', + confidence: 0.9, + routingSource: 'regex', + parameters: {}, + } as ReturnType) + vi.mocked(classifyMode).mockReturnValue('advise') const { router } = buildPipeline() const result = await router.process(makeContext('analyze my expenses')) - expect(result.stageName).toBe('brain-dispatcher') + expect(result.stageName).toBe('deterministic-dispatcher') expect(result.success).toBe(true) expect(result.message).toContain('expense analysis') - expect(processMessage).toHaveBeenCalledTimes(1) + expect(chatWithAgentServerStream).not.toHaveBeenCalled() }) - it('"Explain my data" → BrainDispatcher claims (stage 5 — LLM path)', async () => { + it('"Explain my data" → LLMGateway claims (LLM path)', async () => { // AgentParser doesn't understand vi.mocked(parseMessage).mockReturnValue({ understood: false, @@ -209,24 +330,25 @@ describe('Pipeline Integration: end-to-end routing', () => { // TemplateResolver doesn't match vi.mocked(resolveGalleryTemplate).mockReturnValue(null) - // BrainDispatcher handles it (LLM path internally) - vi.mocked(processMessage).mockResolvedValue({ - success: true, + vi.mocked(parseUserIntent).mockReturnValue(defaultIntent() as ReturnType) + vi.mocked(classifyMode).mockReturnValue('explain') + vi.mocked(chatWithAgentServerStream).mockResolvedValue({ message: 'Your spreadsheet contains financial data with 3 columns...', - toolUsed: 'llm', + actions: [], + source: 'llm', reasoning: 'Used LLM to explain sheet contents', }) const { router } = buildPipeline() const result = await router.process(makeContext('Explain my data')) - expect(result.stageName).toBe('brain-dispatcher') + expect(result.stageName).toBe('llm-gateway') expect(result.success).toBe(true) expect(result.message).toContain('financial data') - expect(processMessage).toHaveBeenCalledTimes(1) + expect(chatWithAgentServerStream).toHaveBeenCalledTimes(1) }) - it('unknown gibberish → BrainDispatcher claims (final fallback)', async () => { + it('unknown gibberish → LLMGateway claims (final fallback)', async () => { // AgentParser doesn't understand vi.mocked(parseMessage).mockReturnValue({ understood: false, @@ -236,23 +358,24 @@ describe('Pipeline Integration: end-to-end routing', () => { // TemplateResolver doesn't match vi.mocked(resolveGalleryTemplate).mockReturnValue(null) - // BrainDispatcher always claims as terminal stage (LLM fallback) - vi.mocked(processMessage).mockResolvedValue({ - success: true, + vi.mocked(parseUserIntent).mockReturnValue(defaultIntent() as ReturnType) + vi.mocked(classifyMode).mockReturnValue('chat') + vi.mocked(chatWithAgentServerStream).mockResolvedValue({ message: "I'm not sure what you mean. Could you rephrase?", - toolUsed: 'llm', + actions: [], + source: 'llm', }) const { router } = buildPipeline() const result = await router.process(makeContext('xyzzy wombat platypus 42')) - expect(result.stageName).toBe('brain-dispatcher') + expect(result.stageName).toBe('llm-gateway') expect(result.success).toBe(true) // IntentClassifier should have enriched context (always passes through) - expect(processMessage).toHaveBeenCalledTimes(1) + expect(chatWithAgentServerStream).toHaveBeenCalledTimes(1) }) - it('IntentClassifier enriches context before BrainDispatcher receives it', async () => { + it('IntentClassifier enriches context before downstream stages receive it', async () => { // AgentParser and TemplateResolver both pass vi.mocked(parseMessage).mockReturnValue({ understood: false, @@ -261,11 +384,13 @@ describe('Pipeline Integration: end-to-end routing', () => { }) vi.mocked(resolveGalleryTemplate).mockReturnValue(null) - // Capture the input passed to processMessage to verify context was enriched - vi.mocked(processMessage).mockResolvedValue({ - success: true, - message: 'Budget analysis result', - }) + vi.mocked(parseUserIntent).mockReturnValue({ + intentType: 'budget', + confidence: 0.9, + routingSource: 'regex', + parameters: {}, + } as ReturnType) + vi.mocked(classifyMode).mockReturnValue('advise') const { router } = buildPipeline() const context = makeContext('analyze my expenses') @@ -313,17 +438,20 @@ describe('Pipeline Integration: end-to-end routing', () => { }) // TemplateResolver doesn't match vi.mocked(resolveGalleryTemplate).mockReturnValue(null) - // BrainDispatcher catches it - vi.mocked(processMessage).mockResolvedValue({ - success: true, - message: 'Recovered via brain', + // LLMGateway catches the fallthrough as terminal stage + vi.mocked(parseUserIntent).mockReturnValue(defaultIntent() as ReturnType) + vi.mocked(classifyMode).mockReturnValue('chat') + vi.mocked(chatWithAgentServerStream).mockResolvedValue({ + message: 'Recovered via LLM', + actions: [], + source: 'llm', }) const { router } = buildPipeline() const result = await router.process(makeContext('sort by amount')) - // Pipeline should recover — BrainDispatcher catches the fallthrough - expect(result.stageName).toBe('brain-dispatcher') + // Pipeline should recover — LLMGateway catches the fallthrough + expect(result.stageName).toBe('llm-gateway') expect(result.success).toBe(true) }) }) diff --git a/src/ai/pipeline/__tests__/smokeTest.integration.test.ts b/src/ai/pipeline/__tests__/smokeTest.integration.test.ts index 475f57e..4d57b6b 100644 --- a/src/ai/pipeline/__tests__/smokeTest.integration.test.ts +++ b/src/ai/pipeline/__tests__/smokeTest.integration.test.ts @@ -9,8 +9,8 @@ * - "Sort by Amount descending" → instant (AgentParser) * - "Highlight cells over 500 red" → instant (AgentParser) * - "Create a monthly budget" → instant (TemplateResolver) - * - "Analyze my expenses" → deterministic (BrainDispatcher, no LLM) - * - "What does this data mean?" → LLM stream (BrainDispatcher) + * - "Analyze my expenses" → deterministic (DeterministicDispatcher, no LLM) + * - "What does this data mean?" → LLM stream (LLMGateway) * - "Delete row Netflix" → preview/confirm flow (AgentParser) */ @@ -39,7 +39,20 @@ vi.mock('@/lib/deleteRowPreview', () => ({ })) vi.mock('@/ai/buildContext', () => ({ - buildSpreadsheetContext: () => ({ profile: { columns: [] }, insights: {} }), + buildSpreadsheetContext: () => ({ + workbookName: 'Test', + activeSheet: 'Sheet1', + sheetNames: ['Sheet1'], + sheetSummaries: [], + selectedCells: [], + dimensions: { rows: 1, cols: 1, populatedCells: 0 }, + headers: [], + sampleRows: [], + sampleRowsTruncated: false, + selectionSnapshot: {}, + insights: { headers: [], columnStats: [], outliers: [] }, + profile: { name: 'S', rowCount: 1, colCount: 1, columns: [], detectedPurpose: 'generic' }, + }), })) vi.mock('@shared/toolRegistry', () => ({ @@ -51,21 +64,117 @@ vi.mock('@/templates', () => ({ executeTemplateTool: vi.fn(), })) -vi.mock('@/ai/brain', () => ({ - processMessage: vi.fn(), +vi.mock('@/ai/agentClient', () => ({ + chatWithAgentServerStream: vi.fn(), +})) + +vi.mock('@shared/intentParser', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + parseUserIntent: vi.fn(), + isQueryIntent: vi.fn(() => false), + } +}) + +vi.mock('@shared/mode', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + classifyMode: vi.fn(), + isBudgetExplainQuery: vi.fn(() => false), + } +}) + +vi.mock('@/ai/analysis/budget', () => ({ + analyzeBudget: vi.fn(() => ({})), + budgetAnalysisToToolResult: vi.fn(() => ({ + success: true, + message: 'Your total expenses are $2,450 across 12 categories. Top spending: Rent ($1,200), Groceries ($450).', + })), + savingsRecommendation: vi.fn(), +})) + +vi.mock('@/ai/analysisTarget', () => ({ + resolveAnalysisTarget: () => ({ + sheet: { cells: {} }, + workbook: { sheets: [], name: 'Test' }, + workbookName: 'Test', + getComputedValue: () => '', + getSheetComputedValue: () => '', + context: { + insights: {}, + profile: { columns: [], detectedPurpose: 'generic', name: 'S', rowCount: 0, colCount: 0 }, + sampleRows: [], + }, + isAttached: false, + }), +})) + +vi.mock('@/ai/sheetProfile', () => ({ + buildSheetProfile: () => ({ + name: 'S', + rowCount: 1, + colCount: 1, + columns: [], + detectedPurpose: 'budget', + hasHeaders: true, + }), +})) + +vi.mock('@/ai/analysis/reporting', () => ({ + generateReport: vi.fn(), +})) + +vi.mock('@/ai/analysis/cleaning', () => ({ + runCleaningSkill: vi.fn(), +})) + +vi.mock('@/ai/queryEngine', () => ({ + runQueryFromIntent: vi.fn(), +})) + +vi.mock('@/ai/comparison', () => ({ + queryComparison: vi.fn(), +})) + +vi.mock('@/ai/responseBuilder', () => ({ + explainOutliers: vi.fn(() => ''), + formatInsights: vi.fn(() => ''), + mergeToolResultContent: vi.fn((parts: string[]) => parts.filter(Boolean).join('\n\n')), +})) + +vi.mock('@/ai/outliers', () => ({ + isOutlierFollowUp: vi.fn(() => false), +})) + +vi.mock('@/ai/mode', () => ({ + isLlmOnlyMode: vi.fn(() => false), +})) + +vi.mock('@/auditor', () => ({ + runAudit: vi.fn(() => ({ findings: [], score: 100 })), + formatAuditForContext: vi.fn(() => ''), +})) + +vi.mock('@/ai/contextualSuggestions', () => ({ + getContextualSuggestions: vi.fn(() => []), })) // ─── Imports (after mocks) ────────────────────────────────────────────────── import { parseMessage, executeToolAsync } from '@/agent' import { resolveGalleryTemplate, executeTemplateTool } from '@/templates' -import { processMessage } from '@/ai/brain' +import { chatWithAgentServerStream } from '@/ai/agentClient' +import { parseUserIntent } from '@shared/intentParser' +import { classifyMode } from '@shared/mode' import { findDeleteRowMatches } from '@/lib/deleteRowPreview' import { createPipelineRouter } from '../router' import { createAgentParserStage } from '../stages/agentParser' import { createTemplateResolverStage } from '../stages/templateResolver' import { createIntentClassifierStage } from '../stages/intentClassifier' -import { createBrainDispatcherStage } from '../stages/brainDispatcher' +import { createDeterministicDispatcherStage } from '../stages/deterministicDispatcher' +import { createLLMGatewayStage } from '../stages/llmGateway' // ─── Helpers ──────────────────────────────────────────────────────────────── @@ -92,11 +201,21 @@ function buildPipeline() { createAgentParserStage(deps), createTemplateResolverStage(deps), createIntentClassifierStage(), - createBrainDispatcherStage(), + createDeterministicDispatcherStage(), + createLLMGatewayStage(), ]) return { router, deps } } +function defaultIntent() { + return { + intentType: 'general' as const, + confidence: 0.3, + routingSource: 'regex' as const, + parameters: {}, + } +} + // ─── Smoke Tests ──────────────────────────────────────────────────────────── describe('Smoke Test: Top commands routing verification', () => { @@ -110,9 +229,12 @@ describe('Smoke Test: Top commands routing verification', () => { explanation: undefined, }) vi.mocked(resolveGalleryTemplate).mockReturnValue(null) - vi.mocked(processMessage).mockResolvedValue({ - success: true, - message: 'Processed by brain', + vi.mocked(parseUserIntent).mockReturnValue(defaultIntent() as ReturnType) + vi.mocked(classifyMode).mockReturnValue('chat') + vi.mocked(chatWithAgentServerStream).mockResolvedValue({ + message: 'Processed by LLM', + actions: [], + source: 'llm', }) }) @@ -136,7 +258,7 @@ describe('Smoke Test: Top commands routing verification', () => { expect(result.message).toContain('Sort') // Downstream stages NOT called expect(resolveGalleryTemplate).not.toHaveBeenCalled() - expect(processMessage).not.toHaveBeenCalled() + expect(chatWithAgentServerStream).not.toHaveBeenCalled() }) it('"Highlight cells over 500 red" → AgentParser claims (instant)', async () => { @@ -162,7 +284,7 @@ describe('Smoke Test: Top commands routing verification', () => { expect(result.success).toBe(true) // Downstream stages NOT called expect(resolveGalleryTemplate).not.toHaveBeenCalled() - expect(processMessage).not.toHaveBeenCalled() + expect(chatWithAgentServerStream).not.toHaveBeenCalled() }) it('"Create a monthly budget" → TemplateResolver claims (instant)', async () => { @@ -192,8 +314,8 @@ describe('Smoke Test: Top commands routing verification', () => { expect(result.stageName).toBe('template-resolver') expect(result.success).toBe(true) expect(result.message).toContain('Monthly Budget') - // BrainDispatcher NOT called - expect(processMessage).not.toHaveBeenCalled() + // LLMGateway NOT called + expect(chatWithAgentServerStream).not.toHaveBeenCalled() }) it('"Analyze my expenses" → deterministic (no LLM)', async () => { @@ -206,25 +328,26 @@ describe('Smoke Test: Top commands routing verification', () => { // TemplateResolver doesn't match vi.mocked(resolveGalleryTemplate).mockReturnValue(null) - // BrainDispatcher handles it via deterministic skill (budget/analyze) - vi.mocked(processMessage).mockResolvedValue({ - success: true, - message: 'Your total expenses are $2,450 across 12 categories. Top spending: Rent ($1,200), Groceries ($450).', - toolUsed: 'budget', - suggestions: ['Show spending by category', 'Compare to last month'], - }) + // IntentClassifier → budget / advise so DeterministicDispatcher claims + vi.mocked(parseUserIntent).mockReturnValue({ + intentType: 'budget', + confidence: 0.9, + routingSource: 'regex', + parameters: {}, + } as ReturnType) + vi.mocked(classifyMode).mockReturnValue('advise') const { router } = buildPipeline() const result = await router.process(makeContext('Analyze my expenses')) - expect(result.stageName).toBe('brain-dispatcher') + expect(result.stageName).toBe('deterministic-dispatcher') expect(result.success).toBe(true) expect(result.message).toContain('expenses') - // Verify the brain handled it (deterministic path — no streaming) - expect(processMessage).toHaveBeenCalledTimes(1) + // Deterministic path — no LLM streaming + expect(chatWithAgentServerStream).not.toHaveBeenCalled() }) - it('"What does this data mean?" → LLM stream (BrainDispatcher)', async () => { + it('"What does this data mean?" → LLM stream (LLMGateway)', async () => { // AgentParser doesn't understand vi.mocked(parseMessage).mockReturnValue({ understood: false, @@ -234,22 +357,22 @@ describe('Smoke Test: Top commands routing verification', () => { // TemplateResolver doesn't match vi.mocked(resolveGalleryTemplate).mockReturnValue(null) - // BrainDispatcher routes to LLM for explanation - vi.mocked(processMessage).mockResolvedValue({ - success: true, + // General intent / explain mode → deterministic passes → LLMGateway claims + vi.mocked(parseUserIntent).mockReturnValue(defaultIntent() as ReturnType) + vi.mocked(classifyMode).mockReturnValue('explain') + vi.mocked(chatWithAgentServerStream).mockResolvedValue({ message: 'This spreadsheet contains monthly expense data with columns for Category, Amount, and Date. It appears to track household spending over the last 6 months.', - toolUsed: 'llm', - reasoning: 'Used LLM to explain data meaning', + actions: [], + source: 'llm', }) const { router } = buildPipeline() const result = await router.process(makeContext('What does this data mean?')) - expect(result.stageName).toBe('brain-dispatcher') + expect(result.stageName).toBe('llm-gateway') expect(result.success).toBe(true) expect(result.message).toContain('spreadsheet') - // Verify LLM path was used - expect(processMessage).toHaveBeenCalledTimes(1) + expect(chatWithAgentServerStream).toHaveBeenCalledTimes(1) }) it('"Delete row Netflix" → AgentParser claims with preview/confirm flow', async () => { @@ -275,6 +398,6 @@ describe('Smoke Test: Top commands routing verification', () => { expect(result.success).toBe(true) // Verify it was claimed by AgentParser, not passed to downstream expect(resolveGalleryTemplate).not.toHaveBeenCalled() - expect(processMessage).not.toHaveBeenCalled() + expect(chatWithAgentServerStream).not.toHaveBeenCalled() }) }) diff --git a/src/ai/pipeline/index.ts b/src/ai/pipeline/index.ts index 11eed9d..179e5d0 100644 --- a/src/ai/pipeline/index.ts +++ b/src/ai/pipeline/index.ts @@ -17,6 +17,7 @@ export { createAgentParserStage, createTemplateResolverStage, createIntentClassifierStage, - createBrainDispatcherStage, + createDeterministicDispatcherStage, + createLLMGatewayStage, } from './stages' export type { AgentParserDeps, TemplateResolverDeps } from './stages' diff --git a/src/ai/pipeline/stages/deterministicDispatcher.ts b/src/ai/pipeline/stages/deterministicDispatcher.ts index 3a4a3c2..051a824 100644 --- a/src/ai/pipeline/stages/deterministicDispatcher.ts +++ b/src/ai/pipeline/stages/deterministicDispatcher.ts @@ -13,7 +13,7 @@ */ import type { PipelineContext, PipelineStage, StageResult } from '../types' -import type { AnalysisTarget } from '@/ai/analysisTarget' +import { resolveAnalysisTarget, type AnalysisTarget } from '@/ai/analysisTarget' import type { SheetInsights } from '@/ai/sheetInsights' import type { ToolResult } from '@/ai/types' import { buildSheetProfile } from '@/ai/sheetProfile' @@ -125,13 +125,12 @@ function toStageResult(result: ToolResult): StageResult { /** * Creates the DeterministicDispatcher pipeline stage. * - * @param target - The resolved analysis target (sheet, workbook, context) - * @param workbookName - Display name of the active workbook - * @param priorInsights - Insights from the previous assistant turn (for follow-ups) + * When `target` is omitted, resolves the analysis target from PipelineContext + * (production chatService path). Tests may still pass an explicit target. */ export function createDeterministicDispatcherStage( - target: AnalysisTarget, - workbookName: string, + target?: AnalysisTarget, + workbookName?: string, priorInsights?: SheetInsights | null, ): PipelineStage { return { @@ -145,12 +144,23 @@ export function createDeterministicDispatcherStage( // If IntentClassifier hasn't enriched context, we can't dispatch if (!intent || !mode) return null - const profile = buildSheetProfile(target.sheet, target.getComputedValue) - const insights = target.context.insights + const resolvedTarget = target ?? resolveAnalysisTarget({ + workbook: context.workbook, + sheet: context.sheet, + selection: context.selection, + getComputedValue: context.getComputedValue, + getSheetComputedValue: context.getSheetComputedValue, + attachedPreview: context.attachedPreview, + }) + const resolvedWorkbookName = workbookName ?? resolvedTarget.workbookName + const resolvedPrior = priorInsights !== undefined ? priorInsights : context.priorInsights + + const profile = buildSheetProfile(resolvedTarget.sheet, resolvedTarget.getComputedValue) + const insights = resolvedTarget.context.insights // ─── Outlier Follow-Up ────────────────────────────────────────────────── if (isOutlierFollowUp(message)) { - const outliers = resolveOutliersForFollowUp(insights, priorInsights) + const outliers = resolveOutliersForFollowUp(insights, resolvedPrior) const result: ToolResult = { success: true, message: explainOutliers(outliers), @@ -171,7 +181,7 @@ export function createDeterministicDispatcherStage( if (lower.includes('what do you know') || lower.includes('what context') || lower.includes('what data do you')) { const result: ToolResult = { success: true, - message: buildDataAwarenessResponse(profile, insights, workbookName, target), + message: buildDataAwarenessResponse(profile, insights, resolvedWorkbookName, resolvedTarget), toolUsed: 'data-awareness', } return toStageResult(result) @@ -179,25 +189,25 @@ export function createDeterministicDispatcherStage( // ─── Clean ────────────────────────────────────────────────────────────── if (intent.intentType === 'clean') { - return toStageResult({ ...runCleaningSkill(target.sheet), toolUsed: 'cleaning' }) + return toStageResult({ ...runCleaningSkill(resolvedTarget.sheet), toolUsed: 'cleaning' }) } // ─── Report ───────────────────────────────────────────────────────────── if (intent.intentType === 'report') { - return toStageResult({ ...generateReport(profile, insights, workbookName), toolUsed: 'reporting' }) + return toStageResult({ ...generateReport(profile, insights, resolvedWorkbookName), toolUsed: 'reporting' }) } // ─── Compare ──────────────────────────────────────────────────────────── if (intent.intentType === 'compare') { return toStageResult({ - ...queryComparison(target.workbook, target.sheet, message, target.getSheetComputedValue), + ...queryComparison(resolvedTarget.workbook, resolvedTarget.sheet, message, resolvedTarget.getSheetComputedValue), toolUsed: 'comparison', }) } // ─── Query ────────────────────────────────────────────────────────────── if (isQueryIntent(intent)) { - const queryResult = runQueryFromIntent(target.sheet, intent, target.getComputedValue, insights) + const queryResult = runQueryFromIntent(resolvedTarget.sheet, intent, resolvedTarget.getComputedValue, insights) return queryResult ? toStageResult({ ...queryResult, toolUsed: 'query' }) : null } diff --git a/src/ai/pipeline/stages/index.ts b/src/ai/pipeline/stages/index.ts index ccc5b10..ed302a3 100644 --- a/src/ai/pipeline/stages/index.ts +++ b/src/ai/pipeline/stages/index.ts @@ -10,6 +10,9 @@ export type { TemplateResolverDeps } from './templateResolver' export { createIntentClassifierStage } from './intentClassifier' -export { createBrainDispatcherStage } from './brainDispatcher' +export { createDeterministicDispatcherStage } from './deterministicDispatcher' export { createLLMGatewayStage } from './llmGateway' + +/** @deprecated Prefer DeterministicDispatcher + LLMGateway. Kept for transitional tests. */ +export { createBrainDispatcherStage } from './brainDispatcher' diff --git a/src/ai/pipeline/stages/llmGateway.ts b/src/ai/pipeline/stages/llmGateway.ts index 5539bf7..f3d4fc1 100644 --- a/src/ai/pipeline/stages/llmGateway.ts +++ b/src/ai/pipeline/stages/llmGateway.ts @@ -96,6 +96,7 @@ export function createLLMGatewayStage(): PipelineStage { toolUsed: 'llm', source: serverResult.source, reasoning: serverResult.reasoning, + providerMeta: serverResult.meta, }, } } diff --git a/src/ai/responseBuilder.ts b/src/ai/responseBuilder.ts index bbf463c..d6a0c9c 100644 --- a/src/ai/responseBuilder.ts +++ b/src/ai/responseBuilder.ts @@ -207,5 +207,6 @@ export function toolResultToChatMessage( actions: actions.length > 0 ? actions : undefined, toolUsed: result.toolUsed ?? meta?.toolUsed, insightsSnapshot: meta?.insightsSnapshot, + providerMeta: result.providerMeta, } } diff --git a/src/ai/types.ts b/src/ai/types.ts index 9042db6..5d8f2f4 100644 --- a/src/ai/types.ts +++ b/src/ai/types.ts @@ -49,6 +49,7 @@ export interface ToolResult { chartConfig?: ChartConfig toolUsed?: string reasoning?: string + providerMeta?: { provider: string; model: string } actions?: Array<{ tool: string params: Record diff --git a/src/components/ChatPanel.tsx b/src/components/ChatPanel.tsx index 24593f9..4ab53a5 100644 --- a/src/components/ChatPanel.tsx +++ b/src/components/ChatPanel.tsx @@ -320,6 +320,16 @@ export function ChatPanel({ isMobileOpen, onCloseMobile, embedded }: { isMobileO ))} )} + {isAssistant && import.meta.env.DEV && msg.providerMeta && ( +
+ + provider details + + + {msg.providerMeta.provider} · {msg.providerMeta.model} + +
+ )} {isAssistant && (