From 8ca9e275321d4fbf0b9abd9a76e7638b32bb170d Mon Sep 17 00:00:00 2001 From: saravanakumardb1 Date: Fri, 17 Apr 2026 12:40:10 -0700 Subject: [PATCH] test(ai-context): TODO-009 unit tests for LLM context message generators MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds 18 new tests covering: Backend (13 tests in ai-context.test.ts): - keyword fallback — meeting, doctor, flight, case-insensitive - generic fallback — no match, minute/hour formatting - LLM path — flag-gated (off = no fetch), extraction-service success, Ollama cascade when extraction returns null, error → keyword fallback, short Ollama response rejected, non-200 fallthrough - prompt construction — includes category, urgency, timeOfDay, recentTimerLabels Web (5 new tests in context-messages.test.ts): - LLM success path - keyword fallback when backend returns 500 - keyword fallback when backend throws - generic fallback when backend fails and no keyword matches - payload shape — POST /api/context-message with all params Test counts: backend 240 (was ~227), web 399 (was ~394), all green. --- backend/src/lib/ai-context.test.ts | 199 +++++++++++++++++++++++++++ docs/AGENTIC_AI_ROADMAP.md | 2 +- web/src/lib/context-messages.test.ts | 76 +++++++++- 3 files changed, 275 insertions(+), 2 deletions(-) create mode 100644 backend/src/lib/ai-context.test.ts diff --git a/backend/src/lib/ai-context.test.ts b/backend/src/lib/ai-context.test.ts new file mode 100644 index 0000000..de5ac4c --- /dev/null +++ b/backend/src/lib/ai-context.test.ts @@ -0,0 +1,199 @@ +/** + * Tests for backend/src/lib/ai-context.ts — TODO-009. + * + * Covers: + * - keyword fallback matches known keywords + * - generic fallback when no keyword matches + * - LLM path gated by feature flag (off → always falls back) + * - extraction-service success path + * - Ollama success path + * - extraction-service → Ollama cascade when first fails + * - LLM errors fall through to keyword fallback + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +const { mockConfig, mockIsFeatureEnabled } = vi.hoisted(() => ({ + mockConfig: { + EXTRACTION_SERVICE_URL: undefined as string | undefined, + OLLAMA_URL: undefined as string | undefined, + OLLAMA_MODEL: 'gemma4', + } as Record, + mockIsFeatureEnabled: vi.fn((_flag: string) => false), +})); + +vi.mock('./config.js', () => ({ + config: new Proxy({} as Record, { + get: (_t, prop: string) => mockConfig[prop], + }), +})); + +vi.mock('./feature-flags.js', () => ({ + isFeatureEnabled: mockIsFeatureEnabled, +})); + +// Must import AFTER mocks +const { generateContextMessage } = await import('./ai-context.js'); + +describe('generateContextMessage — keyword fallback', () => { + beforeEach(() => { + mockIsFeatureEnabled.mockReturnValue(false); + mockConfig.EXTRACTION_SERVICE_URL = undefined; + mockConfig.OLLAMA_URL = undefined; + }); + + it('matches "meeting" keyword', async () => { + const r = await generateContextMessage({ timerLabel: 'Team meeting', minutesBefore: 10 }); + expect(r.source).toBe('keyword'); + expect(r.message).toBe('Review your agenda'); + }); + + it('matches "doctor appointment" keyword', async () => { + const r = await generateContextMessage({ timerLabel: 'Doctor appointment', minutesBefore: 30 }); + expect(r.source).toBe('keyword'); + expect(r.message).toContain('insurance'); + }); + + it('keyword match is case-insensitive', async () => { + const r = await generateContextMessage({ timerLabel: 'FLIGHT to NYC', minutesBefore: 120 }); + expect(r.source).toBe('keyword'); + expect(r.message).toContain('Check in'); + }); +}); + +describe('generateContextMessage — generic fallback', () => { + beforeEach(() => { + mockIsFeatureEnabled.mockReturnValue(false); + mockConfig.EXTRACTION_SERVICE_URL = undefined; + mockConfig.OLLAMA_URL = undefined; + }); + + it('falls back to generic when no keyword matches', async () => { + const r = await generateContextMessage({ timerLabel: 'xyzzy random task', minutesBefore: 5 }); + expect(r.source).toBe('generic'); + expect(r.message).toContain('xyzzy random task'); + expect(r.message).toContain('get ready'); + }); + + it('formats minutes below 60 as "Nm"', async () => { + const r = await generateContextMessage({ timerLabel: 'random', minutesBefore: 15 }); + expect(r.message).toContain('15m'); + }); + + it('formats minutes >= 60 as "Nh Mm"', async () => { + const r = await generateContextMessage({ timerLabel: 'random', minutesBefore: 90 }); + expect(r.message).toContain('1h 30m'); + }); +}); + +describe('generateContextMessage — LLM path', () => { + let fetchSpy: ReturnType; + + beforeEach(() => { + mockIsFeatureEnabled.mockReturnValue(true); + fetchSpy = vi.spyOn(globalThis, 'fetch'); + }); + + afterEach(() => { + fetchSpy.mockRestore(); + mockConfig.EXTRACTION_SERVICE_URL = undefined; + mockConfig.OLLAMA_URL = undefined; + }); + + it('does NOT call LLM when flag is off', async () => { + mockIsFeatureEnabled.mockReturnValue(false); + mockConfig.EXTRACTION_SERVICE_URL = 'http://fake'; + mockConfig.OLLAMA_URL = 'http://fake'; + + const r = await generateContextMessage({ timerLabel: 'meeting', minutesBefore: 10 }); + expect(fetchSpy).not.toHaveBeenCalled(); + expect(r.source).toBe('keyword'); + }); + + it('uses extraction-service when available', async () => { + mockConfig.EXTRACTION_SERVICE_URL = 'http://extract'; + fetchSpy.mockResolvedValueOnce( + new Response(JSON.stringify({ result: 'AI-generated prep message' }), { status: 200 }), + ); + + const r = await generateContextMessage({ timerLabel: 'standup', minutesBefore: 10 }); + expect(r.source).toBe('llm'); + expect(r.message).toBe('AI-generated prep message'); + }); + + it('falls back to Ollama when extraction-service returns null', async () => { + mockConfig.EXTRACTION_SERVICE_URL = 'http://extract'; + mockConfig.OLLAMA_URL = 'http://ollama'; + fetchSpy + .mockResolvedValueOnce(new Response(JSON.stringify({}), { status: 200 })) // extraction returns null + .mockResolvedValueOnce(new Response(JSON.stringify({ response: 'Ollama enriched text' }), { status: 200 })); + + const r = await generateContextMessage({ timerLabel: 'random', minutesBefore: 10 }); + expect(r.source).toBe('llm'); + expect(r.message).toBe('Ollama enriched text'); + }); + + it('falls through to keyword when LLM errors', async () => { + mockConfig.EXTRACTION_SERVICE_URL = 'http://extract'; + mockConfig.OLLAMA_URL = 'http://ollama'; + fetchSpy.mockRejectedValue(new Error('network')); + + const r = await generateContextMessage({ timerLabel: 'presentation', minutesBefore: 30 }); + expect(r.source).toBe('keyword'); + expect(r.message).toContain('slides'); + }); + + it('rejects very short Ollama responses', async () => { + mockConfig.OLLAMA_URL = 'http://ollama'; + fetchSpy.mockResolvedValueOnce( + new Response(JSON.stringify({ response: 'hi' }), { status: 200 }), + ); + + const r = await generateContextMessage({ timerLabel: 'random', minutesBefore: 5 }); + expect(r.source).toBe('generic'); + }); + + it('falls through when extraction-service returns non-200', async () => { + mockConfig.EXTRACTION_SERVICE_URL = 'http://extract'; + fetchSpy.mockResolvedValueOnce(new Response('', { status: 500 })); + + const r = await generateContextMessage({ timerLabel: 'focus time', minutesBefore: 25 }); + expect(r.source).toBe('keyword'); + }); +}); + +describe('prompt construction', () => { + let fetchSpy: ReturnType; + + beforeEach(() => { + mockIsFeatureEnabled.mockReturnValue(true); + mockConfig.EXTRACTION_SERVICE_URL = 'http://extract'; + fetchSpy = vi.spyOn(globalThis, 'fetch'); + }); + + afterEach(() => { + fetchSpy.mockRestore(); + }); + + it('includes all optional context fields in the prompt', async () => { + fetchSpy.mockResolvedValueOnce( + new Response(JSON.stringify({ result: 'ok' }), { status: 200 }), + ); + await generateContextMessage({ + timerLabel: 'team sync', + category: 'work', + urgency: 'high', + minutesBefore: 10, + timeOfDay: 'morning', + recentTimerLabels: ['standup', 'review'], + }); + + const call = fetchSpy.mock.calls[0]; + const body = JSON.parse((call[1] as RequestInit).body as string); + expect(body.input).toContain('team sync'); + expect(body.input).toContain('work'); + expect(body.input).toContain('high'); + expect(body.input).toContain('morning'); + expect(body.input).toContain('standup'); + }); +}); diff --git a/docs/AGENTIC_AI_ROADMAP.md b/docs/AGENTIC_AI_ROADMAP.md index 9efa605..55da2ec 100644 --- a/docs/AGENTIC_AI_ROADMAP.md +++ b/docs/AGENTIC_AI_ROADMAP.md @@ -24,7 +24,7 @@ | ✅ **TODO-006** | low | A.4 | `web/src/lib/context-messages.ts` | ~~Centralize backend URL~~ — uses `getBackendBaseURL()` from `product-config.ts` (5dafcc2) | | **TODO-007** | medium | A.3 | `learning_ai_common_plat/services/mcp-server/` | Add integration tests for 5 new ChronoMind MCP tools (reschedule, availability, routine start, agent-actions list, agent-actions approve) | | **TODO-008** | medium | B | `backend/src/lib/telemetry-events.ts`, `web/src/lib/telemetry-events.ts` | Wire `trackEvent()` / `bufferEvent()` calls into routes and components using the defined telemetry constants. See in-code comments for specific call sites | -| **TODO-009** | medium | A.4 | `backend/src/lib/ai-context.ts`, `web/src/lib/context-messages.ts` | Add unit tests for `generateContextMessage()` (backend) and `fetchEnrichedMessage()` (web). Test keyword fallback, LLM enrichment, generic fallback, and error paths | +| ✅ **TODO-009** | medium | A.4 | `backend/src/lib/ai-context.test.ts`, `web/src/lib/context-messages.test.ts` | Unit tests for `generateContextMessage()` (13 tests: keyword, generic, LLM flag-off, extraction-service, Ollama cascade, error paths, prompt construction) + `fetchEnrichedMessage()` (5 tests: LLM success, keyword fallback on 500/throw, generic fallback, payload shape) | | ✅ **TODO-010** | low | cleanup | `backend/src/modules/timers/routes.ts`, `routines/routes.ts`, `households/routes.ts`, `webhooks/routes.ts`, `shared-timers/routes.ts` | ~~Replace hardcoded PRODUCT_ID~~ — imports from `product-config.js` in all 5 files (d74c80a) | | ✅ **TODO-011** | low | cleanup | `web/src/app/error.tsx` | ~~Wire error boundary to telemetry~~ — `trackEvent('error', 'app', 'error_boundary', ...)` (9e63418) | diff --git a/web/src/lib/context-messages.test.ts b/web/src/lib/context-messages.test.ts index 5c7f282..c0b40ee 100644 --- a/web/src/lib/context-messages.test.ts +++ b/web/src/lib/context-messages.test.ts @@ -1,10 +1,11 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { getContextMessage, getAllContextMessages, getWarningMessage, hasContextMatch, getContextRules, + fetchEnrichedMessage, } from './context-messages'; describe('context-messages', () => { @@ -125,3 +126,76 @@ describe('context-messages', () => { }); }); }); + +describe('fetchEnrichedMessage', () => { + let fetchSpy: ReturnType; + + beforeEach(() => { + fetchSpy = vi.spyOn(globalThis, 'fetch'); + }); + + afterEach(() => { + fetchSpy.mockRestore(); + }); + + it('returns LLM-sourced result when backend responds 200', async () => { + fetchSpy.mockResolvedValueOnce( + new Response(JSON.stringify({ message: 'AI-generated prep', source: 'llm' }), { status: 200 }), + ); + + const r = await fetchEnrichedMessage({ timerLabel: 'meeting', minutesBefore: 10 }); + expect(r.source).toBe('llm'); + expect(r.message).toBe('AI-generated prep'); + }); + + it('falls back to keyword rule when backend returns non-200', async () => { + fetchSpy.mockResolvedValueOnce(new Response('', { status: 500 })); + + const r = await fetchEnrichedMessage({ timerLabel: 'Team meeting', minutesBefore: 10 }); + expect(r.source).toBe('keyword'); + expect(r.message).toBe('Review your agenda'); + }); + + it('falls back to keyword rule when backend throws', async () => { + fetchSpy.mockRejectedValueOnce(new Error('network fail')); + + const r = await fetchEnrichedMessage({ timerLabel: 'Flight to LAX', minutesBefore: 120 }); + expect(r.source).toBe('keyword'); + expect(r.message).toBe('Check in online'); + }); + + it('falls back to generic when backend fails and no keyword matches', async () => { + fetchSpy.mockRejectedValueOnce(new Error('timeout')); + + const r = await fetchEnrichedMessage({ timerLabel: 'xyzzy plinko', minutesBefore: 5 }); + expect(r.source).toBe('generic'); + expect(r.message).toContain('xyzzy plinko'); + expect(r.message).toContain('get ready'); + }); + + it('POSTs params to /api/context-message', async () => { + fetchSpy.mockResolvedValueOnce( + new Response(JSON.stringify({ message: 'ok', source: 'llm' }), { status: 200 }), + ); + + await fetchEnrichedMessage({ + timerLabel: 'team sync', + category: 'work', + urgency: 'high', + minutesBefore: 10, + timeOfDay: 'morning', + recentTimerLabels: ['standup'], + }); + + expect(fetchSpy).toHaveBeenCalledTimes(1); + const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit]; + expect(url).toContain('/api/context-message'); + expect(init.method).toBe('POST'); + const body = JSON.parse(init.body as string); + expect(body.timerLabel).toBe('team sync'); + expect(body.category).toBe('work'); + expect(body.urgency).toBe('high'); + expect(body.timeOfDay).toBe('morning'); + expect(body.recentTimerLabels).toEqual(['standup']); + }); +});