/** * Behavioral tests for centralized confirmation state emissions and * activity version ordering. * * Covers: * - handleConfirmationResponse emits both confirmation_state_changed and * assistant_activity_state events centrally * - emitActivityState produces monotonically increasing activityVersion * - sendToClient receives state signals (confirmation_state_changed, assistant_activity_state) * - "deny" decisions produce 'denied' state, "allow" produces 'approved' */ import { describe, expect, mock, test } from "bun:test"; import { CompactionCircuit } from "../agent/compaction-circuit.js"; import type { AgentEvent } from "../agent/loop.js"; import type { AssistantEvent } from "../api/index.js"; import type { Message, ProviderResponse } from "../providers/types.js"; // --------------------------------------------------------------------------- // Mocks — must precede Conversation import // --------------------------------------------------------------------------- mock.module("../providers/registry.js", () => ({ getProvider: () => ({ name: "mock-provider" }), initializeProviders: async () => {}, })); mock.module("../prompts/system-prompt.js", () => ({ buildSystemPrompt: () => "system prompt", })); mock.module("../config/skills.js", () => ({ loadSkillCatalog: () => [], loadSkillBySelector: () => ({ skill: null }), ensureSkillIcon: async () => null, })); mock.module("../config/skill-state.js", () => ({ resolveSkillStates: () => [], })); mock.module("../permissions/trust-store.js", () => ({ addRule: () => {}, findHighestPriorityRule: () => null, clearCache: () => {}, })); mock.module("../security/secret-allowlist.js", () => ({ resetAllowlist: () => {}, })); mock.module("../persistence/conversation-crud.js", () => ({ setConversationProcessingStartedAt: () => {}, isConversationProcessing: () => false, setConversationOriginChannelIfUnset: () => {}, updateConversationContextWindow: () => {}, deleteMessageById: () => {}, provenanceFromTrustContext: () => ({ source: "user", trustContext: undefined, }), getConversationOriginInterface: () => null, getConversationOriginChannel: () => null, getMessages: () => [], getConversation: () => ({ id: "conv-1", contextSummary: null, contextCompactedMessageCount: 0, totalInputTokens: 0, totalOutputTokens: 0, totalEstimatedCost: 0, }), createConversation: () => ({ id: "conv-1" }), addMessage: () => ({ id: `msg-${Date.now()}` }), updateConversationUsage: () => {}, updateConversationTitle: () => {}, reserveMessage: mock(async () => ({ id: "msg-reserve" })), })); mock.module("../persistence/conversation-queries.js", () => ({ listConversations: () => [], })); mock.module("../persistence/attachments-store.js", () => ({ uploadAttachment: () => ({ id: `att-${Date.now()}` }), linkAttachmentToMessage: () => {}, })); mock.module("../memory/retriever.js", () => ({ buildMemoryRecall: async () => ({ enabled: false, degraded: false, injectedText: "", semanticHits: 0, injectedTokens: 0, latencyMs: 0, }), injectMemoryRecallAsUserBlock: (msgs: Message[]) => msgs, })); mock.module("../plugins/defaults/compaction/window-manager.js", () => ({ ContextWindowManager: class { estimateInputTokens() { return 0; } get tokenCountInputs() { return { systemPrompt: "", tools: undefined }; } constructor() {} updateConfig() {} shouldCompact() { return { needed: false, estimatedTokens: 0 }; } async maybeCompact() { return { compacted: false }; } resetOverflowRecovery() {} }, createContextSummaryMessage: () => ({ role: "user", content: [{ type: "text", text: "summary" }], }), getSummaryFromContextMessage: () => null, })); mock.module("../persistence/llm-usage-store.js", () => ({ recordUsageEvent: () => ({ id: "mock-id", createdAt: Date.now() }), listUsageEvents: () => [], })); mock.module("../agent/loop.js", () => ({ AgentLoop: class { compactionCircuit = new CompactionCircuit("test-conv"); constructor() {} getToolTokenBudget() { return 0; } getResolvedTools() { return []; } getActiveModel() { return undefined; } async run(_options: { messages: Message[]; onEvent: (event: AgentEvent) => void; }): Promise { return []; } }, })); // --------------------------------------------------------------------------- // Import Conversation AFTER mocks // --------------------------------------------------------------------------- import { Conversation } from "../daemon/conversation.js"; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function makeProvider() { return { name: "mock", async sendMessage(): Promise { return { content: [], model: "mock", usage: { inputTokens: 0, outputTokens: 0 }, stopReason: "end_turn", }; }, }; } function makeConversation( sendToClient?: (msg: AssistantEvent) => void, ): Conversation { return new Conversation( "conv-signals-test", makeProvider(), "system prompt", sendToClient ?? (() => {}), process.env.VELLUM_WORKSPACE_DIR!, { maxTokens: 4096 }, ); } /** * Seed a pending confirmation directly in the prompter's internal map. * This avoids calling `prompt()` which has complex side effects (sends * a confirmation_request message, needs allowlistOptions, etc.). */ function seedPendingConfirmation( conversation: Conversation, requestId: string, ): void { // Access private ownedIds so denyAllPending/dispose can find this request. // promptResolve/promptReject callbacks are stored in pendingInteractions via // registerPendingInteraction, which is called separately in each test. const prompter = conversation["prompter"] as unknown as { ownedIds: Set; }; prompter.ownedIds.add(requestId); } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- describe("centralized confirmation emissions", () => { test("handleConfirmationResponse emits confirmation_state_changed with approved state for allow decision", () => { const emitted: AssistantEvent[] = []; const conversation = makeConversation((msg) => emitted.push(msg)); seedPendingConfirmation(conversation, "req-allow-1"); conversation.handleConfirmationResponse("req-allow-1", "allow"); const confirmMsgs = emitted.filter( (m) => m.type === "confirmation_state_changed", ); // Filter to our explicitly requested emission (not the pending/timed_out ones from prompter) const confirmMsg = confirmMsgs.find( (m) => "requestId" in m && (m as { requestId: string }).requestId === "req-allow-1" && "state" in m && (m as { state: string }).state === "approved", ); expect(confirmMsg).toBeDefined(); expect(confirmMsg).toMatchObject({ type: "confirmation_state_changed", conversationId: "conv-signals-test", requestId: "req-allow-1", state: "approved", source: "button", }); }); test("handleConfirmationResponse emits confirmation_state_changed with denied state for deny decision", () => { const emitted: AssistantEvent[] = []; const conversation = makeConversation((msg) => emitted.push(msg)); seedPendingConfirmation(conversation, "req-deny-1"); conversation.handleConfirmationResponse("req-deny-1", "deny"); const confirmMsg = emitted.find( (m) => m.type === "confirmation_state_changed" && "requestId" in m && (m as { requestId: string }).requestId === "req-deny-1" && "state" in m && (m as { state: string }).state === "denied", ); expect(confirmMsg).toBeDefined(); expect(confirmMsg).toMatchObject({ type: "confirmation_state_changed", requestId: "req-deny-1", state: "denied", source: "button", }); }); test("handleConfirmationResponse emits assistant_activity_state with thinking phase", () => { const emitted: AssistantEvent[] = []; const conversation = makeConversation((msg) => emitted.push(msg)); seedPendingConfirmation(conversation, "req-activity-1"); conversation.handleConfirmationResponse("req-activity-1", "allow"); const activityMsg = emitted.find( (m) => m.type === "assistant_activity_state" && "reason" in m && (m as { reason: string }).reason === "confirmation_resolved", ); expect(activityMsg).toBeDefined(); expect(activityMsg).toMatchObject({ type: "assistant_activity_state", conversationId: "conv-signals-test", phase: "thinking", reason: "confirmation_resolved", anchor: "assistant_turn", }); }); test("handleConfirmationResponse passes emissionContext source", () => { const emitted: AssistantEvent[] = []; const conversation = makeConversation((msg) => emitted.push(msg)); seedPendingConfirmation(conversation, "req-ctx-1"); conversation.handleConfirmationResponse("req-ctx-1", "allow", { emissionContext: { source: "inline_nl", decisionText: "yes please", }, }); const confirmMsg = emitted.find( (m) => m.type === "confirmation_state_changed" && "requestId" in m && (m as { requestId: string }).requestId === "req-ctx-1", ); expect(confirmMsg).toBeDefined(); expect(confirmMsg).toMatchObject({ source: "inline_nl", decisionText: "yes please", }); }); }); describe("activity version ordering", () => { test("emitActivityState produces monotonically increasing activityVersion", () => { const emitted: AssistantEvent[] = []; const conversation = makeConversation((msg) => emitted.push(msg)); conversation.emitActivityState("thinking", "message_dequeued"); conversation.emitActivityState("streaming", "first_text_delta"); conversation.emitActivityState("tool_running", "tool_use_start"); conversation.emitActivityState("idle", "message_complete", { anchor: "global", }); const activityMsgs = emitted.filter( (m) => m.type === "assistant_activity_state", ) as Array; expect(activityMsgs).toHaveLength(4); // Versions must be strictly increasing for (let i = 1; i < activityMsgs.length; i++) { expect(activityMsgs[i].activityVersion).toBeGreaterThan( activityMsgs[i - 1].activityVersion, ); } // First version must be >= 1 expect(activityMsgs[0].activityVersion).toBeGreaterThanOrEqual(1); }); test("handleConfirmationResponse increments activityVersion for its activity emission", () => { const emitted: AssistantEvent[] = []; const conversation = makeConversation((msg) => emitted.push(msg)); // Emit a baseline activity state conversation.emitActivityState("thinking", "message_dequeued"); const baselineMsg = emitted.find( (m) => m.type === "assistant_activity_state", ) as AssistantEvent & { activityVersion: number }; const baselineVersion = baselineMsg.activityVersion; // Now handle a confirmation seedPendingConfirmation(conversation, "req-version-1"); conversation.handleConfirmationResponse("req-version-1", "allow"); const activityMsgs = emitted.filter( (m) => m.type === "assistant_activity_state", ) as Array; // The confirmation_resolved activity message should have a higher version const resolvedMsg = activityMsgs.find( (m) => m.reason === "confirmation_resolved", ); expect(resolvedMsg).toBeDefined(); expect(resolvedMsg!.activityVersion).toBeGreaterThan(baselineVersion); }); }); describe("sendToClient receives state signals", () => { test("emitActivityState delivers to sendToClient", () => { const clientMsgs: AssistantEvent[] = []; const conversation = makeConversation((msg) => clientMsgs.push(msg)); conversation.emitActivityState("thinking", "message_dequeued"); expect( clientMsgs.filter((m) => m.type === "assistant_activity_state"), ).toHaveLength(1); }); test("emitConfirmationStateChanged delivers to sendToClient", () => { const clientMsgs: AssistantEvent[] = []; const conversation = makeConversation((msg) => clientMsgs.push(msg)); conversation.emitConfirmationStateChanged({ conversationId: "conv-signals-test", requestId: "req-signal-1", state: "approved", source: "button", }); expect( clientMsgs.filter((m) => m.type === "confirmation_state_changed"), ).toHaveLength(1); }); test("handleConfirmationResponse delivers state signals to sendToClient", () => { const clientMsgs: AssistantEvent[] = []; const conversation = makeConversation((msg) => clientMsgs.push(msg)); seedPendingConfirmation(conversation, "req-signal-confirm"); conversation.handleConfirmationResponse("req-signal-confirm", "allow"); const confirmSignal = clientMsgs.find( (m) => m.type === "confirmation_state_changed" && "requestId" in m && (m as { requestId: string }).requestId === "req-signal-confirm", ); const activitySignal = clientMsgs.find( (m) => m.type === "assistant_activity_state" && "reason" in m && (m as { reason: string }).reason === "confirmation_resolved", ); expect(confirmSignal).toBeDefined(); expect(confirmSignal).toMatchObject({ state: "approved", requestId: "req-signal-confirm", }); expect(activitySignal).toBeDefined(); expect(activitySignal).toMatchObject({ phase: "thinking", reason: "confirmation_resolved", }); }); }); describe("the sink is fixed for the conversation's life", () => { test("a cold instance delivers activity state with no turn ever having run", () => { // The summarize-up-to route resolves its conversation outside the send // path and emits a thinking indicator before any turn: it must reach the // sink the conversation was built with, not depend on a send having wired // one (LUM-2987). const clientMsgs: AssistantEvent[] = []; const conversation = makeConversation((msg) => clientMsgs.push(msg)); conversation.emitActivityState("thinking", "context_compacting", { statusText: "Summarizing conversation", }); const activity = clientMsgs.filter( (m) => m.type === "assistant_activity_state", ); expect(activity).toHaveLength(1); expect((activity[0] as { statusText?: string }).statusText).toBe( "Summarizing conversation", ); }); test("presence is a property of the turn, not of delivery", () => { // Nothing is in flight, so no human is asserted present, even though the // sink is live and delivering. const clientMsgs: AssistantEvent[] = []; const conversation = makeConversation((msg) => clientMsgs.push(msg)); expect(conversation.hasNoClient).toBe(true); conversation.emitActivityState("thinking", "message_dequeued"); expect(clientMsgs).toHaveLength(1); expect(conversation.hasNoClient).toBe(true); }); }); describe("event observers", () => { test("an observer sees each event after the sink delivered it, and detaches", () => { const order: string[] = []; const conversation = makeConversation((msg) => { order.push(`sink:${msg.type}`); }); const detach = conversation.addEventObserver((msg) => { order.push(`observer:${msg.type}`); }); conversation.emitActivityState("thinking", "message_dequeued"); expect(order).toEqual([ "sink:assistant_activity_state", "observer:assistant_activity_state", ]); detach(); conversation.emitActivityState("idle", "message_complete"); expect(order).toEqual([ "sink:assistant_activity_state", "observer:assistant_activity_state", "sink:assistant_activity_state", ]); }); test("a throwing observer neither blocks the sink nor its siblings", () => { const seen: string[] = []; const conversation = makeConversation((msg) => { seen.push(`sink:${msg.type}`); }); conversation.addEventObserver(() => { throw new Error("observer boom"); }); conversation.addEventObserver((msg) => { seen.push(`second:${msg.type}`); }); conversation.emitActivityState("thinking", "message_dequeued"); expect(seen).toEqual([ "sink:assistant_activity_state", "second:assistant_activity_state", ]); }); test("the prompter's confirmation_request reaches observers after the sink", () => { // Voice auto-resolves approval prompts it has no UI for; it must observe // the request only once the sink has put it on the wire, so its resolution // lands after the request it answers. const order: string[] = []; const conversation = makeConversation((msg) => { order.push(`sink:${msg.type}`); }); conversation.addEventObserver((msg) => { if (msg.type === "confirmation_request") { order.push("observer:confirmation_request"); conversation.handleConfirmationResponse(msg.requestId, "deny", { emissionContext: { source: "system" }, }); } }); void conversation.prompter.prompt( "bash", { command: "rm -rf /tmp/x" }, "high", [], [], undefined, conversation.conversationId, "sandbox", false, ); expect(order[0]).toBe("sink:confirmation_request"); expect(order[1]).toBe("observer:confirmation_request"); }); });