import { describe, expect, it } from "vitest"; import { buildInboxItems, type InboxMessageRow, type InboxRunRow, type InboxUnreadThreadRow, } from "./inbox.js"; const scout = { id: "bot-scout", name: "Scout", color: "#3B82F6" }; const writer = { id: "bot-writer", name: "Writer", color: "#D9508A" }; function at(minute: number): Date { return new Date(Date.UTC(2026, 9, 2, 9, minute)); } function run( id: string, status: "waiting_input" | "waiting_takeover", thread: Partial = {}, bot = scout, ): InboxRunRow { return { id, status, updatedAt: at(0), bot, thread: { botId: bot.id, groupId: null, group: null, ...thread }, }; } function message(id: string, runId: string, blocks: unknown[], minute: number): InboxMessageRow { return { id, runId, blocks, createdAt: at(minute) }; } const question = { kind: "ask", text: "Which city should I use?", status: "pending" } as const; const approval = { kind: "ask", text: "Review before writing to notes", approvalEffectId: "effect-1", status: "pending", actions: [ { id: "allow", label: "Allow once" }, { id: "always", label: "Always allow this tool" }, { id: "deny", label: "Deny" }, ], } as const; function unreadThread( bot: InboxUnreadThreadRow["bot"], latest?: { id: string; text: string; minute: number }, ): InboxUnreadThreadRow { return { createdAt: at(0), bot, messages: latest ? [ { id: latest.id, blocks: [{ kind: "text", text: latest.text }], createdAt: at(latest.minute), }, ] : [], }; } describe("buildInboxItems", () => { it("lists a pending ask with the asking bot as sender and its own thread as target", () => { const items = buildInboxItems({ runs: [run("run-1", "waiting_input")], messages: [message("msg-1", "run-1", [{ kind: "text", text: "one moment" }, question], 5)], unreadThreads: [], }); expect(items).toEqual([ { kind: "ask", bot: scout, target: { botId: scout.id }, runId: "run-1", messageId: "msg-1", ask: question, createdAt: at(5).toISOString(), }, ]); }); it("marks approval asks as approvals", () => { const items = buildInboxItems({ runs: [run("run-1", "waiting_input")], messages: [message("msg-1", "run-1", [approval], 1)], unreadThreads: [], }); expect(items.map((item) => item.kind)).toEqual(["approval"]); }); it("answers group asks through the group and leaves out ones it cannot answer from here", () => { const items = buildInboxItems({ runs: [ run("in-main", "waiting_input", { botId: null, groupId: "group-main", group: { kind: "main", archivedAt: null }, }), run("in-group", "waiting_input", { botId: null, groupId: "group-ops", group: { kind: "group", archivedAt: null }, }), run("in-archived", "waiting_input", { botId: null, groupId: "group-old", group: { kind: "group", archivedAt: at(0) }, }), run("in-external", "waiting_input", { botId: null }), ], messages: ["in-main", "in-group", "in-archived", "in-external"].map((runId, index) => message(`msg-${runId}`, runId, [question], index), ), unreadThreads: [], }); expect(items).toHaveLength(1); expect(items[0]).toMatchObject({ runId: "in-group", target: { groupId: "group-ops" } }); }); it("uses the newest pending ask and skips runs without one", () => { const items = buildInboxItems({ runs: [run("run-1", "waiting_input"), run("run-2", "waiting_input", {}, writer)], messages: [ message("newest", "run-1", [{ ...question, text: "And the date?" }], 9), message("older", "run-1", [{ ...question, status: "answered", answer: "Paris" }], 4), message("answered", "run-2", [{ ...question, status: "answered", answer: "Rome" }], 3), message("malformed", "run-2", [{ kind: "ask" }], 8), ], unreadThreads: [], }); expect(items).toHaveLength(1); expect(items[0]).toMatchObject({ messageId: "newest", ask: { text: "And the date?" } }); }); it("sends takeovers to the bot's own thread, including runs in the main conversation", () => { const items = buildInboxItems({ runs: [ run("run-1", "waiting_takeover", { botId: null, groupId: "group-main", group: { kind: "main", archivedAt: null }, }), run("run-2", "waiting_takeover", {}, writer), ], messages: [ message( "msg-1", "run-1", [{ kind: "computer", state: "Needs you", text: "Sign in to continue." }], 2, ), ], unreadThreads: [], }); expect(items).toEqual([ { kind: "takeover", bot: writer, target: { botId: writer.id }, runId: "run-2", messageId: null, text: "", createdAt: at(0).toISOString(), }, { kind: "takeover", bot: scout, target: { botId: scout.id }, runId: "run-1", messageId: "msg-1", text: "Sign in to continue.", createdAt: at(2).toISOString(), }, ]); }); it("adds unread threads after waiting items unless the bot already waits in that thread", () => { const items = buildInboxItems({ runs: [ run("run-1", "waiting_input"), run( "run-2", "waiting_input", { botId: null, groupId: "group-ops", group: { kind: "group", archivedAt: null }, }, writer, ), ], messages: [ message("ask-1", "run-1", [question], 7), message("ask-2", "run-2", [question], 8), ], unreadThreads: [ unreadThread(scout, { id: "scout-latest", text: "covered by the ask", minute: 7 }), unreadThread(writer, { id: "writer-latest", text: "Draft ready\nfor review", minute: 1 }), unreadThread({ id: "bot-quiet", name: "Quiet", color: "#3EC5A8" }), unreadThread(null, { id: "orphan", text: "no bot", minute: 1 }), ], }); expect(items.map((item) => [item.kind, item.bot.name])).toEqual([ ["ask", "Scout"], ["ask", "Writer"], ["unread", "Quiet"], ["unread", "Writer"], ]); expect(items[3]).toEqual({ kind: "unread", bot: writer, target: { botId: writer.id }, messageId: "writer-latest", text: "Draft ready for review", createdAt: at(1).toISOString(), }); expect(items[2]).toMatchObject({ messageId: null, text: "", createdAt: at(0).toISOString() }); }); });