import { beforeEach, describe, expect, mock, test } from "bun:test"; import type { AssistantEvent } from "../api/index.js"; let broadcastedMessages: AssistantEvent[] = []; const realEventHub = await import("../runtime/assistant-event-hub.js"); mock.module("../runtime/assistant-event-hub.js", () => ({ ...realEventHub, broadcastMessage: (msg: AssistantEvent) => broadcastedMessages.push(msg), })); // These surfaces are shown live and asserted through their client messages, so // history holds nothing. Stand in an empty store: the completion path reads // persisted `ui_surface` blocks, and this file's DB carries no schema, so the // real read would throw and be indistinguishable from a persistence failure. const realCrud = await import("../persistence/conversation-crud.js"); mock.module("../persistence/conversation-crud.js", () => ({ ...realCrud, getMessages: () => [], })); const { createSurfaceMutex, handleSurfaceAction, surfaceProxyResolver } = await import("../daemon/conversation-surfaces.js"); import type { Conversation } from "../daemon/conversation.js"; import type { SurfaceType, UiSurfaceShow } from "../daemon/message-protocol.js"; import type { UserMessageAttachment } from "../daemon/message-types/shared.js"; import { asConversation } from "./helpers/mock-conversation.js"; interface ProcessMessageCall { content: string; attachments: UserMessageAttachment[]; requestId?: string; activeSurfaceId?: string; displayContent?: string; sourceActorPrincipalId?: string; } function makeContext(sent: AssistantEvent[] = []): Conversation & { processMessageCalls: ProcessMessageCall[]; } { const processMessageCalls: ProcessMessageCall[] = []; return asConversation({ conversationId: "conv-1", sendToClient: (msg: AssistantEvent) => sent.push(msg), emit: (msg: AssistantEvent) => sent.push(msg), pendingSurfaceActions: new Map(), lastSurfaceAction: new Map< string, { actionId: string; data?: Record } >(), surfaceState: new Map(), surfaceUndoStacks: new Map(), accumulatedSurfaceState: new Map>(), surfaceActionRequestIds: new Set(), currentTurnSurfaces: [], isProcessing: () => false, enqueueMessage: () => ({ queued: false, requestId: "req-1" }), getQueueDepth: () => 0, processMessage: async (options) => { processMessageCalls.push({ content: options.content, attachments: options.attachments, requestId: options.requestId, activeSurfaceId: options.activeSurfaceId, displayContent: options.displayContent, sourceActorPrincipalId: options.sourceActorPrincipalId, }); return "msg-1"; }, withSurface: createSurfaceMutex(), processMessageCalls, }); } describe("surface action delivery to assistant", () => { beforeEach(() => { broadcastedMessages = []; }); test("table action button click triggers processMessage with action content", async () => { const sent: AssistantEvent[] = []; const ctx = makeContext(sent); // Step 1: Show a table surface with actions const showResult = await surfaceProxyResolver(ctx, "ui_show", { surface_type: "table", title: "Newsletters", data: { columns: [ { id: "sender", label: "Sender" }, { id: "count", label: "Count" }, ], rows: [ { id: "row-1", cells: { sender: "Newsletter A", count: "5" } }, { id: "row-2", cells: { sender: "Newsletter B", count: "3" } }, ], selectionMode: "multiple", }, actions: [ { id: "archive", label: "Archive", style: "primary" }, { id: "unsubscribe", label: "Unsubscribe", style: "destructive" }, ], }); expect(showResult.isError).toBe(false); expect(showResult.yieldToUser).toBe(true); // Verify surface was shown and pending action was registered const showMessage = sent.find( (msg): msg is UiSurfaceShow => msg.type === "ui_surface_show", ) as UiSurfaceShow; expect(showMessage).toBeDefined(); const surfaceId = showMessage.surfaceId; expect(ctx.pendingSurfaceActions.has(surfaceId)).toBe(true); expect(ctx.surfaceState.has(surfaceId)).toBe(true); // Step 2: Simulate user clicking "Archive" with selected rows const actionData = { selectedIds: ["row-1", "row-2"], }; await handleSurfaceAction(ctx, surfaceId, "archive", actionData); // Step 3: Verify processMessage was called expect(ctx.processMessageCalls.length).toBe(1); const call = ctx.processMessageCalls[0]; expect(call.content).toContain("[User action on table surface:"); expect(call.content).toContain("archive"); expect(call.content).toContain("selectedIds"); expect(call.content).toContain("row-1"); expect(call.content).toContain("row-2"); expect(call.activeSurfaceId).toBe(surfaceId); // Verify pending action was cleared expect(ctx.pendingSurfaceActions.has(surfaceId)).toBe(false); // Verify the requestId was tracked as a surface action expect(ctx.surfaceActionRequestIds.size).toBe(1); }); test("idle pending follow-up path threads submitter principal into processMessage", async () => { const sent: AssistantEvent[] = []; const ctx = makeContext(sent); await surfaceProxyResolver(ctx, "ui_show", { surface_type: "table", title: "Items", data: { columns: [{ id: "name", label: "Name" }], rows: [{ id: "r1", cells: { name: "Item 1" } }], }, actions: [{ id: "archive", label: "Archive" }], }); const showMessage = sent.find( (msg): msg is UiSurfaceShow => msg.type === "ui_surface_show", ) as UiSurfaceShow; const surfaceId = showMessage.surfaceId; await handleSurfaceAction( ctx, surfaceId, "archive", { selectedIds: ["r1"] }, "principal-committer", ); expect(ctx.processMessageCalls.length).toBe(1); expect(ctx.processMessageCalls[0].sourceActorPrincipalId).toBe( "principal-committer", ); }); test("idle history-restored path threads submitter principal into processMessage", async () => { const sent: AssistantEvent[] = []; const ctx = makeContext(sent); // History-restored surface: surfaceState exists, pendingSurfaceActions // does not — exercises the immediate (idle) fallthrough. ctx.surfaceState.set("hist-surface-p", { surfaceType: "table", data: { columns: [{ id: "col", label: "Col" }], rows: [], }, title: "History Table", actions: [{ id: "delete", label: "Delete" }], }); await handleSurfaceAction( ctx, "hist-surface-p", "delete", { selectedIds: ["row-1"] }, "principal-committer", ); expect(ctx.processMessageCalls.length).toBe(1); expect(ctx.processMessageCalls[0].sourceActorPrincipalId).toBe( "principal-committer", ); }); test("table action without selection data still triggers processMessage", async () => { const sent: AssistantEvent[] = []; const ctx = makeContext(sent); // Show table surface await surfaceProxyResolver(ctx, "ui_show", { surface_type: "table", title: "Emails", data: { columns: [{ id: "subject", label: "Subject" }], rows: [{ id: "r1", cells: { subject: "Hello" } }], }, actions: [{ id: "archive", label: "Archive" }], }); const showMessage = sent.find( (msg): msg is UiSurfaceShow => msg.type === "ui_surface_show", ) as UiSurfaceShow; const surfaceId = showMessage.surfaceId; // Click action WITHOUT selection data (data is undefined) await handleSurfaceAction(ctx, surfaceId, "archive", undefined); // processMessage must still be called expect(ctx.processMessageCalls.length).toBe(1); expect(ctx.processMessageCalls[0].content).toContain( "[User action on table surface:", ); }); test("history-restored relay action uses stored prompt data and can complete the surface", async () => { const ctx = makeContext(); const surfaceId = "max-token-surface"; ctx.surfaceState.set(surfaceId, { surfaceType: "card", data: { title: "Response limit reached", body: "Continue from where the assistant stopped.", }, actions: [ { id: "relay_prompt", label: "Continue", style: "primary", data: { prompt: "Continue from where you stopped.", _completeSurface: true, _completionSummary: "Continue", }, }, ], }); await handleSurfaceAction(ctx, surfaceId, "relay_prompt"); expect(ctx.processMessageCalls).toHaveLength(1); expect(ctx.processMessageCalls[0]!.content).toBe( "Continue from where you stopped.", ); expect( broadcastedMessages.some( (msg) => msg.type === "ui_surface_complete" && msg.surfaceId === surfaceId && msg.summary === "Continue", ), ).toBe(true); expect( broadcastedMessages.some( (msg) => msg.type === "user_message_echo" && msg.text === "Continue from where you stopped.", ), ).toBe(true); }); test("action on history-restored surface (no pending) still processes", async () => { const sent: AssistantEvent[] = []; const ctx = makeContext(sent); // Simulate a history-restored surface: surfaceState exists, but // pendingSurfaceActions does NOT have an entry. ctx.surfaceState.set("hist-surface-1", { surfaceType: "table", data: { columns: [{ id: "col", label: "Col" }], rows: [], }, title: "History Table", actions: [{ id: "delete", label: "Delete" }], }); // Click the action — should go through the history-restored path await handleSurfaceAction(ctx, "hist-surface-1", "delete", { selectedIds: ["row-1"], }); // processMessage should still be called expect(ctx.processMessageCalls.length).toBe(1); expect(ctx.processMessageCalls[0].content).toContain( "[User action on app:", ); }); test("confirmation surface broadcasts ui_surface_complete on action", async () => { const sent: AssistantEvent[] = []; const ctx = makeContext(sent); const showResult = await surfaceProxyResolver(ctx, "ui_show", { surface_type: "confirmation", title: "Delete files?", data: { message: "This will permanently delete 3 files.", confirmLabel: "Delete", cancelLabel: "Keep", }, }); expect(showResult.isError).toBe(false); expect(showResult.yieldToUser).toBe(true); const showMessage = sent.find( (msg): msg is UiSurfaceShow => msg.type === "ui_surface_show", ) as UiSurfaceShow; const surfaceId = showMessage.surfaceId; expect(ctx.pendingSurfaceActions.has(surfaceId)).toBe(true); await handleSurfaceAction(ctx, surfaceId, "confirm", {}); const completeMsg = broadcastedMessages.find( (m) => (m as unknown as Record).type === "ui_surface_complete" && (m as unknown as Record).surfaceId === surfaceId, ) as unknown as Record | undefined; expect(completeMsg).toBeDefined(); expect(completeMsg?.conversationId).toBe("conv-1"); expect(completeMsg?.summary).toContain("Delete"); }); test("file_upload surface broadcasts ui_surface_complete on action", async () => { const sent: AssistantEvent[] = []; const ctx = makeContext(sent); const showResult = await surfaceProxyResolver(ctx, "ui_show", { surface_type: "file_upload", title: "Upload documents", data: { accept: ".pdf,.docx", maxFiles: 5 }, }); expect(showResult.isError).toBe(false); expect(showResult.yieldToUser).toBe(true); const showMessage = sent.find( (msg): msg is UiSurfaceShow => msg.type === "ui_surface_show", ) as UiSurfaceShow; const surfaceId = showMessage.surfaceId; expect(ctx.pendingSurfaceActions.has(surfaceId)).toBe(true); await handleSurfaceAction(ctx, surfaceId, "submit", { files: [ { filename: "doc.pdf", mimeType: "application/pdf", data: "base64encodedcontent", }, ], }); const completeMsg = broadcastedMessages.find( (m) => (m as unknown as Record).type === "ui_surface_complete" && (m as unknown as Record).surfaceId === surfaceId, ) as unknown as Record | undefined; expect(completeMsg).toBeDefined(); expect(completeMsg?.conversationId).toBe("conv-1"); }); test("file_upload completion event does not include base64 file blobs", async () => { const sent: AssistantEvent[] = []; const ctx = makeContext(sent); await surfaceProxyResolver(ctx, "ui_show", { surface_type: "file_upload", title: "Upload", data: { accept: "*" }, }); const showMessage = sent.find( (msg): msg is UiSurfaceShow => msg.type === "ui_surface_show", ) as UiSurfaceShow; const surfaceId = showMessage.surfaceId; const largeBase64 = "A".repeat(10_000); await handleSurfaceAction(ctx, surfaceId, "submit", { files: [ { filename: "big.pdf", mimeType: "application/pdf", data: largeBase64, }, ], }); const completeMsg = broadcastedMessages.find( (m) => (m as unknown as Record).type === "ui_surface_complete" && (m as unknown as Record).surfaceId === surfaceId, ) as unknown as Record | undefined; expect(completeMsg).toBeDefined(); const submittedData = completeMsg?.submittedData as | Record | undefined; // The files array with base64 blobs should be stripped from the // completion event — only the sanitized payload (without files) is sent. expect(submittedData?.files).toBeUndefined(); // The raw base64 content should not appear anywhere in the event expect(JSON.stringify(completeMsg)).not.toContain(largeBase64); }); test("choice surface broadcasts ui_surface_complete on action", async () => { const sent: AssistantEvent[] = []; const ctx = makeContext(sent); const showResult = await surfaceProxyResolver(ctx, "ui_show", { surface_type: "choice", title: "Pick an outcome", data: { options: [ { id: "inbox", title: "Clean up my inbox" }, { id: "calendar", title: "Plan my week" }, ], }, }); expect(showResult.isError).toBe(false); expect(showResult.yieldToUser).toBe(true); const showMessage = sent.find( (msg): msg is UiSurfaceShow => msg.type === "ui_surface_show", ) as UiSurfaceShow; const surfaceId = showMessage.surfaceId; expect(ctx.pendingSurfaceActions.has(surfaceId)).toBe(true); await handleSurfaceAction(ctx, surfaceId, "inbox", { choiceId: "inbox", choiceTitle: "Clean up my inbox", selectedIds: ["inbox"], selectedTitles: ["Clean up my inbox"], }); const completeMsg = broadcastedMessages.find( (m) => (m as unknown as Record).type === "ui_surface_complete" && (m as unknown as Record).surfaceId === surfaceId, ) as unknown as Record | undefined; expect(completeMsg).toBeDefined(); expect(completeMsg?.conversationId).toBe("conv-1"); expect(completeMsg?.summary).toBe('User chose: "Clean up my inbox"'); expect(ctx.pendingSurfaceActions.has(surfaceId)).toBe(false); }); test("oauth_connect surface broadcasts ui_surface_complete on action", async () => { const sent: AssistantEvent[] = []; const ctx = makeContext(sent); const showResult = await surfaceProxyResolver(ctx, "ui_show", { surface_type: "oauth_connect", title: "Connect Google", data: { providerKey: "google", displayName: "Google", }, }); expect(showResult.isError).toBe(false); expect(showResult.yieldToUser).toBe(true); const showMessage = sent.find( (msg): msg is UiSurfaceShow => msg.type === "ui_surface_show", ) as UiSurfaceShow; const surfaceId = showMessage.surfaceId; expect(ctx.pendingSurfaceActions.has(surfaceId)).toBe(true); await handleSurfaceAction(ctx, surfaceId, "connect", { status: "connected", providerKey: "google", providerLabel: "Google", accountLabel: "user@example.com", }); const completeMsg = broadcastedMessages.find( (m) => (m as unknown as Record).type === "ui_surface_complete" && (m as unknown as Record).surfaceId === surfaceId, ) as unknown as Record | undefined; expect(completeMsg).toBeDefined(); expect(completeMsg?.conversationId).toBe("conv-1"); expect(completeMsg?.summary).toBe("Connected Google: user@example.com"); expect(ctx.pendingSurfaceActions.has(surfaceId)).toBe(false); expect(ctx.processMessageCalls).toHaveLength(1); }); test("table surface does NOT broadcast ui_surface_complete (not one-shot)", async () => { const sent: AssistantEvent[] = []; const ctx = makeContext(sent); await surfaceProxyResolver(ctx, "ui_show", { surface_type: "table", title: "Items", data: { columns: [{ id: "name", label: "Name" }], rows: [{ id: "r1", cells: { name: "Item 1" } }], }, actions: [{ id: "select", label: "Select" }], }); const showMessage = sent.find( (msg): msg is UiSurfaceShow => msg.type === "ui_surface_show", ) as UiSurfaceShow; const surfaceId = showMessage.surfaceId; broadcastedMessages = []; await handleSurfaceAction(ctx, surfaceId, "select", { selectedIds: ["r1"], }); const completeMsg = broadcastedMessages.find( (m) => (m as unknown as Record).type === "ui_surface_complete", ); expect(completeMsg).toBeUndefined(); }); });