/** * Queue-branch contract for hidden sends in POST /v1/messages. * * A hidden send is a machine signal (e.g. the channel-setup wizard-close * marker), not a user decision — when it queues behind an in-flight turn it * must NOT supersede pending interactions: no auto-denied confirmations, no * steer of a parked ask_question. A visible send in the same state keeps the * existing supersede behavior ("the user chose to move on"). */ import { afterEach, describe, expect, mock, test } from "bun:test"; mock.module("../config/env.js", () => ({ isHttpAuthDisabled: () => true })); mock.module("../persistence/conversation-key-store.js", () => ({ getOrCreateConversation: () => ({ conversationId: "conv-hidden-queue" }), getConversationByKey: () => null, })); mock.module("../runtime/guardian-reply-router.js", () => ({ routeGuardianReply: async () => ({ consumed: false, decisionApplied: false, type: "not_consumed", }), })); mock.module("../channels/gateway-guardian-requests.js", () => ({ createGuardianRequest: async (params: Record) => ({ ...params, requestCode: "ABC123", }), })); mock.module("../runtime/confirmation-request-guardian-bridge.js", () => ({ bridgeConfirmationRequestToGuardian: async () => undefined, })); mock.module("../persistence/conversation-crud.js", () => ({ setConversationProcessingStartedAt: () => {}, isConversationProcessing: () => false, addMessage: async (_conversationId: string, role: string) => ({ id: role === "user" ? "persisted-user-id" : "persisted-assistant-id", deduplicated: false, }), extractImageSourcePaths: () => undefined, getConversation: () => null, getConversationOverrideProfile: () => undefined, getMessages: () => [], isHiddenMessageMetadata: (meta: Record | undefined) => meta?.hidden === true, provenanceFromTrustContext: (ctx: unknown) => ctx ? { provenanceTrustClass: (ctx as Record).trustClass } : { provenanceTrustClass: "unknown" }, setConversationOriginChannelIfUnset: () => {}, setConversationOriginInterfaceIfUnset: () => {}, setConversationInferenceProfile: () => {}, setConversationEnabledPlugins: () => {}, reserveMessage: mock(async () => ({ id: "msg-reserve" })), recordConversationPersistedSeq: () => {}, })); mock.module("../persistence/conversation-disk-view.js", () => ({ syncMessageToDisk: () => {}, updateMetaFile: () => {}, })); mock.module("../persistence/attachments-store.js", () => ({ getAttachmentsByIds: () => [], getSourcePathsForAttachments: () => new Map(), attachmentExists: () => false, linkAttachmentToMessage: () => {}, attachInlineAttachmentToMessage: () => {}, validateAttachmentUpload: () => ({ ok: true }), })); mock.module("../daemon/conversation-process.js", () => ({ buildModelInfoEvent: () => ({ type: "model_info", model: "claude-opus-4-7", provider: "anthropic", configuredProviders: ["anthropic"], }), isModelSlashCommand: () => false, formatCompactResult: () => "", })); const realLocalActorIdentity = await import("../runtime/local-actor-identity.js"); mock.module("../runtime/local-actor-identity.js", () => ({ ...realLocalActorIdentity, })); mock.module("../runtime/trust-context-resolver.js", () => ({ resolveTrustContext: () => ({ trustClass: "guardian", sourceChannel: "vellum", }), withSourceChannel: (sourceChannel: unknown, ctx: unknown) => ({ ...(ctx as Record), sourceChannel, }), })); mock.module("../contacts/guardian-delivery-reader.js", () => ({ getGuardianDelivery: async () => [ { channelType: "vellum", contactId: "guardian-contact", principalId: "test-user", address: "test-user", status: "active", }, ], })); mock.module("../ipc/gateway-client.js", () => ({ ipcCall: async () => ({ ok: true }), })); import type { Conversation } from "../daemon/conversation.js"; import { deleteConversation, setConversation, } from "../daemon/conversation-registry.js"; import * as pendingInteractions from "../runtime/pending-interactions.js"; import { handleSendMessage } from "../runtime/routes/conversation-routes.js"; import { callHandler } from "./helpers/call-route-handler.js"; const CONV_ID = "conv-hidden-queue"; interface BusyConversationSpies { conversation: Conversation; enqueuedMetadata: () => Record | undefined; denyAllCount: () => number; abortCount: () => number; agentLoopOptions: () => Record | undefined; } /** * A live conversation with a pending tool confirmation. `processing: true` * models a mid-turn conversation (queue branch); `false` an idle one whose * confirmation outlived its turn (e.g. a guardian approval awaiting a * channel reply). */ function makeConversationWithPendingConfirmation( processing: boolean, ): BusyConversationSpies { let enqueuedMetadata: Record | undefined; let denyAllCount = 0; let abortCount = 0; let agentLoopOptions: Record | undefined; const conversation = { conversationId: CONV_ID, messages: [], abortController: { abort: () => { abortCount += 1; }, }, currentRequestId: undefined, queue: { length: 0, promoteToHead: (requestId: string) => ({ requestId }), }, pendingSteerRepair: false, // Stores rather than discarding, so a test can observe which trust the // route resolved and move the slot afterwards. setTrustContext(this: { trustContext: unknown }, ctx: unknown) { this.trustContext = ctx; }, replayActivityState: () => {}, emitConfirmationStateChanged: () => {}, emitActivityState: () => {}, setTurnChannelContext: () => {}, setTurnInterfaceContext: () => {}, getTurnChannelContext: () => null, getTurnInterfaceContext: () => null, ensureActorScopedHistory: async () => {}, isProcessing: () => processing, setProcessing: () => {}, hasAnyPendingConfirmation: () => true, denyAllPendingConfirmations: () => { denyAllCount += 1; }, enqueueMessage: (options: { metadata?: Record }) => { enqueuedMetadata = options.metadata; return { queued: true, requestId: "queued-id" }; }, persistUserMessage: async () => ({ id: "persisted-user-id", deduplicated: false, }), runAgentLoop: async ( _content: string, _messageId: string, options?: Record, ) => { agentLoopOptions = options; }, setPreactivatedSkillIds: () => {}, drainQueue: async (_reason?: string) => {}, kickDrainQueue( this: { drainQueue: (reason?: string) => unknown }, reason: string = "loop_complete", _origin?: string, ) { return this.drainQueue(reason); }, warmPromptCache: () => {}, getMessages: () => [], assistantId: "self", trustContext: undefined, hasPendingConfirmation: () => false, setHostBrowserProxy: () => {}, setHostCuProxy: () => {}, setHostAppControlProxy: () => {}, addPreactivatedSkillId: () => {}, usageStats: { inputTokens: 1000, outputTokens: 500, estimatedCost: 0.05 }, } as unknown as Conversation; return { conversation, enqueuedMetadata: () => enqueuedMetadata, denyAllCount: () => denyAllCount, abortCount: () => abortCount, agentLoopOptions: () => agentLoopOptions, }; } function makeBusyConversation(): BusyConversationSpies { return makeConversationWithPendingConfirmation(true); } function makeRequest(extras: Record = {}) { return new Request("http://localhost/v1/messages", { method: "POST", headers: { "Content-Type": "application/json", "x-vellum-actor-principal-id": "test-user", "x-vellum-principal-type": "actor", }, body: JSON.stringify({ conversationKey: "hidden-queue-key", content: "[User action on channel_setup surface: closed the slack setup wizard]", sourceChannel: "vellum", interface: "macos", ...extras, }), }); } function makeDeps(conversation: Conversation) { return { sendMessageDeps: { getOrCreateConversation: async () => conversation, assistantEventHub: { publish: async () => {} } as never, resolveAttachments: () => [], }, }; } const registeredRequestIds: string[] = []; function registerConfirmation(): void { const requestId = `pending-confirmation-${registeredRequestIds.length}`; pendingInteractions.register(requestId, { conversationId: CONV_ID, kind: "confirmation", }); registeredRequestIds.push(requestId); } afterEach(() => { for (const id of registeredRequestIds) { pendingInteractions.resolve(id, "cancelled"); } registeredRequestIds.length = 0; deleteConversation(CONV_ID); }); describe("hidden sends queued behind an in-flight turn", () => { test("carry hidden metadata and do NOT supersede pending interactions", async () => { const spies = makeBusyConversation(); setConversation(CONV_ID, spies.conversation); registerConfirmation(); const res = await callHandler( (args) => handleSendMessage(args, makeDeps(spies.conversation)), makeRequest({ hidden: true }), undefined, 202, ); expect(res.status).toBe(202); // Queued with the transcript-suppression flag intact... expect(spies.enqueuedMetadata()?.hidden).toBe(true); // ...without auto-denying the live approval prompt or aborting the turn: // a passive UI event is not the user choosing to move on. expect(spies.denyAllCount()).toBe(0); expect(spies.abortCount()).toBe(0); }); test("visible sends in the same state keep the supersede behavior", async () => { const spies = makeBusyConversation(); setConversation(CONV_ID, spies.conversation); registerConfirmation(); const res = await callHandler( (args) => handleSendMessage(args, makeDeps(spies.conversation)), makeRequest({ content: "actually, do this instead" }), undefined, 202, ); expect(res.status).toBe(202); expect(spies.enqueuedMetadata()?.hidden).toBeUndefined(); // The typed message supersedes: pending confirmations are auto-denied. expect(spies.denyAllCount()).toBe(1); }); }); describe("hidden sends to an idle conversation with a pending confirmation", () => { test("do NOT auto-deny the confirmation and mark the turn as a hidden prompt", async () => { const spies = makeConversationWithPendingConfirmation(false); setConversation(CONV_ID, spies.conversation); registerConfirmation(); const res = await callHandler( (args) => handleSendMessage(args, makeDeps(spies.conversation)), makeRequest({ hidden: true }), undefined, 202, ); expect(res.status).toBe(202); // The confirmation that outlived its turn (e.g. a guardian approval // awaiting a channel reply) survives the machine signal... expect(spies.denyAllCount()).toBe(0); // ...and the turn is flagged so prompt-as-user-speech consumers (title // generation) skip it. expect(spies.agentLoopOptions()?.isHiddenPrompt).toBe(true); }); test("visible sends to the same state keep the idle auto-deny cleanup", async () => { const spies = makeConversationWithPendingConfirmation(false); setConversation(CONV_ID, spies.conversation); registerConfirmation(); const res = await callHandler( (args) => handleSendMessage(args, makeDeps(spies.conversation)), makeRequest({ content: "hello again" }), undefined, 202, ); expect(res.status).toBe(202); expect(spies.denyAllCount()).toBe(1); expect(spies.agentLoopOptions()?.isHiddenPrompt).toBeUndefined(); }); }); describe("POST /messages turn trust", () => { test("the idle send path runs its turn under the trust resolved for the request", async () => { // The idle web path does not go through `processMessage`; it persists and // calls `runAgentLoop` directly. Without the captured trust travelling // with that call, the loop re-reads the conversation slot when it opens, // and the slot is writable in between by paths that do not own this turn // (channel ingress for another actor, live-voice hydration, pointer // elevation, the voice bridge). // // The defect here is at the call site, not inside the loop, so observing // the options the route passes is sufficient. The re-read *inside* the // loop is covered separately against a real Conversation, since a stub // like this one cannot see it. const spies = makeConversationWithPendingConfirmation(false); setConversation(CONV_ID, spies.conversation); const conversation = spies.conversation as unknown as { trustContext: unknown; persistUserMessage: () => Promise<{ id: string; deduplicated: boolean }>; }; // Another actor writes the slot inside the window, after the route // resolves this request's trust and before the loop call. let slotMoved = false; conversation.persistUserMessage = async () => { conversation.trustContext = { trustClass: "trusted_contact", sourceChannel: "slack", requesterExternalUserId: "U-other-actor", }; slotMoved = true; return { id: "persisted-user-id", deduplicated: false }; }; const res = await callHandler( (args) => handleSendMessage(args, makeDeps(spies.conversation)), makeRequest({ content: "run a command" }), undefined, 202, ); expect(res.status).toBe(202); // Guard the test itself: if the injection stopped running the assertion // below would pass for the wrong reason. expect(slotMoved).toBe(true); expect( (conversation.trustContext as { trustClass?: string } | undefined) ?.trustClass, ).toBe("trusted_contact"); // The turn carries the trust this request resolved, not the moved slot. const turnTrust = spies.agentLoopOptions()?.turnTrustContext as | { trustClass?: string } | undefined; expect(turnTrust).toBeDefined(); expect(turnTrust?.trustClass).toBe("guardian"); }); });