/** * Tests for POST /v1/messages queue-if-busy behavior and hub publishing. * * Validates that: * - Messages are accepted (202) when the conversation is idle, with hub events published. * - Messages are queued (202, queued: true) when the conversation is busy, not 409. * - SSE subscribers receive events from messages sent via this endpoint. */ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; mock.module("../config/env.js", () => ({ isHttpAuthDisabled: () => true })); import type { AssistantEvent } from "../api/index.js"; import type { Conversation } from "../daemon/conversation.js"; import { getConversationByKey, getOrCreateConversation, } from "../persistence/conversation-key-store.js"; import { createGuardianBinding } from "./helpers/create-guardian-binding.js"; import { setConfig } from "./helpers/set-config.js"; // The send path's ingress secret check reads `secretDetection`; keep it off so // the normal-text fixtures below flow through untouched. `memory` is disabled // to match the isolated route-level scope (no real indexing on this path). setConfig("secretDetection", { enabled: false }); setConfig("memory", { enabled: false }); // --------------------------------------------------------------------------- // Module mocks for direct-import deps used by conversation-routes ROUTES. // These must appear before any import that triggers conversation-routes.ts // module evaluation, so the routes pick up the test-controlled instances. // --------------------------------------------------------------------------- let _conversationFactory: (() => Conversation) | undefined; let _approvalGenerator: unknown; mock.module("../daemon/conversation-registry.js", () => ({ findConversation: () => { if (!_conversationFactory) { return undefined; } return _conversationFactory(); }, })); mock.module("../daemon/conversation-store.js", () => ({ getOrCreateConversation: async (..._args: unknown[]) => { if (!_conversationFactory) { throw new Error("_conversationFactory not set in test"); } return _conversationFactory(); }, })); mock.module("../daemon/approval-generators.js", () => ({ createApprovalConversationGenerator: () => _approvalGenerator, })); // Dev-bypass resolves the real guardian principal, then runs the real // local-principal trust mapper against the gateway delivery read. mock.module("../runtime/local-actor-identity.js", () => ({ findLocalGuardianPrincipalId: async () => "test-principal-id", })); // Mock the IPC transport rather than local-principal-trust.js so the sibling // resolver unit test (which mocks guardian-delivery-reader, not ipcCall) isn't // shadowed when both files run in one Bun process. resolve_guardian_delivery // returns a single active vellum guardian whose principal matches the // dev-bypass-resolved id; any other method throws so unexpected IPC surfaces. mock.module("../ipc/gateway-client.js", () => ({ ipcCall: async (method: string) => { if (method === "resolve_guardian_delivery") { return { guardians: [ { channelType: "vellum", contactId: "test-contact-id", principalId: "test-principal-id", address: "test-principal-id", externalChatId: "test-principal-id", status: "active", }, ], }; } throw new Error(`Unexpected ipcCall in test: ${method}`); }, })); // Guardian decisions read and CAS through the gateway client; serve that // surface from the in-memory sim the tests seed. import { bridgeState, gatewayGuardianRequestsStoreBridge, } from "./helpers/gateway-guardian-requests-store-bridge.js"; mock.module( "../channels/gateway-guardian-requests.js", () => gatewayGuardianRequestsStoreBridge, ); import type { AssistantEventEnvelope } from "../api/index.js"; import { __resetGuardianDeliveryCacheForTest } from "../contacts/guardian-delivery-reader.js"; import { getDb } from "../persistence/db-connection.js"; import { initializeDb } from "../persistence/db-init.js"; import { RuntimeHttpServer } from "../runtime/http-server.js"; import type { ApprovalConversationGenerator } from "../runtime/http-types.js"; import * as pendingInteractions from "../runtime/pending-interactions.js"; await initializeDb(); // --------------------------------------------------------------------------- // Conversation helpers // --------------------------------------------------------------------------- /** Conversation that completes its agent loop quickly and emits a text delta + message_complete. */ function makeCompletingConversation(): Conversation { let processing = false; const messages: unknown[] = []; return { isProcessing: () => processing, persistUserMessage: (options: { requestId?: string }) => { processing = true; return { id: options.requestId ?? "msg-1", deduplicated: false }; }, setChannelCapabilities: () => {}, setAssistantId: () => {}, setTrustContext: () => {}, setAuthContext: () => {}, setCommandIntent: () => {}, setTurnChannelContext: () => {}, setTurnInterfaceContext: () => {}, ensureActorScopedHistory: async () => {}, usageStats: { inputTokens: 0, outputTokens: 0, estimatedCost: 0 }, replayActivityState: () => {}, setHostBrowserProxy: () => {}, setHostCuProxy: () => {}, setHostAppControlProxy: () => {}, addPreactivatedSkillId: () => {}, hasAnyPendingConfirmation: () => false, hasPendingConfirmation: () => false, denyAllPendingConfirmations: () => {}, getQueueDepth: () => 0, enqueueMessage: () => ({ queued: false, requestId: "noop" }), runAgentLoop: async ( _content: string, _messageId: string, options?: { onEvent?: (msg: AssistantEvent) => void }, ) => { const onEvent = options?.onEvent ?? (() => {}); onEvent({ type: "assistant_text_delta", text: "Hello!" }); onEvent({ type: "message_complete", conversationId: "test-session" }); processing = false; }, handleConfirmationResponse: () => {}, handleSecretResponse: () => {}, getMessages: () => messages as never[], } as unknown as Conversation; } /** Conversation that hangs forever in the agent loop (simulates a busy conversation). */ function makeHangingConversation(): Conversation { let processing = false; const messages: unknown[] = []; const enqueuedMessages: Array<{ content: string; onEvent?: (msg: AssistantEvent) => void; requestId?: string; }> = []; return { isProcessing: () => processing, persistUserMessage: (options: { requestId?: string }) => { processing = true; return { id: options.requestId ?? "msg-1", deduplicated: false }; }, setChannelCapabilities: () => {}, setAssistantId: () => {}, setTrustContext: () => {}, setAuthContext: () => {}, setCommandIntent: () => {}, setTurnChannelContext: () => {}, setTurnInterfaceContext: () => {}, ensureActorScopedHistory: async () => {}, usageStats: { inputTokens: 0, outputTokens: 0, estimatedCost: 0 }, replayActivityState: () => {}, setHostBrowserProxy: () => {}, setHostCuProxy: () => {}, setHostAppControlProxy: () => {}, addPreactivatedSkillId: () => {}, hasAnyPendingConfirmation: () => false, hasPendingConfirmation: () => false, denyAllPendingConfirmations: () => {}, getQueueDepth: () => enqueuedMessages.length, enqueueMessage: (options: { content: string; onEvent?: (msg: AssistantEvent) => void; requestId?: string; }) => { enqueuedMessages.push({ content: options.content, onEvent: options.onEvent, requestId: options.requestId, }); return { queued: true, requestId: options.requestId ?? "hanging-req", }; }, runAgentLoop: async () => { // Hang forever await new Promise(() => {}); }, handleConfirmationResponse: () => {}, handleSecretResponse: () => {}, getMessages: () => messages as never[], _enqueuedMessages: enqueuedMessages, } as unknown as Conversation; } function makePendingApprovalConversation( requestId: string, processing: boolean, options?: { queueDepth?: number }, ): { conversation: Conversation; runAgentLoopMock: ReturnType; enqueueMessageMock: ReturnType; denyAllPendingConfirmationsMock: ReturnType; handleConfirmationResponseMock: ReturnType; } { const queueDepth = options?.queueDepth ?? 0; const pending = new Set([requestId]); const messages: unknown[] = []; const runAgentLoopMock = mock(async () => {}); const enqueueMessageMock = mock( (options: { content: string; requestId?: string }) => ({ queued: true, requestId: options.requestId ?? "queued-req", }), ); const denyAllPendingConfirmationsMock = mock(() => { pending.clear(); }); const handleConfirmationResponseMock = mock((resolvedRequestId: string) => { pending.delete(resolvedRequestId); }); const conversation = { isProcessing: () => processing, persistUserMessage: (options: { requestId?: string }) => ({ id: options.requestId ?? "msg-1", deduplicated: false, }), setChannelCapabilities: () => {}, setAssistantId: () => {}, trustContext: undefined as unknown, setTrustContext(this: { trustContext: unknown }, ctx: unknown) { this.trustContext = ctx; }, setAuthContext: () => {}, setCommandIntent: () => {}, setTurnChannelContext: () => {}, setTurnInterfaceContext: () => {}, ensureActorScopedHistory: async () => {}, usageStats: { inputTokens: 0, outputTokens: 0, estimatedCost: 0 }, replayActivityState: () => {}, setHostBrowserProxy: () => {}, setHostCuProxy: () => {}, setHostAppControlProxy: () => {}, addPreactivatedSkillId: () => {}, hasAnyPendingConfirmation: () => pending.size > 0, hasPendingConfirmation: (candidateRequestId: string) => pending.has(candidateRequestId), denyAllPendingConfirmations: denyAllPendingConfirmationsMock, emitConfirmationStateChanged: () => {}, emitActivityState: () => {}, getQueueDepth: () => queueDepth, enqueueMessage: enqueueMessageMock, runAgentLoop: runAgentLoopMock, handleConfirmationResponse: handleConfirmationResponseMock, handleSecretResponse: () => {}, getMessages: () => messages as never[], } as unknown as Conversation; return { conversation, runAgentLoopMock, enqueueMessageMock, denyAllPendingConfirmationsMock, handleConfirmationResponseMock, }; } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- const TEST_TOKEN = "test-bearer-token-send"; const AUTH_HEADERS = { Authorization: `Bearer ${TEST_TOKEN}` }; describe("POST /v1/messages — queue-if-busy and hub publishing", () => { let server: RuntimeHttpServer; let port: number; beforeEach(() => { const db = getDb(); db.run("DELETE FROM messages"); db.run("DELETE FROM conversations"); db.run("DELETE FROM conversation_keys"); bridgeState.reset(); db.run("DELETE FROM contact_channels"); db.run("DELETE FROM contacts"); pendingInteractions.clear(); __resetGuardianDeliveryCacheForTest(); createGuardianBinding({ channel: "vellum", guardianExternalUserId: "dev-bypass", guardianDeliveryChatId: "vellum", guardianPrincipalId: "test-principal-id", verifiedVia: "test", }); }); afterEach(async () => { await server?.stop(); }); async function startServer( conversationFactory: () => Conversation, options?: { approvalConversationGenerator?: ApprovalConversationGenerator }, ): Promise { _conversationFactory = conversationFactory; _approvalGenerator = options?.approvalConversationGenerator; server = new RuntimeHttpServer({ port: 0, }); await server.start(); port = server.actualPort; } async function stopServer(): Promise { await server?.stop(); } function messagesUrl(): string { return `http://127.0.0.1:${port}/v1/messages`; } // ── Idle conversation: immediate processing ───────────────────────── test("returns 202 with accepted: true and messageId when conversation is idle", async () => { await startServer(() => makeCompletingConversation()); const res = await fetch(messagesUrl(), { method: "POST", headers: { "Content-Type": "application/json", ...AUTH_HEADERS }, body: JSON.stringify({ conversationKey: "conv-idle", content: "Hello", sourceChannel: "vellum", interface: "macos", }), }); const body = (await res.json()) as { accepted: boolean; messageId: string; conversationId: string; }; expect(res.status).toBe(202); expect(body.accepted).toBe(true); expect(body.messageId).toBeDefined(); expect(typeof body.conversationId).toBe("string"); expect(body.conversationId.length).toBeGreaterThan(0); await stopServer(); }); test("publishes events to assistantEventHub when conversation is idle", async () => { const publishedEvents: AssistantEventEnvelope[] = []; await startServer(() => makeCompletingConversation()); // Subscribe on the module-level singleton that the route handler publishes to const { assistantEventHub: routeEventHub } = await import("../runtime/assistant-event-hub.js"); routeEventHub.subscribe({ type: "process", callback: (event: AssistantEventEnvelope) => { publishedEvents.push(event); }, }); const res = await fetch(messagesUrl(), { method: "POST", headers: { "Content-Type": "application/json", ...AUTH_HEADERS }, body: JSON.stringify({ conversationKey: "conv-hub", content: "Hello hub", sourceChannel: "vellum", interface: "macos", }), }); expect(res.status).toBe(202); // Wait for the async agent loop to complete and events to be published await new Promise((r) => setTimeout(r, 100)); // Should have received assistant_text_delta and message_complete const types = publishedEvents.map((e) => e.message.type); expect(types).toContain("assistant_text_delta"); expect(types).toContain("message_complete"); await stopServer(); }); test("consumes explicit approval text when a single pending confirmation exists (idle)", async () => { const conversationKey = "conv-inline-idle"; const { conversationId } = getOrCreateConversation(conversationKey); const requestId = "req-inline-idle"; const { conversation, runAgentLoopMock, enqueueMessageMock, denyAllPendingConfirmationsMock, handleConfirmationResponseMock, } = makePendingApprovalConversation(requestId, false); pendingInteractions.register(requestId, { conversationId, kind: "confirmation", }); bridgeState.seedRequest({ id: requestId, kind: "tool_approval", sourceType: "desktop", sourceChannel: "vellum", sourceConversationId: conversationId, toolName: "call_start", guardianPrincipalId: "test-principal-id", status: "pending", requestCode: "ABC123", expiresAt: Date.now() + 5 * 60 * 1000, }); await startServer(() => conversation); const res = await fetch(messagesUrl(), { method: "POST", headers: { "Content-Type": "application/json", ...AUTH_HEADERS }, body: JSON.stringify({ conversationKey, content: "yes", sourceChannel: "vellum", interface: "macos", }), }); const body = (await res.json()) as { accepted: boolean; messageId?: string; queued?: boolean; }; expect(res.status).toBe(202); expect(body.accepted).toBe(true); expect(body.messageId).toBeDefined(); expect(body.queued).toBeUndefined(); expect(handleConfirmationResponseMock).toHaveBeenCalledTimes(1); expect(denyAllPendingConfirmationsMock).toHaveBeenCalledTimes(0); expect(enqueueMessageMock).toHaveBeenCalledTimes(0); expect(runAgentLoopMock).toHaveBeenCalledTimes(0); await stopServer(); }); test("consumes natural-language approval text when approval conversation generator is configured", async () => { const conversationKey = "conv-inline-nl"; const { conversationId } = getOrCreateConversation(conversationKey); const requestId = "req-inline-nl"; const { conversation, runAgentLoopMock, enqueueMessageMock, denyAllPendingConfirmationsMock, handleConfirmationResponseMock, } = makePendingApprovalConversation(requestId, false); pendingInteractions.register(requestId, { conversationId, kind: "confirmation", }); bridgeState.seedRequest({ id: requestId, kind: "tool_approval", sourceType: "voice", sourceChannel: "slack", sourceConversationId: conversationId, toolName: "call_start", status: "pending", guardianPrincipalId: "test-principal-id", requestCode: "C0FFEE", expiresAt: Date.now() + 5 * 60 * 1000, }); const approvalConversationGenerator: ApprovalConversationGenerator = async ( context, ) => ({ disposition: "approve_once", replyText: "Approved.", targetRequestId: context.pendingApprovals[0]?.requestId, }); await startServer(() => conversation, { approvalConversationGenerator }); const res = await fetch(messagesUrl(), { method: "POST", headers: { "Content-Type": "application/json", ...AUTH_HEADERS }, body: JSON.stringify({ conversationKey, content: "sure let's do that", sourceChannel: "slack", interface: "slack", }), }); const body = (await res.json()) as { accepted: boolean; messageId?: string; queued?: boolean; }; expect(res.status).toBe(202); expect(body.accepted).toBe(true); expect(body.messageId).toBeDefined(); expect(body.queued).toBeUndefined(); expect(handleConfirmationResponseMock).toHaveBeenCalledTimes(1); expect(denyAllPendingConfirmationsMock).toHaveBeenCalledTimes(0); expect(enqueueMessageMock).toHaveBeenCalledTimes(0); expect(runAgentLoopMock).toHaveBeenCalledTimes(0); await stopServer(); }); test("consumes explicit approval text while busy instead of auto-denying and queueing", async () => { const conversationKey = "conv-inline-busy"; const { conversationId } = getOrCreateConversation(conversationKey); const requestId = "req-inline-busy"; const { conversation, runAgentLoopMock, enqueueMessageMock, denyAllPendingConfirmationsMock, handleConfirmationResponseMock, } = makePendingApprovalConversation(requestId, true); pendingInteractions.register(requestId, { conversationId, kind: "confirmation", }); bridgeState.seedRequest({ id: requestId, kind: "tool_approval", sourceType: "desktop", sourceChannel: "vellum", sourceConversationId: conversationId, toolName: "call_start", status: "pending", guardianPrincipalId: "test-principal-id", requestCode: "DEF456", expiresAt: Date.now() + 5 * 60 * 1000, }); await startServer(() => conversation); const res = await fetch(messagesUrl(), { method: "POST", headers: { "Content-Type": "application/json", ...AUTH_HEADERS }, body: JSON.stringify({ conversationKey, content: "approve", sourceChannel: "vellum", interface: "macos", }), }); const body = (await res.json()) as { accepted: boolean; messageId?: string; queued?: boolean; }; expect(res.status).toBe(202); expect(body.accepted).toBe(true); expect(body.messageId).toBeDefined(); expect(body.queued).toBeUndefined(); expect(handleConfirmationResponseMock).toHaveBeenCalledTimes(1); expect(denyAllPendingConfirmationsMock).toHaveBeenCalledTimes(0); expect(enqueueMessageMock).toHaveBeenCalledTimes(0); expect(runAgentLoopMock).toHaveBeenCalledTimes(0); await stopServer(); }); test("consumes explicit approval text while busy even when queue depth is non-zero", async () => { const conversationKey = "conv-inline-busy-queued"; const { conversationId } = getOrCreateConversation(conversationKey); const requestId = "req-inline-busy-queued"; const { conversation, runAgentLoopMock, enqueueMessageMock, denyAllPendingConfirmationsMock, handleConfirmationResponseMock, } = makePendingApprovalConversation(requestId, true, { queueDepth: 2 }); pendingInteractions.register(requestId, { conversationId, kind: "confirmation", }); bridgeState.seedRequest({ id: requestId, kind: "tool_approval", sourceType: "desktop", sourceChannel: "vellum", sourceConversationId: conversationId, toolName: "call_start", status: "pending", guardianPrincipalId: "test-principal-id", requestCode: "Q2D456", expiresAt: Date.now() + 5 * 60 * 1000, }); await startServer(() => conversation); const res = await fetch(messagesUrl(), { method: "POST", headers: { "Content-Type": "application/json", ...AUTH_HEADERS }, body: JSON.stringify({ conversationKey, content: "approve", sourceChannel: "vellum", interface: "macos", }), }); const body = (await res.json()) as { accepted: boolean; messageId?: string; queued?: boolean; }; expect(res.status).toBe(202); expect(body.accepted).toBe(true); expect(body.messageId).toBeDefined(); expect(body.queued).toBeUndefined(); expect(handleConfirmationResponseMock).toHaveBeenCalledTimes(1); expect(denyAllPendingConfirmationsMock).toHaveBeenCalledTimes(0); expect(enqueueMessageMock).toHaveBeenCalledTimes(0); expect(runAgentLoopMock).toHaveBeenCalledTimes(0); await stopServer(); }); test("consumes explicit rejection text when a single pending confirmation exists (idle)", async () => { const conversationKey = "conv-inline-reject"; const { conversationId } = getOrCreateConversation(conversationKey); const requestId = "req-inline-reject"; const { conversation, runAgentLoopMock, enqueueMessageMock, denyAllPendingConfirmationsMock, handleConfirmationResponseMock, } = makePendingApprovalConversation(requestId, false); pendingInteractions.register(requestId, { conversationId, kind: "confirmation", }); bridgeState.seedRequest({ id: requestId, kind: "tool_approval", sourceType: "desktop", sourceChannel: "vellum", sourceConversationId: conversationId, toolName: "call_start", status: "pending", guardianPrincipalId: "test-principal-id", requestCode: "GHI789", expiresAt: Date.now() + 5 * 60 * 1000, }); await startServer(() => conversation); const res = await fetch(messagesUrl(), { method: "POST", headers: { "Content-Type": "application/json", ...AUTH_HEADERS }, body: JSON.stringify({ conversationKey, content: "no", sourceChannel: "vellum", interface: "macos", }), }); const body = (await res.json()) as { accepted: boolean; messageId?: string; queued?: boolean; }; expect(res.status).toBe(202); expect(body.accepted).toBe(true); expect(body.messageId).toBeDefined(); expect(body.queued).toBeUndefined(); // Rejection still flows through handleConfirmationResponse (with reject action) expect(handleConfirmationResponseMock).toHaveBeenCalledTimes(1); expect(denyAllPendingConfirmationsMock).toHaveBeenCalledTimes(0); expect(enqueueMessageMock).toHaveBeenCalledTimes(0); expect(runAgentLoopMock).toHaveBeenCalledTimes(0); await stopServer(); }); test("does not consume ambiguous text — falls through to normal message handling", async () => { const conversationKey = "conv-inline-ambiguous"; const { conversationId } = getOrCreateConversation(conversationKey); const requestId = "req-inline-ambiguous"; const { conversation, runAgentLoopMock } = makePendingApprovalConversation( requestId, false, ); pendingInteractions.register(requestId, { conversationId, kind: "confirmation", }); bridgeState.seedRequest({ id: requestId, kind: "tool_approval", sourceType: "desktop", sourceChannel: "vellum", sourceConversationId: conversationId, toolName: "call_start", status: "pending", guardianPrincipalId: "test-principal-id", requestCode: "JKL012", expiresAt: Date.now() + 5 * 60 * 1000, }); await startServer(() => conversation); const res = await fetch(messagesUrl(), { method: "POST", headers: { "Content-Type": "application/json", ...AUTH_HEADERS }, body: JSON.stringify({ conversationKey, content: "What is the weather today?", sourceChannel: "vellum", interface: "macos", }), }); const body = (await res.json()) as { accepted: boolean; messageId?: string; queued?: boolean; }; // Ambiguous text should NOT be consumed — falls through to normal send path expect(res.status).toBe(202); expect(body.accepted).toBe(true); expect(body.messageId).toBeDefined(); // The normal idle send path fires runAgentLoop expect(runAgentLoopMock).toHaveBeenCalledTimes(1); await stopServer(); }); // ── Busy conversation: queue-if-busy ──────────────────────────────── test("returns 202 with queued: true when conversation is busy (not 409)", async () => { const conversation = makeHangingConversation(); await startServer(() => conversation); // First message starts the agent loop and makes the conversation busy const res1 = await fetch(messagesUrl(), { method: "POST", headers: { "Content-Type": "application/json", ...AUTH_HEADERS }, body: JSON.stringify({ conversationKey: "conv-busy", content: "First", sourceChannel: "vellum", interface: "macos", }), }); expect(res1.status).toBe(202); const body1 = (await res1.json()) as { accepted: boolean; messageId: string; }; expect(body1.accepted).toBe(true); expect(body1.messageId).toBeDefined(); // Wait for the agent loop to start await new Promise((r) => setTimeout(r, 30)); // Second message should be queued, not rejected const res2 = await fetch(messagesUrl(), { method: "POST", headers: { "Content-Type": "application/json", ...AUTH_HEADERS }, body: JSON.stringify({ conversationKey: "conv-busy", content: "Second", sourceChannel: "vellum", interface: "macos", }), }); const body2 = (await res2.json()) as { accepted: boolean; queued: boolean; conversationId: string; }; expect(res2.status).toBe(202); expect(body2.accepted).toBe(true); expect(body2.queued).toBe(true); expect(typeof body2.conversationId).toBe("string"); expect(body2.conversationId.length).toBeGreaterThan(0); await stopServer(); }); // ── Validation ────────────────────────────────────────────────────── test("returns 400 when sourceChannel is missing", async () => { await startServer(() => makeCompletingConversation()); const res = await fetch(messagesUrl(), { method: "POST", headers: { "Content-Type": "application/json", ...AUTH_HEADERS }, body: JSON.stringify({ conversationKey: "conv-val", content: "Hello" }), }); expect(res.status).toBe(400); await stopServer(); }); test("returns 400 when content is empty", async () => { await startServer(() => makeCompletingConversation()); const res = await fetch(messagesUrl(), { method: "POST", headers: { "Content-Type": "application/json", ...AUTH_HEADERS }, body: JSON.stringify({ conversationKey: "conv-empty", content: "", sourceChannel: "vellum", interface: "macos", }), }); expect(res.status).toBe(400); await stopServer(); }); test("accepts message when conversationKey is omitted (vellum channel mints fresh)", async () => { await startServer(() => makeCompletingConversation()); const res = await fetch(messagesUrl(), { method: "POST", headers: { "Content-Type": "application/json", ...AUTH_HEADERS }, body: JSON.stringify({ content: "Hello", sourceChannel: "vellum", interface: "macos", }), }); expect(res.status).toBe(202); const body = (await res.json()) as { accepted: boolean; conversationId: string; }; expect(body.accepted).toBe(true); expect(body.conversationId).toBeTruthy(); // The vellum channel never falls through to the shared // `default:vellum:` thread: each empty-handed send mints // a fresh conversation so the first-message id surfaces to the client. expect(getConversationByKey("default:vellum:macos")).toBeNull(); await stopServer(); }); test("two empty-handed vellum sends each mint distinct conversations", async () => { await startServer(() => makeCompletingConversation()); const res1 = await fetch(messagesUrl(), { method: "POST", headers: { "Content-Type": "application/json", ...AUTH_HEADERS }, body: JSON.stringify({ content: "First", sourceChannel: "vellum", interface: "macos", }), }); expect(res1.status).toBe(202); const body1 = (await res1.json()) as { conversationId: string }; const res2 = await fetch(messagesUrl(), { method: "POST", headers: { "Content-Type": "application/json", ...AUTH_HEADERS }, body: JSON.stringify({ content: "Second", sourceChannel: "vellum", interface: "macos", }), }); expect(res2.status).toBe(202); const body2 = (await res2.json()) as { conversationId: string }; expect(body1.conversationId).toBeTruthy(); expect(body2.conversationId).toBeTruthy(); expect(body1.conversationId).not.toBe(body2.conversationId); await stopServer(); }); test("two empty-handed phone sends share the default channel thread", async () => { await startServer(() => makeCompletingConversation()); const res1 = await fetch(messagesUrl(), { method: "POST", headers: { "Content-Type": "application/json", ...AUTH_HEADERS }, body: JSON.stringify({ content: "First", sourceChannel: "phone", interface: "phone", }), }); expect(res1.status).toBe(202); const body1 = (await res1.json()) as { conversationId: string }; const res2 = await fetch(messagesUrl(), { method: "POST", headers: { "Content-Type": "application/json", ...AUTH_HEADERS }, body: JSON.stringify({ content: "Second", sourceChannel: "phone", interface: "phone", }), }); expect(res2.status).toBe(202); const body2 = (await res2.json()) as { conversationId: string }; // Non-vellum channels keep the legacy `default::` // co-location so repeated inbound messages from the same external // channel/interface land on a single thread. expect(body1.conversationId).toBe(body2.conversationId); const mapping = getConversationByKey("default:phone:phone"); expect(mapping).not.toBeNull(); expect(mapping!.conversationId).toBe(body1.conversationId); await stopServer(); }); test("auto-deny resolves the guardian request so stale records do not cause pending_interaction_not_found", async () => { const conversationKey = "conv-auto-deny-guardian"; const { conversationId } = getOrCreateConversation(conversationKey); const requestId = "req-auto-deny-guardian"; // Step 1: Create a pending approval conversation with a guardian request. const { conversation, denyAllPendingConfirmationsMock } = makePendingApprovalConversation(requestId, false); pendingInteractions.register(requestId, { conversationId, kind: "confirmation", }); bridgeState.seedRequest({ id: requestId, kind: "tool_approval", sourceType: "desktop", sourceChannel: "vellum", sourceConversationId: conversationId, toolName: "bash", guardianPrincipalId: "test-principal-id", status: "pending", requestCode: "STALE1", expiresAt: Date.now() + 5 * 60 * 1000, }); await startServer(() => conversation); // Step 2: Send a non-approval message to trigger auto-deny of the // pending confirmation. "do something else" is not an approval phrase, // so tryConsumeGuardianReply won't consume it, and the // auto-deny path will fire. const res = await fetch(messagesUrl(), { method: "POST", headers: { "Content-Type": "application/json", ...AUTH_HEADERS }, body: JSON.stringify({ conversationKey, content: "do something else instead", sourceChannel: "vellum", interface: "macos", }), }); expect(res.status).toBe(202); expect(denyAllPendingConfirmationsMock).toHaveBeenCalledTimes(1); // Step 3: Verify the guardian request was resolved to "denied". // Without the fix, this would remain "pending", causing // pending_interaction_not_found errors on subsequent "yes" messages. const guardianRequest = bridgeState.getRequest(requestId); expect(guardianRequest).toBeDefined(); expect(guardianRequest!.status).toBe("denied"); await stopServer(); }); });