import { buildSuggestionCard, withSuggestionStatus } from "@rakazo/core"; import { describe, expect, it } from "vitest"; import { buildInboxItems, type InboxMessageRow, type InboxRunRow, type InboxSuggestionRow, 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, spaceId = "space-home", ): InboxRunRow { return { id, spaceId, 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 }, spaceId = "space-home", ): InboxUnreadThreadRow { return { spaceId, 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() }); }); it("lists asks from another space's main conversation, but not the open space's own", () => { const project = { id: "space-launch", name: "Launch" }; const main = (groupId: string) => ({ botId: null, groupId, group: { kind: "main", archivedAt: null }, }); const items = buildInboxItems({ runs: [ run("home-main", "waiting_input", main("group-main-home")), run("project-main", "waiting_input", main("group-main-launch"), writer, project.id), ], messages: [ message("ask-home", "home-main", [question], 1), message("ask-project", "project-main", [question], 2), ], unreadThreads: [], otherSpaces: new Map([[project.id, project]]), }); expect(items).toHaveLength(1); expect(items[0]).toMatchObject({ runId: "project-main", space: project, target: { groupId: "group-main-launch" }, }); }); it("names the space of items from the user's other spaces", () => { const side = { id: "space-side", name: "Side project" }; const items = buildInboxItems({ runs: [ run("run-home", "waiting_input"), run("run-side", "waiting_input", {}, writer, side.id), run("takeover-side", "waiting_takeover", {}, writer, side.id), ], messages: [ message("ask-home", "run-home", [question], 1), message("ask-side", "run-side", [question], 2), ], unreadThreads: [ unreadThread( { id: "bot-quiet", name: "Quiet", color: "#3EC5A8" }, { id: "quiet-latest", text: "Done", minute: 3 }, side.id, ), ], otherSpaces: new Map([[side.id, side]]), }); expect(items.map((item) => [item.kind, item.space?.name ?? null])).toEqual([ ["takeover", "Side project"], ["ask", null], ["ask", "Side project"], ["unread", "Side project"], ]); expect(items[1]).not.toHaveProperty("space"); expect(items[2]).toMatchObject({ space: side, target: { botId: writer.id } }); }); it("lists an open suggestion with its card, and drops it once settled", () => { const card = buildSuggestionCard({ id: "effect-9", effectId: "effect-9", tool: "schedule_create", args: { name: "Morning walk", prompt: "Take a walk." }, }); const row = (blocks: unknown[]): InboxSuggestionRow => ({ spaceId: "space-home", createdAt: at(3), bot: scout, thread: { botId: scout.id, groupId: null, group: null }, messageId: "msg-card", cardId: "effect-9", blocks, }); const items = buildInboxItems({ runs: [], messages: [], unreadThreads: [unreadThread(scout, { id: "msg-card", text: "", minute: 3 })], suggestions: [row([card])], }); expect(items).toEqual([ { kind: "suggestion", bot: scout, target: { botId: scout.id }, messageId: "msg-card", card, createdAt: at(3).toISOString(), }, ]); expect( buildInboxItems({ runs: [], messages: [], unreadThreads: [], suggestions: [row([withSuggestionStatus(card, "dismissed")])], }), ).toEqual([]); }); });