import { beforeEach, describe, expect, mock, test } from "bun:test"; import type { ConversationCreatedInfo } from "../notifications/broadcaster.js"; import type { NotificationDeliveryResult } from "../notifications/types.js"; // Note: stale mock for channel-guardian-store.js removed — the barrel was // deleted and none of the functions it mocked (getActiveBinding, createBinding, // listActiveBindingsByAssistant) existed in the barrel. // The pending_question request principal is resolved via the gateway guardian // delivery for the vellum channel — the SAME source the Vellum actor uses — so // the stamped principal always equals the submitting actor principal. The real // contacts DB is seeded in resetTables(); the reader mock below derives the // gateway delivery from that DB binding so tests model drift / missing guardian // by reseeding or clearing the local binding directly. mock.module("../contacts/guardian-delivery-reader.js", () => ({ getGuardianDelivery: async (input?: { channelTypes?: string[] }) => { const { deriveGuardianDeliveries } = await import("./helpers/derive-guardian-delivery.js"); return deriveGuardianDeliveries({ channelTypes: input?.channelTypes ?? [], }); }, guardianForChannel: ( list: Array<{ channelType: string; status: string }>, channelType: string, ) => list.find((g) => g.channelType === channelType && g.status === "active"), })); const emitCalls: unknown[] = []; let conversationCreatedFromMock: ConversationCreatedInfo | null = null; let mockEmitResult: { signalId: string; deduplicated: boolean; dispatched: boolean; reason: string; deliveryResults: NotificationDeliveryResult[]; } = { signalId: "sig-1", deduplicated: false, dispatched: true, reason: "ok", deliveryResults: [ { channel: "vellum", destination: "vellum", status: "sent", conversationId: "conv-vellum-1", }, ], }; mock.module("../notifications/emit-signal.js", () => ({ emitNotificationSignal: async (params: Record) => { emitCalls.push(params); const callback = params.onConversationCreated; if (typeof callback === "function" && conversationCreatedFromMock) { callback(conversationCreatedFromMock); } return mockEmitResult; }, })); // Guardian requests/deliveries are created through the gateway client; serve // that surface from the in-memory sim the assertions read. import { bridgeState, gatewayGuardianRequestsStoreBridge, } from "./helpers/gateway-guardian-requests-store-bridge.js"; mock.module( "../channels/gateway-guardian-requests.js", () => gatewayGuardianRequestsStoreBridge, ); import { createCallSession, createPendingQuestion, } from "../calls/call-store.js"; import { dispatchGuardianQuestion } from "../calls/guardian-dispatch.js"; import { getDb } from "../persistence/db-connection.js"; import { initializeDb } from "../persistence/db-init.js"; import { conversations } from "../persistence/schema/index.js"; import { createGuardianBinding } from "./helpers/create-guardian-binding.js"; import { resetGatewayAclStore } from "./helpers/gateway-acl-store.js"; await initializeDb(); function ensureConversation(id: string): void { const db = getDb(); const now = Date.now(); db.insert(conversations) .values({ id, title: `Conversation ${id}`, createdAt: now, updatedAt: now, }) .run(); } function requestsForCallSession(callSessionId: string) { return [...bridgeState.requests.values()] .filter((r) => r.callSessionId === callSessionId) .sort((a, b) => a.createdAt - b.createdAt); } function deliveryFor(requestId: string, channel: string) { return bridgeState.deliveries.find( (d) => d.requestId === requestId && d.destinationChannel === channel, ); } function resetTables(): void { const db = getDb(); bridgeState.reset(); db.run("DELETE FROM call_pending_questions"); db.run("DELETE FROM call_events"); db.run("DELETE FROM call_sessions"); db.run("DELETE FROM conversations"); db.run("DELETE FROM contact_channels"); db.run("DELETE FROM contacts"); resetGatewayAclStore(); // Seed the vellum guardian binding (gateway does this at startup in production) createGuardianBinding({ channel: "vellum", guardianExternalUserId: "test-principal-id", guardianDeliveryChatId: "local", guardianPrincipalId: "test-principal-id", verifiedVia: "bootstrap", }); emitCalls.length = 0; conversationCreatedFromMock = null; mockEmitResult = { signalId: "sig-1", deduplicated: false, dispatched: true, reason: "ok", deliveryResults: [ { channel: "vellum", destination: "vellum", status: "sent", conversationId: "conv-vellum-1", }, ], }; } describe("guardian-dispatch", () => { beforeEach(() => { resetTables(); }); test("creates a guardian action request and vellum delivery from pipeline results", async () => { const convId = "conv-dispatch-1"; ensureConversation(convId); const session = createCallSession({ conversationId: convId, provider: "twilio", fromNumber: "+15550001111", toNumber: "+15550002222", }); const pq = createPendingQuestion(session.id, "What is the gate code?"); await dispatchGuardianQuestion({ callSessionId: session.id, conversationId: convId, assistantId: "self", pendingQuestion: pq, }); const [request] = requestsForCallSession(session.id); expect(request).toBeDefined(); expect(request!.status).toBe("pending"); expect(request!.questionText).toBe("What is the gate code?"); // principalId comes from the local guardian binding (same source the actor submits) expect(request!.guardianPrincipalId).toBe("test-principal-id"); const vellumDelivery = deliveryFor(request!.id, "vellum"); expect(vellumDelivery).toBeDefined(); expect(vellumDelivery!.status).toBe("sent"); expect(vellumDelivery!.destinationConversationId).toBe("conv-vellum-1"); const signalParams = emitCalls[0] as Record; expect(typeof signalParams.onConversationCreated).toBe("function"); }); test("stamps the request principal from the local source the actor submits, not the (possibly drifted) gateway binding", async () => { // Simulate gateway/local binding drift: the local guardian binding (the // source the actor submit path reads) carries a different principal than // the gateway would. The request must be stamped with that local value so // a later actor submission matches (no identity_mismatch under drift). const db = getDb(); db.run("DELETE FROM contact_channels"); db.run("DELETE FROM contacts"); resetGatewayAclStore(); createGuardianBinding({ channel: "vellum", guardianExternalUserId: "local-actor-principal", guardianDeliveryChatId: "local", guardianPrincipalId: "local-actor-principal", verifiedVia: "bootstrap", }); const convId = "conv-dispatch-drift"; ensureConversation(convId); const session = createCallSession({ conversationId: convId, provider: "twilio", fromNumber: "+15550001111", toNumber: "+15550002222", }); const pq = createPendingQuestion(session.id, "Drifted bindings?"); await dispatchGuardianQuestion({ callSessionId: session.id, conversationId: convId, assistantId: "self", pendingQuestion: pq, }); const [request] = requestsForCallSession(session.id); expect(request).toBeDefined(); expect(request!.guardianPrincipalId).toBe("local-actor-principal"); }); test("skips dispatch when no local guardian binding exists (no principal to stamp)", async () => { const db = getDb(); db.run("DELETE FROM contact_channels"); db.run("DELETE FROM contacts"); resetGatewayAclStore(); const convId = "conv-dispatch-no-principal"; ensureConversation(convId); const session = createCallSession({ conversationId: convId, provider: "twilio", fromNumber: "+15550001111", toNumber: "+15550002222", }); const pq = createPendingQuestion(session.id, "No principal available"); await dispatchGuardianQuestion({ callSessionId: session.id, conversationId: convId, assistantId: "self", pendingQuestion: pq, }); // No request is created and the pipeline is never invoked. expect(requestsForCallSession(session.id)).toHaveLength(0); expect(emitCalls).toHaveLength(0); }); test("creates a telegram guardian delivery with binding metadata when pipeline sends telegram", async () => { const convId = "conv-dispatch-2"; ensureConversation(convId); mockEmitResult = { signalId: "sig-2", deduplicated: false, dispatched: true, reason: "ok", deliveryResults: [ { channel: "vellum", destination: "vellum", status: "sent", conversationId: "conv-vellum-2", }, { channel: "telegram", destination: "tg-chat-999", status: "sent", }, ], }; const session = createCallSession({ conversationId: convId, provider: "twilio", fromNumber: "+15550001111", toNumber: "+15550002222", }); const pq = createPendingQuestion(session.id, "Should I proceed?"); await dispatchGuardianQuestion({ callSessionId: session.id, conversationId: convId, assistantId: "self", pendingQuestion: pq, }); const [request] = requestsForCallSession(session.id); const telegramDelivery = deliveryFor(request!.id, "telegram"); expect(telegramDelivery).toBeDefined(); expect(telegramDelivery!.status).toBe("sent"); expect(telegramDelivery!.destinationChatId).toBe("tg-chat-999"); }); test("marks non-sent pipeline delivery results as failed", async () => { const convId = "conv-dispatch-3"; ensureConversation(convId); mockEmitResult = { signalId: "sig-3", deduplicated: false, dispatched: true, reason: "partial", deliveryResults: [ { channel: "vellum", destination: "vellum", status: "failed", errorMessage: "delivery unavailable", conversationId: "conv-vellum-3", }, ], }; const session = createCallSession({ conversationId: convId, provider: "twilio", fromNumber: "+15550001111", toNumber: "+15550002222", }); const pq = createPendingQuestion(session.id, "Error case"); await dispatchGuardianQuestion({ callSessionId: session.id, conversationId: convId, assistantId: "self", pendingQuestion: pq, }); const [request] = requestsForCallSession(session.id); const vellumDelivery = deliveryFor(request!.id, "vellum"); expect(vellumDelivery).toBeDefined(); expect(vellumDelivery!.status).toBe("failed"); }); test("uses onConversationCreated callback conversation when delivery result omits conversationId", async () => { const convId = "conv-dispatch-4"; ensureConversation(convId); conversationCreatedFromMock = { conversationId: "conv-from-thread-created", title: "Guardian alert", sourceEventName: "guardian.question", silent: false, }; mockEmitResult = { signalId: "sig-4", deduplicated: false, dispatched: true, reason: "ok", deliveryResults: [ { channel: "vellum", destination: "vellum", status: "sent", }, ], }; const session = createCallSession({ conversationId: convId, provider: "twilio", fromNumber: "+15550001111", toNumber: "+15550002222", }); const pq = createPendingQuestion(session.id, "Need callback conversation"); await dispatchGuardianQuestion({ callSessionId: session.id, conversationId: convId, assistantId: "self", pendingQuestion: pq, }); const [request] = requestsForCallSession(session.id); const vellumDelivery = deliveryFor(request!.id, "vellum"); expect(vellumDelivery).toBeDefined(); expect(vellumDelivery!.destinationConversationId).toBe( "conv-from-thread-created", ); }); test("persists toolName and inputDigest on the guardian request for tool-approval dispatches", async () => { const convId = "conv-dispatch-5"; ensureConversation(convId); const session = createCallSession({ conversationId: convId, provider: "twilio", fromNumber: "+15550001111", toNumber: "+15550002222", }); const pq = createPendingQuestion( session.id, "Allow send_email to bob@example.com?", ); await dispatchGuardianQuestion({ callSessionId: session.id, conversationId: convId, assistantId: "self", pendingQuestion: pq, toolName: "send_email", inputDigest: "abc123def456", }); const [request] = requestsForCallSession(session.id); expect(request).toBeDefined(); expect(request!.toolName).toBe("send_email"); expect(request!.inputDigest).toBe("abc123def456"); const signalParams = emitCalls[0] as Record; const payload = signalParams.contextPayload as Record; expect(payload.requestKind).toBe("pending_question"); expect(payload.toolName).toBe("send_email"); }); test("omitting toolName and inputDigest stores null for informational ASK_GUARDIAN dispatches", async () => { const convId = "conv-dispatch-6"; ensureConversation(convId); const session = createCallSession({ conversationId: convId, provider: "twilio", fromNumber: "+15550001111", toNumber: "+15550002222", }); const pq = createPendingQuestion(session.id, "What time works?"); await dispatchGuardianQuestion({ callSessionId: session.id, conversationId: convId, assistantId: "self", pendingQuestion: pq, }); const [request] = requestsForCallSession(session.id); expect(request).toBeDefined(); expect(request!.toolName).toBeNull(); expect(request!.inputDigest).toBeNull(); }); test("includes activeGuardianRequestCount in context payload", async () => { const convId = "conv-dispatch-7"; ensureConversation(convId); const session = createCallSession({ conversationId: convId, provider: "twilio", fromNumber: "+15550001111", toNumber: "+15550002222", }); const pq = createPendingQuestion(session.id, "First question"); await dispatchGuardianQuestion({ callSessionId: session.id, conversationId: convId, assistantId: "self", pendingQuestion: pq, }); const signalParams = emitCalls[0] as Record; const payload = signalParams.contextPayload as Record; // The request was just created so there is 1 pending request for this session expect(payload.activeGuardianRequestCount).toBe(1); expect(payload.callSessionId).toBe(session.id); expect(payload.requestKind).toBe("pending_question"); expect(payload.toolName).toBeUndefined(); expect(payload.pendingQuestionId).toBeUndefined(); }); test("repeated guardian questions in the same call each create per-request delivery rows even when sharing a conversation", async () => { const convId = "conv-dispatch-reuse-1"; ensureConversation(convId); // Both dispatches deliver to the same vellum conversation (simulating thread reuse) const sharedConversationId = "conv-shared-guardian"; const session = createCallSession({ conversationId: convId, provider: "twilio", fromNumber: "+15550001111", toNumber: "+15550002222", }); // First dispatch const pq1 = createPendingQuestion(session.id, "What is the gate code?"); mockEmitResult = { signalId: "sig-reuse-1", deduplicated: false, dispatched: true, reason: "ok", deliveryResults: [ { channel: "vellum", destination: "vellum", status: "sent", conversationId: sharedConversationId, }, ], }; await dispatchGuardianQuestion({ callSessionId: session.id, conversationId: convId, assistantId: "self", pendingQuestion: pq1, }); // Second dispatch (same call session, same shared conversation) emitCalls.length = 0; const pq2 = createPendingQuestion(session.id, "Should I let them in?"); mockEmitResult = { signalId: "sig-reuse-2", deduplicated: false, dispatched: true, reason: "ok", deliveryResults: [ { channel: "vellum", destination: "vellum", status: "sent", conversationId: sharedConversationId, }, ], }; await dispatchGuardianQuestion({ callSessionId: session.id, conversationId: convId, assistantId: "self", pendingQuestion: pq2, }); // Both dispatches should have created separate requests const requests = requestsForCallSession(session.id); expect(requests).toHaveLength(2); expect(requests[0].questionText).toBe("What is the gate code?"); expect(requests[1].questionText).toBe("Should I let them in?"); // Each request should have its own delivery row, both pointing to the shared conversation for (const req of requests) { const delivery = deliveryFor(req.id, "vellum"); expect(delivery).toBeDefined(); expect(delivery!.status).toBe("sent"); expect(delivery!.destinationConversationId).toBe(sharedConversationId); } // Total delivery rows should be 2 (one per request), not 1 const allDeliveries = bridgeState.deliveries.filter( (d) => d.destinationConversationId === sharedConversationId, ); expect(allDeliveries).toHaveLength(2); // Second dispatch should report a higher activeGuardianRequestCount const secondPayload = (emitCalls[0] as Record) .contextPayload as Record; expect(secondPayload.activeGuardianRequestCount).toBe(2); }); test("second guardian question in same call session passes conversationAffinityHint with first conversation ID", async () => { const convId = "conv-dispatch-affinity-1"; ensureConversation(convId); const sharedConversationId = "conv-affinity-guardian"; const session = createCallSession({ conversationId: convId, provider: "twilio", fromNumber: "+15550001111", toNumber: "+15550002222", }); // First dispatch — no affinity hint expected (no prior delivery exists) const pq1 = createPendingQuestion(session.id, "First question"); mockEmitResult = { signalId: "sig-affinity-1", deduplicated: false, dispatched: true, reason: "ok", deliveryResults: [ { channel: "vellum", destination: "vellum", status: "sent", conversationId: sharedConversationId, }, ], }; await dispatchGuardianQuestion({ callSessionId: session.id, conversationId: convId, assistantId: "self", pendingQuestion: pq1, }); const firstParams = emitCalls[0] as Record; // First dispatch should not have an affinity hint expect(firstParams.conversationAffinityHint).toBeUndefined(); // Second dispatch — should carry the affinity hint from the first delivery emitCalls.length = 0; const pq2 = createPendingQuestion(session.id, "Second question"); mockEmitResult = { signalId: "sig-affinity-2", deduplicated: false, dispatched: true, reason: "ok", deliveryResults: [ { channel: "vellum", destination: "vellum", status: "sent", conversationId: sharedConversationId, }, ], }; await dispatchGuardianQuestion({ callSessionId: session.id, conversationId: convId, assistantId: "self", pendingQuestion: pq2, }); const secondParams = emitCalls[0] as Record; expect(secondParams.conversationAffinityHint).toEqual({ vellum: sharedConversationId, }); }); test("ASK_GUARDIAN_APPROVAL path (toolName present) uses same-thread affinity on second dispatch", async () => { const convId = "conv-dispatch-affinity-tool"; ensureConversation(convId); const sharedConversationId = "conv-affinity-tool-guardian"; const session = createCallSession({ conversationId: convId, provider: "twilio", fromNumber: "+15550001111", toNumber: "+15550002222", }); // First dispatch — tool-approval style pending_question (toolName set) const pq1 = createPendingQuestion( session.id, "Allow send_email to bob@example.com?", ); mockEmitResult = { signalId: "sig-tool-affinity-1", deduplicated: false, dispatched: true, reason: "ok", deliveryResults: [ { channel: "vellum", destination: "vellum", status: "sent", conversationId: sharedConversationId, }, ], }; await dispatchGuardianQuestion({ callSessionId: session.id, conversationId: convId, assistantId: "self", pendingQuestion: pq1, toolName: "send_email", }); const firstParams = emitCalls[0] as Record; expect(firstParams.conversationAffinityHint).toBeUndefined(); // Second dispatch — also with toolName emitCalls.length = 0; const pq2 = createPendingQuestion( session.id, "Allow run_script with sudo?", ); mockEmitResult = { signalId: "sig-tool-affinity-2", deduplicated: false, dispatched: true, reason: "ok", deliveryResults: [ { channel: "vellum", destination: "vellum", status: "sent", conversationId: sharedConversationId, }, ], }; await dispatchGuardianQuestion({ callSessionId: session.id, conversationId: convId, assistantId: "self", pendingQuestion: pq2, toolName: "run_script", }); const secondParams = emitCalls[0] as Record; expect(secondParams.conversationAffinityHint).toEqual({ vellum: sharedConversationId, }); }); test("third guardian question in same call session also carries affinity hint", async () => { const convId = "conv-dispatch-affinity-2"; ensureConversation(convId); const sharedConversationId = "conv-affinity-triple"; const session = createCallSession({ conversationId: convId, provider: "twilio", fromNumber: "+15550001111", toNumber: "+15550002222", }); // Dispatch three guardian questions in the same call session for (let i = 0; i < 3; i++) { emitCalls.length = 0; const pq = createPendingQuestion(session.id, `Question ${i + 1}`); mockEmitResult = { signalId: `sig-triple-${i}`, deduplicated: false, dispatched: true, reason: "ok", deliveryResults: [ { channel: "vellum", destination: "vellum", status: "sent", conversationId: sharedConversationId, }, ], }; await dispatchGuardianQuestion({ callSessionId: session.id, conversationId: convId, assistantId: "self", pendingQuestion: pq, }); const params = emitCalls[0] as Record; if (i === 0) { // First dispatch — no affinity hint expect(params.conversationAffinityHint).toBeUndefined(); } else { // Subsequent dispatches — affinity hint points to the shared conversation expect(params.conversationAffinityHint).toEqual({ vellum: sharedConversationId, }); } } }); });