/** * Tests for recipient context (guardian contact notes) injection in the * notification decision engine. * * Validates that guardian contact notes appear in the LLM system prompt as * a block when available, are omitted when absent or * empty, and are truncated when large. */ import { beforeEach, describe, expect, mock, test } from "bun:test"; // ── Mocks (must precede imports from mocked modules) ────────────────── mock.module("../channels/config.js", () => ({ getDeliverableChannels: () => ["vellum"], })); mock.module("../notifications/decisions-store.js", () => ({ createDecision: () => {}, })); mock.module("../notifications/preference-summary.js", () => ({ getPreferenceSummary: () => undefined, })); mock.module("../notifications/conversation-candidates.js", () => ({ buildConversationCandidates: () => undefined, serializeCandidatesForPrompt: () => undefined, })); mock.module("../prompts/persona-resolver.js", () => ({ resolveGuardianPersona: () => null, })); mock.module("../prompts/system-prompt.js", () => ({ buildCoreIdentityContext: () => null, })); // ── Guardian binding + contact notes mocks ─────────────────────────── // Guardian binding (ACL) is resolved via the gateway pull; notes (INFO) are // joined locally by contactId. Tests drive both via mutable slots. let guardianDeliveryFixture: Array<{ contactId: string }> = []; let contactInfoFixture: Record = {}; mock.module("../contacts/guardian-delivery-reader.js", () => ({ getGuardianDelivery: async () => guardianDeliveryFixture, anyGuardian: (list: Array<{ contactId: string }>) => list[0], })); mock.module("../contacts/contact-store.js", () => ({ findContactInfoById: (contactId: string) => contactInfoFixture[contactId] ?? null, })); const GUARDIAN_CONTACT_ID = "guardian-contact-1"; /** Bind a guardian with the given notes (or no guardian when notes is null). */ function setGuardian(notes: string | null | undefined): void { if (notes === undefined) { guardianDeliveryFixture = []; contactInfoFixture = {}; return; } guardianDeliveryFixture = [{ contactId: GUARDIAN_CONTACT_ID }]; contactInfoFixture = { [GUARDIAN_CONTACT_ID]: { notes } }; } // ── Provider mock with system prompt capture ────────────────────────── let configuredProvider: { sendMessage: (...args: unknown[]) => Promise; } | null = null; let extractedToolUse: unknown = null; let capturedSystemPrompt: string | undefined; mock.module("../providers/provider-send-message.js", () => ({ getConfiguredProvider: async () => configuredProvider, createTimeout: () => ({ signal: new AbortController().signal, cleanup: () => {}, }), extractToolUse: () => extractedToolUse, userMessage: (text: string) => ({ role: "user", content: text }), })); // ── Imports (after all mocks) ───────────────────────────────────────── import { evaluateSignal } from "../notifications/decision-engine.js"; import type { NotificationSignal } from "../notifications/signal.js"; import type { NotificationChannel } from "../notifications/types.js"; // ── Helpers ─────────────────────────────────────────────────────────── function makeSignal( overrides?: Partial, ): NotificationSignal { return { signalId: "sig-recipient-ctx-test-1", createdAt: Date.now(), sourceChannel: "phone", sourceContextId: "call-session-1", sourceEventName: "guardian.question", contextPayload: { questionText: "What is the gate code?", }, attentionHints: { requiresAction: true, urgency: "high", isAsyncBackground: false, visibleInSourceNow: false, }, ...overrides, }; } function setupLLMProvider() { configuredProvider = { sendMessage: async (...args: unknown[]) => { const options = args[1] as { systemPrompt?: string } | undefined; capturedSystemPrompt = options?.systemPrompt; return { content: [] }; }, }; extractedToolUse = { name: "record_notification_decision", input: { shouldNotify: true, selectedChannels: ["vellum"], reasoningSummary: "LLM decision with recipient context", renderedCopy: { vellum: { title: "Guardian Question", body: "What is the gate code?", }, }, dedupeKey: "recipient-ctx-test", confidence: 0.9, }, }; } // ── Tests ───────────────────────────────────────────────────────────── describe("recipient context in notification decision engine", () => { beforeEach(() => { configuredProvider = null; extractedToolUse = null; setGuardian(undefined); capturedSystemPrompt = undefined; }); test("guardian contact notes appear in system prompt as ", async () => { setGuardian("Prefers formal tone. Address as Dr. Smith."); setupLLMProvider(); const signal = makeSignal(); await evaluateSignal(signal, ["vellum"] as NotificationChannel[]); expect(capturedSystemPrompt).toBeDefined(); expect(capturedSystemPrompt).toContain(""); expect(capturedSystemPrompt).toContain( "Prefers formal tone. Address as Dr. Smith.", ); expect(capturedSystemPrompt).toContain(""); }); test("recipient-context is omitted when no guardian exists", async () => { setGuardian(undefined); setupLLMProvider(); const signal = makeSignal(); await evaluateSignal(signal, ["vellum"] as NotificationChannel[]); expect(capturedSystemPrompt).toBeDefined(); expect(capturedSystemPrompt).not.toContain(""); expect(capturedSystemPrompt).not.toContain(""); }); test("recipient-context is omitted when guardian notes are null", async () => { setGuardian(null); setupLLMProvider(); const signal = makeSignal(); await evaluateSignal(signal, ["vellum"] as NotificationChannel[]); expect(capturedSystemPrompt).toBeDefined(); expect(capturedSystemPrompt).not.toContain(""); expect(capturedSystemPrompt).not.toContain(""); }); test("recipient-context is omitted when guardian notes are empty string", async () => { setGuardian(""); setupLLMProvider(); const signal = makeSignal(); await evaluateSignal(signal, ["vellum"] as NotificationChannel[]); expect(capturedSystemPrompt).toBeDefined(); expect(capturedSystemPrompt).not.toContain(""); expect(capturedSystemPrompt).not.toContain(""); }); test("large guardian notes are truncated to prevent oversized prompts", async () => { setGuardian("N".repeat(3000)); setupLLMProvider(); const signal = makeSignal(); await evaluateSignal(signal, ["vellum"] as NotificationChannel[]); expect(capturedSystemPrompt).toBeDefined(); expect(capturedSystemPrompt).toContain(""); // Full 3000-char string should NOT appear expect(capturedSystemPrompt).not.toContain("N".repeat(3000)); // Truncation marker should be present expect(capturedSystemPrompt).toContain("\u2026[truncated]"); const match = capturedSystemPrompt!.match( /([\s\S]*?)<\/recipient-context>/, ); expect(match).toBeTruthy(); const block = match![1]; expect(block).toContain("\u2026[truncated]"); // The notes portion within the block should not exceed 2000 chars expect(block).not.toContain("N".repeat(2001)); }); test("fallback path works correctly without recipient context", async () => { setGuardian("Prefers formal tone."); // null provider forces fallback path configuredProvider = null; const signal = makeSignal(); const decision = await evaluateSignal(signal, [ "vellum", ] as NotificationChannel[]); expect(decision.fallbackUsed).toBe(true); expect(decision.shouldNotify).toBe(true); expect(decision.renderedCopy.vellum?.title).toBeDefined(); expect(decision.renderedCopy.vellum?.body).toBeDefined(); // No LLM call, so no system prompt captured expect(capturedSystemPrompt).toBeUndefined(); }); test("recipient-context appears after user-preferences in prompt", async () => { setGuardian("Prefers brief updates."); setupLLMProvider(); const signal = makeSignal(); await evaluateSignal( signal, ["vellum"] as NotificationChannel[], "Notify only for urgent items", ); expect(capturedSystemPrompt).toBeDefined(); const prefsIdx = capturedSystemPrompt!.indexOf(""); const recipientIdx = capturedSystemPrompt!.indexOf(""); expect(prefsIdx).toBeGreaterThan(-1); expect(recipientIdx).toBeGreaterThan(-1); expect(recipientIdx).toBeGreaterThan(prefsIdx); }); });