/** * Tests for cascading approval decisions to matching pending confirmations. * * When a user resolves one confirmation with allow or deny, other pending * confirmations in the same conversation that match may be auto-resolved. */ import { beforeEach, describe, expect, mock, test } from "bun:test"; import { Minimatch } from "minimatch"; import { CompactionCircuit } from "../agent/compaction-circuit.js"; import type { AgentEvent } from "../agent/loop.js"; import type { ConfirmationStateChangedEvent } from "../api/events/confirmation-state-changed.js"; import type { AssistantEvent } from "../api/index.js"; import type { Message, ProviderResponse } from "../providers/types.js"; import type { ConfirmationDetails } from "../runtime/pending-interactions.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: () => [], })); // Trust store mock — uses real minimatch for patternMatchesCandidate so the // mock doesn't break trust-store-pattern-matches.test.ts when both files run // in the same Bun process (mock.module leaks across test files). mock.module("../permissions/trust-store.js", () => ({ addRule: () => {}, findHighestPriorityRule: () => null, clearCache: () => {}, patternMatchesCandidate: (pattern: string, candidate: string): boolean => { try { return new Minimatch(pattern).match(candidate); } catch { return false; } }, })); 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 and pendingInteractions AFTER mocks // --------------------------------------------------------------------------- import { Conversation } from "../daemon/conversation.js"; import * as pendingInteractions from "../runtime/pending-interactions.js"; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- const CONV_ID = "conv-cascade-test"; 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, conversationId = CONV_ID, ): Conversation { return new Conversation( conversationId, makeProvider(), "system prompt", sendToClient ?? (() => {}), process.env.VELLUM_WORKSPACE_DIR!, { maxTokens: 4096 }, ); } /** * Seed a pending confirmation directly in the prompter's internal map. */ 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); } /** * Register a pending interaction in the pending-interactions tracker with * confirmation details. */ function registerPendingInteraction( requestId: string, conversationId: string, confirmationDetails?: ConfirmationDetails, ): void { pendingInteractions.register(requestId, { conversationId, kind: "confirmation", confirmationDetails, }); } function makeConfirmationDetails(patterns: string[]): ConfirmationDetails { return { toolName: "bash", input: { command: "echo hello" }, riskLevel: "medium", allowlistOptions: patterns.map((p) => ({ label: p, description: `Allow ${p}`, pattern: p, })), scopeOptions: [{ label: "Everywhere", scope: "everywhere" }], }; } beforeEach(() => { pendingInteractions.clear(); }); // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- describe("approval cascading", () => { test("allow (one-time) does NOT cascade", () => { const emitted: AssistantEvent[] = []; const conversationObj = makeConversation( (msg) => emitted.push(msg), CONV_ID, ); seedPendingConfirmation(conversationObj, "req-1"); seedPendingConfirmation(conversationObj, "req-2"); registerPendingInteraction( "req-1", CONV_ID, makeConfirmationDetails(["bash:echo hello"]), ); registerPendingInteraction( "req-2", CONV_ID, makeConfirmationDetails(["bash:echo world"]), ); conversationObj.handleConfirmationResponse("req-1", "allow"); const confirmMsgs = emitted.filter( (m) => m.type === "confirmation_state_changed" && (m as unknown as ConfirmationStateChangedEvent).state === "approved", ) as unknown as ConfirmationStateChangedEvent[]; // Only the primary should be resolved expect(confirmMsgs).toHaveLength(1); expect(confirmMsgs[0].requestId).toBe("req-1"); }); test("deny (one-time) does NOT cascade", () => { const emitted: AssistantEvent[] = []; const conversationObj = makeConversation( (msg) => emitted.push(msg), CONV_ID, ); seedPendingConfirmation(conversationObj, "req-1"); seedPendingConfirmation(conversationObj, "req-2"); registerPendingInteraction( "req-1", CONV_ID, makeConfirmationDetails(["bash:echo hello"]), ); registerPendingInteraction( "req-2", CONV_ID, makeConfirmationDetails(["bash:echo world"]), ); conversationObj.handleConfirmationResponse("req-1", "deny"); const confirmMsgs = emitted.filter( (m) => m.type === "confirmation_state_changed" && (m as unknown as ConfirmationStateChangedEvent).state === "denied", ) as unknown as ConfirmationStateChangedEvent[]; // Only the primary should be denied expect(confirmMsgs).toHaveLength(1); expect(confirmMsgs[0].requestId).toBe("req-1"); }); test("already-resolved request handled gracefully", () => { const emitted: AssistantEvent[] = []; const conversationObj = makeConversation( (msg) => emitted.push(msg), CONV_ID, ); seedPendingConfirmation(conversationObj, "req-primary"); seedPendingConfirmation(conversationObj, "req-stale"); registerPendingInteraction( "req-primary", CONV_ID, makeConfirmationDetails(["bash:echo primary"]), ); // Register in pending-interactions but with a request ID that exists // in the prompter. We'll remove it from the prompter before cascading // reaches it to simulate a stale/already-resolved request. registerPendingInteraction( "req-stale", CONV_ID, makeConfirmationDetails(["bash:echo stale"]), ); // Remove req-stale from the prompter's ownedIds (simulating it was // already resolved by another path before cascade reaches it) const prompter = conversationObj["prompter"] as unknown as { ownedIds: Set; }; prompter.ownedIds.delete("req-stale"); // This should not throw — cascade should skip req-stale gracefully expect(() => { conversationObj.handleConfirmationResponse("req-primary", "allow"); }).not.toThrow(); // Only the primary should be resolved const confirmMsgs = emitted.filter( (m) => m.type === "confirmation_state_changed" && (m as unknown as ConfirmationStateChangedEvent).state === "approved", ) as unknown as ConfirmationStateChangedEvent[]; expect(confirmMsgs).toHaveLength(1); expect(confirmMsgs[0].requestId).toBe("req-primary"); }); });