import { afterEach, describe, expect, it, vi } from "vitest"; import * as journal from "./message-db.ts"; import { cancelChannelMessage, drainChannelMessages, queuedChannelMessage, queueChannelMessage, restoreChannelMessages } from "./channel-queue.ts"; import { cancelSteeredMessage, drainSteeredMessages, queuedSteerSnapshot, queueSteeredMessage, restoreSteeredMessages, type SteerStore } from "./steer-queue.ts"; import type { BotRecord, Message } from "./store.ts"; afterEach(() => { vi.restoreAllMocks(); journal.settleChatFollowups(journal.chatFollowups().map((row) => row.id), null); restoreSteeredMessages(); restoreChannelMessages(); }); function storeFor(botId: string, threadId: string): SteerStore & { messages: Message[] } { const messages: Message[] = []; return { messages, bot: (id) => id === botId ? { id, threadId, busy: false } as BotRecord : null, appendMessage: (_thread, message) => { const saved = { ...message, id: `message-${messages.length}`, at: Date.now() }; messages.push(saved); return saved; }, patchMessage: () => null, }; } describe("durable accepted follow-ups", () => { it("restores bot order, receipt, attachments, reply context, unattended provenance and who to bill", async () => { const text = 'inspect this\n\n'; const first = queueSteeredMessage("bot", "thread", text, { prompt: `Reply context\n${text}`, replyToId: "reply", sendId: "first", reason: "capacity", unattended: true, dataContext: { cardId: "c_1", draftSql: "select 1" }, sender: { name: "ada@example.test" }, trigger: { kind: "user", email: "ada@example.test", label: "Ada's laptop" }, }); const second = queueSteeredMessage("bot", "thread", "then summarize", { sendId: "second" }); const cancelled = queueSteeredMessage("bot", "thread", "never run", { sendId: "cancelled" }); expect(cancelSteeredMessage("bot", cancelled.id)).toBe(true); journal.closeMessageDb(); restoreSteeredMessages(); expect(queuedSteerSnapshot(() => true)).toEqual({ thread: [ { queueId: first.id, text, reason: "capacity" }, { queueId: second.id, text: "then summarize" }, ] }); const store = storeFor("bot", "thread"); // the durable row flips to dispatching before its turn runs; the // expected set changes with each drain below let dispatchingNow: string[] = []; const run = vi.fn(() => { expect(journal.chatFollowups().filter((row) => row.status === "dispatching").map((row) => row.id)) .toEqual(dispatchingNow); }); // The first line is the bot's own unattended work, the second a person's words: different provenance never merges. dispatchingNow = [first.id]; drainSteeredMessages(store, run); expect(run.mock.calls[0]).toEqual([ "bot", "thread", `Reply context\n${text}`, store.messages[0], ["message-0"], true, { trigger: { kind: "user", email: "ada@example.test", label: "Ada's laptop" }, sender: { name: "ada@example.test" }, peerAsk: undefined }, ]); expect(store.messages).toEqual([ expect.objectContaining({ text, replyToId: "reply", sendId: "first", queueId: first.id, dataContext: { cardId: "c_1", draftSql: "select 1" } }), ]); // let the first turn's durable row settle before the next drain reads it await Promise.resolve(); dispatchingNow = [second.id]; drainSteeredMessages(store, run); expect(run.mock.calls[1]).toEqual([ "bot", "thread", "then summarize", store.messages[1], ["message-1"], false, { trigger: undefined, sender: undefined, peerAsk: undefined }, ]); expect(store.messages).toEqual([ expect.objectContaining({ text, replyToId: "reply", sendId: "first", queueId: first.id }), expect.objectContaining({ text: "then summarize", sendId: "second", queueId: second.id }), ]); expect(store.messages[1]).not.toHaveProperty("dataContext"); await Promise.resolve(); expect(journal.chatFollowups().map((row) => row.id)).toEqual([cancelled.id]); expect(journal.cancelledChatFollowup("bot", "bot", "thread", "cancelled")).toBe(true); expect(journal.cancelledChatFollowup("bot", "other", "thread", "cancelled")).toBe(false); expect(journal.chatFollowups()[0].payload).toEqual({ text: "" }); }); it("restores channel target, mode and API provenance without reviving cancelled messages", async () => { const first = queueChannelMessage("group", "old-thread", "continue goal", { mode: "goal", via: "api", replyToId: "reply", sendId: "first", trigger: { kind: "user", label: "Ada's phone" }, }); const second = queueChannelMessage("group", "old-thread", "next", { sendId: "second" }); const cancelled = queueChannelMessage("group", "old-thread", "drop", { sendId: "cancelled" }); expect(cancelChannelMessage("group", cancelled.id)).toBe(true); journal.closeMessageDb(); restoreChannelMessages(); const run = vi.fn(); drainChannelMessages(() => false, run); expect(run).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ groupId: "group", threadId: "old-thread", items: [expect.objectContaining({ id: first.id, text: "continue goal", mode: "goal", via: "api", replyToId: "reply", sendId: "first", trigger: { kind: "user", label: "Ada's phone" }, queuedAt: expect.any(Number), })], })); expect(journal.chatFollowups().find((row) => row.id === first.id)?.status).toBe("dispatching"); drainChannelMessages(() => false, run); expect(run.mock.calls[1][0]).toMatchObject({ groupId: "group", threadId: "old-thread", items: [expect.objectContaining({ id: second.id, mode: "chat", text: "next", queuedAt: expect.any(Number) })], }); await Promise.resolve(); expect(journal.cancelledChatFollowup("channel", "group", "old-thread", "cancelled")).toBe(true); }); it("runs the person's messages ahead of a teammate's, a steered row stopped before it was read as itself, and never shows that row as queued", async () => { const store = storeFor("bot", "thread"); const handOn = queueSteeredMessage("bot", "thread", "Nightly note", { peerAsk: { botId: "chief", name: "Chief" } }); const steered = store.appendMessage("thread", { role: "user", kind: "text", text: "is the PR up?", steered: true }); // Sent a moment later, while the turn still ran: a runner can be slow enough for the clock to move. await new Promise((resolve) => setTimeout(resolve, 5)); const queued = queueSteeredMessage("bot", "thread", "and the docs?", { sendId: "later", first: true }); queueSteeredMessage("bot", "thread", steered.text!, { prompt: "is the PR up?\n\n[note]", rowId: steered.id, first: true, queuedAt: steered.at }); expect(queuedSteerSnapshot(() => true)).toEqual({ thread: [{ queueId: queued.id, text: "and the docs?" }, { queueId: handOn.id, text: "Nightly note" }] }); const run = vi.fn(); const messagesFor = () => store.messages; drainSteeredMessages({ ...store, messagesFor }, run); // Both are the person's words, so they run as one turn, the stopped one first and as its own row, before the teammate's. expect(run.mock.calls[0]?.[2]).toBe("is the PR up?\n\n[note]\n\nand the docs?"); expect(run.mock.calls[0]?.[4]).toEqual([steered.id, "message-1"]); expect(store.messages.map((message) => message.text)).toEqual(["is the PR up?", "and the docs?"]); expect(store.messages[1]).toMatchObject({ queueId: queued.id }); await Promise.resolve(); drainSteeredMessages({ ...store, messagesFor }, run); expect(run.mock.calls[1]?.[2]).toBe("Nightly note"); }); it("keeps running a stopped steered row as itself across a restart", () => { const store = storeFor("bot", "thread"); const steered = store.appendMessage("thread", { role: "user", kind: "text", text: "is the PR up?", steered: true }); queueSteeredMessage("bot", "thread", steered.text!, { prompt: "is the PR up?\n\n[note]", rowId: steered.id, first: true }); journal.closeMessageDb(); restoreSteeredMessages(); const run = vi.fn(); drainSteeredMessages({ ...store, messagesFor: () => store.messages }, run); expect(run.mock.calls[0]?.slice(2, 5)).toEqual(["is the PR up?\n\n[note]", steered, [steered.id]]); expect(store.messages).toHaveLength(1); }); it("does not publish or cancel in memory when its durable write fails", () => { const saved = queueSteeredMessage("bot", "thread", "keep"); const save = vi.spyOn(journal, "saveChatFollowup").mockImplementation(() => { throw new Error("disk full"); }); expect(() => queueSteeredMessage("bot", "thread", "not accepted")).toThrow("disk full"); expect(() => queueChannelMessage("group", "channel", "not accepted")).toThrow("disk full"); save.mockRestore(); const channel = queueChannelMessage("group", "channel", "keep channel", { sendId: "channel-send" }); const settle = vi.spyOn(journal, "settleChatFollowups").mockImplementation(() => { throw new Error("disk full"); }); expect(() => cancelSteeredMessage("bot", saved.id)).toThrow("disk full"); expect(() => cancelChannelMessage("group", channel.id)).toThrow("disk full"); expect(queuedSteerSnapshot(() => true)).toEqual({ thread: [{ queueId: saved.id, text: "keep" }] }); const store = storeFor("bot", "thread"); const run = vi.fn(); expect(() => drainSteeredMessages(store, run)).toThrow("disk full"); expect(() => drainChannelMessages(() => false, run)).toThrow("disk full"); expect(queuedChannelMessage("group", "channel", "channel-send")?.id).toBe(channel.id); expect(store.messages).toEqual([]); expect(run).not.toHaveBeenCalled(); settle.mockRestore(); }); it("settles failed dispatches before restoring durable follow-ups", async () => { const bot = queueSteeredMessage("bot", "thread", "possibly executed"); const channel = queueChannelMessage("group", "channel", "possibly executed"); expect(() => drainSteeredMessages(storeFor("bot", "thread"), () => { throw new Error("lost dispatch"); })).not.toThrow(); drainChannelMessages(() => false, () => Promise.reject(new Error("lost channel dispatch"))); await Promise.resolve(); journal.closeMessageDb(); restoreSteeredMessages(); restoreChannelMessages(); const run = vi.fn(); drainSteeredMessages(storeFor("bot", "thread"), run); drainChannelMessages(() => false, run); expect(run).not.toHaveBeenCalled(); expect(journal.chatFollowups()).toEqual([ expect.objectContaining({ id: bot.id, status: "interrupted" }), expect.objectContaining({ id: channel.id, status: "interrupted" }), ]); }); it("never restores an unsettled claim whose dispatch may have started", async () => { const bot = queueSteeredMessage("bot", "thread", "possibly executed"); const channel = queueChannelMessage("group", "channel", "possibly executed"); const stillRunning = () => new Promise(() => {}); drainSteeredMessages(storeFor("bot", "thread"), stillRunning); drainChannelMessages(() => false, stillRunning); await Promise.resolve(); journal.closeMessageDb(); restoreSteeredMessages(); restoreChannelMessages(); const run = vi.fn(); drainSteeredMessages(storeFor("bot", "thread"), run); drainChannelMessages(() => false, run); expect(run).not.toHaveBeenCalled(); expect(journal.chatFollowups()).toEqual([ expect.objectContaining({ id: bot.id, status: "dispatching" }), expect.objectContaining({ id: channel.id, status: "dispatching" }), ]); }); it("deleting a thread deletes pending work and cancellation tombstones", () => { queueSteeredMessage("bot", "thread", "pending"); const cancelled = queueChannelMessage("group", "thread", "cancelled", { sendId: "old-id" }); cancelChannelMessage("group", cancelled.id); journal.deleteThread("thread"); journal.closeMessageDb(); restoreSteeredMessages(); restoreChannelMessages(); expect(journal.chatFollowups()).toEqual([]); expect(queuedSteerSnapshot(() => true)).toEqual({}); const run = vi.fn(); drainChannelMessages(() => false, run); expect(run).not.toHaveBeenCalled(); }); });