import { rmSync } from "node:fs"; import { beforeEach, describe, expect, it } from "vitest"; import type { RoutineRunReport } from "../shared/routines.ts"; import "./server-line-packs.ts"; import { automationContextText, createAutomationReports, newestAutomationLines, readRunReply, recoveredText } from "./automation-reports.ts"; import { DATA_DIR } from "./config.ts"; import type { RoutineRun } from "./routines.ts"; import { Store, type BotRecord, type Message } from "./store.ts"; const selection = () => ({ instanceId: "claude", model: "claude-sonnet-5" }); describe("readRunReply", () => { it.each(["NOTHING NEW", "Nothing new.", "**NOTHING NEW**", "Checked 14 emails.\n\nNOTHING NEW", " nothing new "])("keeps %j quiet", (text) => { expect(readRunReply(text)).toEqual({ quiet: true }); }); it("reports anything else, including a sentence that only mentions nothing new", () => { expect(readRunReply("Acme replied about the order.")).toEqual({ quiet: false, text: "Acme replied about the order." }); expect(readRunReply("There is nothing new from Acme, but Bob wrote twice.")).toMatchObject({ quiet: false }); }); it("is quiet when a run said nothing at all, unless it attached something", () => { expect(readRunReply(" ")).toEqual({ quiet: true }); expect(readRunReply("", true)).toEqual({ quiet: false, text: "" }); }); it("turns a NEEDS INPUT line into the question the person answers", () => { expect(readRunReply("I drafted a reply to Acme.\nNEEDS INPUT: Should I send it?")).toEqual({ quiet: false, text: "I drafted a reply to Acme.\nShould I send it?", question: "Should I send it?", }); }); }); describe("what the chat's own session reads", () => { it("names and dates each line as the automation's own", () => { const line = { routineId: "r1", runId: "run", name: "Inbox brief", trigger: "schedule" as const, executionThreadId: "x", scheduledFor: 0 }; expect(automationContextText(line, Date.UTC(2026, 9, 21, 9, 0), "Acme replied.")).toMatch(/^\[From routine "Inbox brief" · run, reported at 2026-10-21 \d\d:\d\d · information only\]\nAcme replied\.$/); expect(automationContextText({ ...line, needsYou: true }, 0, "Send it?")).toContain('routine "Inbox brief" · run, asked at'); expect(automationContextText({ ...line, failed: true }, 0, "Signed out.")).toContain('routine "Inbox brief" · run, could not finish at'); expect(automationContextText({ ...line, recovered: true }, 0, "Recovered.")).toContain('routine "Inbox brief" · run, recovered at'); }); it("always carries the newest line of each automation", () => { const line = (id: string, routineId: string) => ({ id, automation: { routineId, runId: id, name: routineId, trigger: "schedule" as const, executionThreadId: "x", scheduledFor: 0 } }); expect([...newestAutomationLines([line("a1", "a"), { id: "talk" }, line("b1", "b"), line("a2", "a")])].sort()).toEqual(["a2", "b1"]); }); }); /** A real Store; the routine runs live in a stand-in for the RoutineManager, which keeps their report states. */ function fixture(options: { threadLanguage?: (threadId: string) => string | undefined } = {}) { const store = new Store(selection); const bot = store.createBot({ name: "Pepper" }); const main = bot.threadId; const runs = new Map(); const busy = new Set(); const notified: Array<{ kind: string; threadId: string; detail: string }> = []; const wrote: string[] = []; const routines = { listRuns: () => [...runs.values()].map((run) => ({ ...run })).reverse(), noteReport: (id: string, report: RoutineRunReport | undefined) => { const run = runs.get(id); if (!run) return null; if (report) run.report = report; else delete run.report; return { ...run }; }, runForThread: (threadId: string) => { const run = [...runs.values()].findLast((candidate) => candidate.threadId === threadId); return run ? { ...run } : null; }, }; let manager: typeof routines | null = routines; const create = () => createAutomationReports({ store, routines: () => manager, busy: (_botId, threadId) => busy.has(threadId), wrote: (_bot: BotRecord, threadId) => wrote.push(threadId), notify: (kind, _bot, threadId, detail) => notified.push({ kind, threadId, detail }), ...(options.threadLanguage ? { threadLanguage: options.threadLanguage } : {}), }); const reports = create(); let serial = 0; /** One run of an automation, finished in its own hidden conversation with `said` as its last reply. */ const finished = (said: string | null, name = "Inbox brief", extra: Partial = {}) => { const id = `run-${++serial}`; const threadId = store.createTask(bot.id, `${name} run`, false)!.threadId; store.patchTask(bot.id, threadId, { routineRunId: id }); if (said !== null) store.appendMessage(threadId, { role: "bot", kind: "text", text: said, turnId: `turn-${id}`, turnTerminal: true }); const run: RoutineRun = { id, routineId: `routine-${name}`, routineName: name, target: "bot", botId: bot.id, runOn: "maus", scheduledFor: Date.now(), status: "completed", manual: false, triggerSource: "schedule", threadId, resultsThreadId: main, createdAt: Date.now(), ...extra, }; runs.set(id, run); return run; }; const lines = () => store.messagesFor(main).filter((message) => message.automation); const greeting = new Set(store.messagesFor(main).map((message) => message.id)); /** Everything written into the chat since the bot said hello. */ const added = () => store.messagesFor(main).filter((message) => !greeting.has(message.id)); return { store, bot, main, runs, busy, notified, wrote, reports, finished, lines, added, create, offline: () => { manager = null; }, online: () => { manager = routines; }, }; } describe("automation reports", () => { beforeEach(() => { rmSync(DATA_DIR, { recursive: true, force: true }); }); it("writes a run's reply into the chat it reports to as one bot line named after the automation", () => { const f = fixture(); const run = f.finished("Acme replied about the order."); f.store.patchTask(f.bot.id, f.main, { unread: false }); f.reports.runSettled(run, `turn-${run.id}`); expect(f.store.taskByThread(f.bot.id, f.main)?.unread, "the chat it reports to shows unread").toBe(true); expect(f.lines()).toEqual([expect.objectContaining({ role: "bot", kind: "text", text: "Acme replied about the order.", automation: { routineId: "routine-Inbox brief", runId: run.id, name: "Inbox brief", trigger: "schedule", executionThreadId: run.threadId, scheduledFor: run.scheduledFor }, })]); expect(f.runs.get(run.id)!.report).toEqual({ state: "posted", threadId: f.main, messageId: f.lines()[0]!.id }); expect(f.wrote).toEqual([f.main]); expect(f.notified).toEqual([{ kind: "done", threadId: f.main, detail: "Inbox brief: Acme replied about the order." }]); expect(f.store.messagesFor(f.main).some((message) => message.kind === "routine.run")).toBe(false); }); it("writes nothing, marks nothing and notifies nobody for a run with nothing new", () => { const f = fixture(); const run = f.finished("Checked 14 emails.\nNOTHING NEW"); f.store.patchTask(f.bot.id, f.main, { unread: false }); f.reports.runSettled(run, `turn-${run.id}`); expect(f.added()).toEqual([]); expect(f.store.taskByThread(f.bot.id, f.main)?.unread).toBe(false); expect(f.runs.get(run.id)!.report).toEqual({ state: "quiet" }); expect(f.wrote).toEqual([]); expect(f.notified).toEqual([]); }); it("asks the person in the chat, as a normal line they can answer, when the run needs them", () => { const f = fixture(); const run = f.finished("I drafted a reply to Acme.\nNEEDS INPUT: Should I send it?"); f.reports.runSettled(run, `turn-${run.id}`); expect(f.lines()).toEqual([expect.objectContaining({ kind: "text", text: "I drafted a reply to Acme.\nShould I send it?", automation: expect.objectContaining({ needsYou: true }) })]); expect(f.notified).toEqual([{ kind: "question", threadId: f.main, detail: "Inbox brief: Should I send it?" }]); }); it("reports the last reply of the turn that finished the run, not an earlier progress note", () => { const f = fixture(); const run = f.finished("Looking at the inbox now."); f.store.appendMessage(run.threadId!, { role: "bot", kind: "text", text: "Two new orders from Acme.", turnId: "final" }); f.reports.runSettled(run, "final"); expect(f.lines().map((line) => line.text)).toEqual(["Two new orders from Acme."]); }); it("holds a report while a turn runs in the chat and writes it once, right after that turn", () => { const f = fixture(); f.busy.add(f.main); const run = f.finished("Acme replied."); f.reports.runSettled(run, `turn-${run.id}`); f.reports.flush(); expect(f.lines()).toEqual([]); expect(f.runs.get(run.id)!.report).toEqual({ state: "pending" }); expect(f.notified).toEqual([]); f.busy.delete(f.main); f.reports.flush(); expect(f.lines().map((line) => line.text)).toEqual(["Acme replied."]); f.reports.flush(); f.reports.runSettled(f.runs.get(run.id)!, `turn-${run.id}`); expect(f.lines()).toHaveLength(1); expect(f.notified).toHaveLength(1); }); it("leaves a run that began before reports existed to the status card it already has", () => { const f = fixture(); const run = f.finished("Acme replied."); const card = f.store.appendMessage(f.main, { role: "bot", kind: "routine.run", text: "Routine “Inbox brief” completed", routineRun: { runId: run.id, routineId: run.routineId, routineName: run.routineName, status: "completed" } }); f.reports.runSettled(run, `turn-${run.id}`); expect(f.lines()).toEqual([]); expect(f.runs.get(run.id)!.report).toEqual({ state: "posted", threadId: f.main, messageId: card.id }); expect(f.notified).toEqual([]); }); it("writes reports that waited on the same chat in the order their runs finished", () => { const f = fixture(); f.busy.add(f.main); for (const said of ["First result.", "Second result.", "Third result."]) { const run = f.finished(said, `Watch ${said.split(" ")[0]}`); f.reports.runSettled(run, `turn-${run.id}`); } f.busy.delete(f.main); f.reports.flush(); expect(f.lines().map((line) => line.text)).toEqual(["First result.", "Second result.", "Third result."]); }); it("writes a report that was still waiting when the server stopped once, after it starts again", () => { const f = fixture(); f.busy.add(f.main); const run = f.finished("Acme replied."); f.reports.runSettled(run, `turn-${run.id}`); f.busy.delete(f.main); const restarted = f.create(); restarted.flush(); expect(f.lines().map((line) => line.text)).toEqual(["Acme replied."]); f.runs.get(run.id)!.report = { state: "pending" }; f.create().flush(); expect(f.lines()).toHaveLength(1); expect(f.runs.get(run.id)!.report).toMatchObject({ state: "posted", messageId: f.lines()[0]!.id }); }); it("says once in the chat that a run failed, and stays out of chats a run does not report to", () => { const f = fixture(); const failed = f.finished(null, "Inbox brief", { status: "failed", error: "Signed out" }); f.reports.runFailed(failed, "This bot's engine is signed out. Sign in again in Settings → Model providers."); expect(f.lines()).toEqual([expect.objectContaining({ text: "This run didn't finish: This bot's engine is signed out. Sign in again in Settings → Model providers.", automation: expect.objectContaining({ failed: true }), })]); expect(f.notified).toEqual([]); f.reports.runFailed(f.runs.get(failed.id)!, "again"); const joined = f.finished("Picked up where I stopped.", "Continue", { threadId: f.main }); f.reports.runSettled(joined); const nowhere = f.finished("Done.", "Orphan", { resultsThreadId: undefined }); f.reports.runSettled(nowhere, `turn-${nowhere.id}`); const quietly = f.finished("NOTHING NEW", "Orphan", { resultsThreadId: undefined }); f.reports.runSettled(quietly, `turn-${quietly.id}`); expect(f.lines()).toHaveLength(1); expect(f.notified.map((entry) => entry.threadId)).toEqual([f.main, nowhere.threadId]); }); it("says a run whose reply came back but a step failed was partly done, not that it didn't finish", () => { const f = fixture(); const partly = f.finished(null, "Inbox brief", { status: "failed", error: "Stopped because the tool call failed.", partial: true }); f.reports.runFailed(partly, "Stopped because the tool call failed."); expect(f.lines()).toEqual([expect.objectContaining({ text: "This run was partly done: Stopped because the tool call failed.", automation: expect.objectContaining({ failed: true, partial: true }), })]); const broken = f.finished(null, "Other", { status: "failed", error: "Signed out" }); f.reports.runFailed(broken, "Signed out"); expect(f.lines()[1]).toMatchObject({ text: "This run didn't finish: Signed out" }); expect(f.lines()[1]!.automation).not.toHaveProperty("partial"); }); it("posts one recovered line when a run with nothing new follows a failed run, and stays quiet after that", () => { const f = fixture(); const failed = f.finished(null, "Inbox brief", { status: "failed", error: "Timed out" }); f.reports.runFailed(failed, "it ran past its 25-minute limit."); const finishedAt = Date.UTC(2026, 9, 26, 2, 40); const ok = f.finished("Checked 14 emails.\nNOTHING NEW", "Inbox brief", { finishedAt }); f.reports.runSettled(ok, `turn-${ok.id}`); for (let i = 0; i < 3; i++) { const later = f.finished("NOTHING NEW"); f.reports.runSettled(later, `turn-${later.id}`); expect(f.runs.get(later.id)!.report).toEqual({ state: "quiet" }); } expect(f.lines().map((line) => [line.text, line.automation?.runId, line.automation?.failed, line.automation?.recovered])).toEqual([ ["This run didn't finish: it ran past its 25-minute limit.", failed.id, true, undefined], [recoveredText("Inbox brief", finishedAt), ok.id, undefined, true], ]); expect(f.lines()[1]!.textCode).toBeUndefined(); expect(f.lines()[1]!.textParams).toBeUndefined(); expect(f.runs.get(ok.id)!.report).toEqual({ state: "posted", threadId: f.main, messageId: f.lines()[1]!.id }); expect(f.wrote).toEqual([f.main, f.main]); expect(f.notified).toEqual([]); }); it("says a failed run and its recovery in the language of the chat's person", () => { const asked = new Set(); const f = fixture({ threadLanguage: (threadId) => { asked.add(threadId); return "de"; } }); const failed = f.finished(null, "Posteingang", { status: "failed", error: "Timed out" }); f.reports.runFailed(failed, "it ran past its 25-minute limit."); const finishedAt = Date.UTC(2026, 9, 26, 2, 40); const ok = f.finished("NOTHING NEW", "Posteingang", { finishedAt }); f.reports.runSettled(ok, `turn-${ok.id}`); const plain = f.finished(null, "Posteingang", { status: "failed" }); f.reports.runFailed(plain, ""); expect(f.lines().map((line) => line.text)).toEqual([ "Diese Ausführung wurde nicht beendet: it ran past its 25-minute limit.", recoveredText("Posteingang", finishedAt, "de"), "Diese Ausführung wurde nicht beendet.", ]); expect(recoveredText("Posteingang", finishedAt, "de")).toMatch(/^Wieder in Ordnung: Posteingang wurde um \d{1,2}:\d\d \S+ erfolgreich beendet$/); expect([...asked]).toEqual([f.main]); }); it("says when a recovered run finished, in the host's clock", () => { expect(recoveredText("PAC flow driver", Date.UTC(2026, 9, 26, 2, 40))).toMatch(/^Recovered: PAC flow driver finished OK at \d{1,2}:\d\d [AP]M \S+$/); }); it("posts no recovered line after another automation's failure, nor for a run with something to say, which posts only its reply", () => { const f = fixture(); const other = f.finished(null, "Ops sweep", { status: "failed" }); f.reports.runFailed(other, ""); const quiet = f.finished("NOTHING NEW"); f.reports.runSettled(quiet, `turn-${quiet.id}`); const failed = f.finished(null, "Inbox brief", { status: "failed" }); f.reports.runFailed(failed, ""); const spoke = f.finished("Acme replied."); f.reports.runSettled(spoke, `turn-${spoke.id}`); const after = f.finished("NOTHING NEW"); f.reports.runSettled(after, `turn-${after.id}`); expect(f.lines().map((line) => line.text)).toEqual(["This run didn't finish.", "This run didn't finish.", "Acme replied."]); expect(f.lines().some((line) => line.automation?.recovered)).toBe(false); }); it("still recovers when a card a later run copied into the chat came after the failure", () => { const f = fixture(); const failed = f.finished(null, "Inbox brief", { status: "failed" }); f.reports.runFailed(failed, ""); const run = f.finished(null, "Inbox brief", { status: "running" }); f.store.appendMessage(run.threadId!, { role: "bot", kind: "options", card: { title: "Q", subtitle: "Which?", options: ["A"], requestId: "ask-1", requestType: "question" } }); f.runs.get(run.id)!.status = "completed"; f.store.appendMessage(run.threadId!, { role: "bot", kind: "text", text: "NOTHING NEW", turnId: "last", turnTerminal: true }); f.reports.runSettled(f.runs.get(run.id)!, "last"); expect(f.lines().map((line) => line.automation?.recovered ?? line.automation?.failed ?? line.automation?.needsYou)).toEqual([true, true, true]); expect(f.lines().at(-1)!.text).toMatch(/^Recovered: Inbox brief finished OK at /); }); it("recovers from a failure of an overlapping run queued behind this run's own card copy", () => { const f = fixture(); f.busy.add(f.main); const slow = f.finished(null, "Inbox brief", { status: "running" }); f.store.appendMessage(slow.threadId!, { role: "bot", kind: "options", card: { title: "Q", subtitle: "Which?", options: ["A"], requestId: "ask-1", requestType: "question" } }); const failed = f.finished(null, "Inbox brief", { status: "failed" }); f.reports.runFailed(failed, ""); f.runs.get(slow.id)!.status = "completed"; f.store.appendMessage(slow.threadId!, { role: "bot", kind: "text", text: "NOTHING NEW", turnId: "last", turnTerminal: true }); f.reports.runSettled(f.runs.get(slow.id)!, "last"); f.busy.delete(f.main); f.reports.flush(); expect(f.lines().map((line) => [line.automation?.runId, line.kind, line.automation?.failed ?? false, line.automation?.recovered ?? false])).toEqual([ [slow.id, "options", false, false], [failed.id, "text", true, false], [slow.id, "text", false, true], ]); }); it("recovers after a failure that is still waiting on a busy chat, in order, and after a restart", () => { const f = fixture(); f.busy.add(f.main); const failed = f.finished(null, "Inbox brief", { status: "failed" }); f.reports.runFailed(failed, ""); const ok = f.finished("NOTHING NEW"); f.reports.runSettled(ok, `turn-${ok.id}`); const failedAgain = f.finished(null, "Inbox brief", { status: "failed" }); f.reports.runFailed(failedAgain, ""); expect(f.runs.get(ok.id)!.report).toEqual({ state: "pending" }); f.busy.delete(f.main); f.reports.flush(); expect(f.lines().map((line) => [line.automation?.runId, line.automation?.failed ?? false, line.automation?.recovered ?? false])).toEqual([ [failed.id, true, false], [ok.id, false, true], [failedAgain.id, true, false], ]); const quiet = f.finished("NOTHING NEW"); f.runs.get(quiet.id)!.report = { state: "pending" }; f.create().flush(); expect(f.lines().at(-1)).toMatchObject({ automation: { runId: quiet.id, recovered: true } }); expect(f.lines()).toHaveLength(4); }); it("copies a card the run waits on into the chat, routes the answer to the run, and keeps the copy in step", () => { const f = fixture(); const run = f.finished(null, "Inbox brief", { status: "running" }); const card = { title: "Your bot has a question", subtitle: "Which account?", options: ["Work", "Home"], requestId: "ask-1", requestType: "question" as const }; const original = f.store.appendMessage(run.threadId!, { role: "bot", kind: "options", card }); const copy = f.store.messagesFor(f.main).find((message) => message.card?.requestId === "ask-1"); expect(copy).toMatchObject({ kind: "options", card, automation: expect.objectContaining({ runId: run.id, needsYou: true, executionThreadId: run.threadId }) }); expect(f.reports.answeredIn(f.main, "ask-1")).toBe(run.threadId); expect(f.reports.answeredIn(f.main, "other")).toBe(f.main); expect(f.reports.answeredIn(run.threadId!, "ask-1")).toBe(run.threadId); f.store.patchMessage(run.threadId!, original.id, { card: { ...card, answered: "answer", answeredText: "Work" } }); expect(f.store.messagesFor(f.main).find((message) => message.id === copy!.id)?.card).toEqual({ ...card, answered: "answer", answeredText: "Work" }); f.store.appendMessage(run.threadId!, { role: "bot", kind: "options", card: { ...card, requestId: "ask-2", answered: "allow" } }); f.store.appendMessage(run.threadId!, { role: "bot", kind: "options", card: { ...card, requestId: "ask-3", autoApplied: true } }); expect(f.store.messagesFor(f.main).filter((message) => message.kind === "options")).toHaveLength(1); }); it("names a sign-in, connection or credential card the run waits on, and stops saying it needs the person once that card settles", () => { const f = fixture(); const run = f.finished(null, "Dashboard check", { status: "running" }); const signIn = f.store.appendMessage(run.threadId!, { role: "bot", kind: "signin", text: "Sign in", signIn: { url: "https://example.com", site: "example.com", place: "browser", reason: "the dashboard is behind a login" } }); const connect = f.store.appendMessage(run.threadId!, { role: "bot", kind: "connector", connector: { slug: "gmail", label: "Gmail", description: "", status: "required", resumeKey: "resume-key-1" } }); f.store.appendMessage(run.threadId!, { role: "bot", kind: "secret", secret: { target: "xaiApiKey", label: "xAI API key", description: "", placeholder: "", helpUrl: "", requestKey: "secret-1" } }); f.store.appendMessage(run.threadId!, { role: "bot", kind: "signin", text: "Sign in", signIn: { url: "https://done.example", site: "done.example", place: "browser", done: true } }); expect(f.lines().map((line) => [line.text, line.automation?.needsYou, line.automation?.copyOf])).toEqual([ ["I need you to sign in to example.com: the dashboard is behind a login.", true, signIn.id], ["I need you to connect Gmail.", true, connect.id], ["I need your xAI API key.", true, expect.any(String)], ]); expect(f.wrote).toEqual([f.main, f.main, f.main]); f.store.patchMessage(run.threadId!, signIn.id, { signIn: { ...signIn.signIn!, done: true } }); f.store.patchMessage(run.threadId!, connect.id, { connector: { ...connect.connector!, status: "authorizing" } }); expect(f.lines().map((line) => line.automation?.needsYou)).toEqual([undefined, true, true]); f.store.patchMessage(run.threadId!, connect.id, { connector: { ...connect.connector!, status: "connected" } }); expect(f.lines().map((line) => line.automation?.needsYou)).toEqual([undefined, undefined, true]); f.runs.get(run.id)!.status = "completed"; f.store.appendMessage(run.threadId!, { role: "bot", kind: "text", text: "The dashboard looks fine.", turnId: "last", turnTerminal: true }); f.reports.runSettled(f.runs.get(run.id)!, "last"); expect(f.lines().at(-1)).toMatchObject({ text: "The dashboard looks fine.", automation: { runId: run.id } }); expect(f.lines().at(-1)?.automation).not.toHaveProperty("copyOf"); }); it("names a card the run waits on in the language of the chat's person", () => { const f = fixture({ threadLanguage: () => "de" }); const run = f.finished(null, "Dashboard check", { status: "running" }); f.store.appendMessage(run.threadId!, { role: "bot", kind: "signin", text: "Sign in", signIn: { url: "https://example.com", site: "example.com", place: "browser", reason: "das Dashboard braucht eine Anmeldung" } }); f.store.appendMessage(run.threadId!, { role: "bot", kind: "connector", connector: { slug: "clarity", label: "Microsoft Clarity", description: "", status: "required", resumeKey: "resume-key-1" } }); f.store.appendMessage(run.threadId!, { role: "bot", kind: "secret", secret: { target: "xaiApiKey", label: "xAI API key", description: "", placeholder: "", helpUrl: "", requestKey: "secret-1" } }); expect(f.lines().map((line) => line.text)).toEqual([ "Bitte melde dich bei example.com an: das Dashboard braucht eine Anmeldung.", "Bitte verbinde Microsoft Clarity.", "Bitte hinterlege xAI API key.", ]); }); it("copies a card into a busy chat only after its turn, and not at all once it was answered elsewhere", () => { const f = fixture(); const run = f.finished(null, "Inbox brief", { status: "running" }); f.busy.add(f.main); const card = { title: "Approval needed", subtitle: "Send the reply", options: ["Allow", "Deny"], requestId: "ask-1", requestType: "permission" as const, tool: "Gmail" }; const first = f.store.appendMessage(run.threadId!, { role: "bot", kind: "options", card }); f.store.appendMessage(run.threadId!, { role: "bot", kind: "options", card: { ...card, requestId: "ask-2" } }); expect(f.added()).toEqual([]); f.store.patchMessage(run.threadId!, first.id, { card: { ...card, answered: "allow" } }); f.busy.delete(f.main); f.reports.flush(); expect(f.added().map((message) => message.card?.requestId)).toEqual(["ask-2"]); }); it("does not copy cards from a conversation the person took over", () => { const f = fixture(); const run = f.finished(null, "Inbox brief", { status: "running" }); f.store.patchTask(f.bot.id, run.threadId!, { routineRunId: undefined }); f.store.appendMessage(run.threadId!, { role: "bot", kind: "options", card: { title: "Q", subtitle: "Which?", options: [], requestId: "ask-1", requestType: "question" } }); expect(f.added()).toEqual([]); }); it("carries what the run attached with its last reply", () => { const f = fixture(); const run = f.finished(null); const attachments = [{ kind: "file" as const, path: "report-1.pdf", mime: "application/pdf", name: "Weekly report.pdf" }]; f.store.appendMessage(run.threadId!, { role: "bot", kind: "text", text: "Here is this week's report.", attachments, turnId: `turn-${run.id}`, turnTerminal: true }); f.reports.runSettled(run, `turn-${run.id}`); expect(f.lines()).toEqual([expect.objectContaining({ text: "Here is this week's report.", attachments })]); }); it("carries the files the run's last turn sent, in order, and not the images its tools returned", () => { const f = fixture(); const run = f.finished(null); const thread = run.threadId!; const earlier = { kind: "file" as const, path: "old.pdf", mime: "application/pdf", name: "Old.pdf" }; const pdf = { kind: "file" as const, path: "brief-1.pdf", mime: "application/pdf", name: "Brief.pdf" }; const mp3 = { kind: "file" as const, path: "brief-1.mp3", mime: "audio/mpeg", name: "Brief.mp3" }; const page = { kind: "image" as const, path: "page-1.png", mime: "image/png", fromTool: true as const }; const drawn = { kind: "image" as const, path: "chart-1.png", mime: "image/png" }; const note = { kind: "audio" as const, path: "note-1.ogg", mime: "audio/ogg", text: "Today's brief." }; const slides = { kind: "file" as const, path: "brief-1.pptx", mime: "application/vnd.openxmlformats-officedocument.presentationml.presentation", name: "Brief.pptx" }; f.store.appendMessage(thread, { role: "bot", kind: "text", text: "", attachments: [earlier] }); f.store.appendMessage(thread, { role: "user", kind: "text", text: "Go on." }); f.store.appendMessage(thread, { role: "bot", kind: "text", text: "", attachments: [pdf] }); f.store.appendMessage(thread, { role: "user", kind: "text", text: "Add the audio too.", steered: true }); f.store.appendMessage(thread, { role: "bot", kind: "text", text: "", attachments: [mp3] }); f.store.appendMessage(thread, { role: "bot", kind: "text", text: "Your brief.", attachments: [page, drawn, note], turnId: `turn-${run.id}` }); f.store.appendMessage(thread, { role: "bot", kind: "text", text: "", attachments: [slides] }); f.reports.runSettled(run, `turn-${run.id}`); expect(f.lines()).toEqual([expect.objectContaining({ text: "Your brief.", attachments: [pdf, mp3, drawn, note, slides] })]); }); it("reports a run that only sent files, without a word", () => { const f = fixture(); const run = f.finished(null); const pdf = { kind: "file" as const, path: "brief-1.pdf", mime: "application/pdf", name: "Brief.pdf" }; f.store.appendMessage(run.threadId!, { role: "user", kind: "text", text: "Do the Daily brief check." }); f.store.appendMessage(run.threadId!, { role: "bot", kind: "text", text: "", attachments: [pdf] }); f.store.appendMessage(run.threadId!, { role: "bot", kind: "activity", tool: { name: "Bash", ok: true }, turnId: `turn-${run.id}` }); f.reports.runSettled(run, `turn-${run.id}`); expect(f.lines()).toEqual([expect.objectContaining({ text: "", attachments: [pdf] })]); expect(f.runs.get(run.id)!.report).toMatchObject({ state: "posted" }); }); it("still carries the turn's files when the person wrote in the run before its report was written", () => { const f = fixture(); const run = f.finished(null); const pdf = { kind: "file" as const, path: "brief-1.pdf", mime: "application/pdf", name: "Brief.pdf" }; f.store.appendMessage(run.threadId!, { role: "bot", kind: "text", text: "", attachments: [pdf] }); f.store.appendMessage(run.threadId!, { role: "bot", kind: "text", text: "Your brief.", turnId: `turn-${run.id}`, turnTerminal: true }); f.busy.add(f.main); f.reports.runSettled(run, `turn-${run.id}`); f.store.appendMessage(run.threadId!, { role: "user", kind: "text", text: "Thanks." }); f.busy.delete(f.main); f.reports.flush(); expect(f.lines()).toEqual([expect.objectContaining({ text: "Your brief.", attachments: [pdf] })]); }); it("says only that an automation recovered, without the files its quiet run sent", () => { const f = fixture(); const failed = f.finished(null, "Inbox brief", { status: "failed", error: "Timed out" }); f.reports.runFailed(failed, "it ran past its 25-minute limit."); const ok = f.finished(null); f.store.appendMessage(ok.threadId!, { role: "bot", kind: "text", text: "", attachments: [{ kind: "file", path: "brief-1.pdf", mime: "application/pdf", name: "Brief.pdf" }] }); f.store.appendMessage(ok.threadId!, { role: "bot", kind: "text", text: "NOTHING NEW", turnId: `turn-${ok.id}`, turnTerminal: true }); f.reports.runSettled(ok, `turn-${ok.id}`); expect(f.lines().at(-1)).toMatchObject({ automation: expect.objectContaining({ recovered: true }) }); expect(f.lines().at(-1)).not.toHaveProperty("attachments"); }); it("keeps a run quiet whose last turn, resumed after a hand-off, said and sent nothing", () => { const f = fixture(); const run = f.finished(null); f.store.appendMessage(run.threadId!, { role: "user", kind: "text", text: "Do the Stock check." }); f.store.appendMessage(run.threadId!, { role: "bot", kind: "text", text: "Asked Sam to count the stock.", turnId: "turn-before", turnTerminal: true }); f.reports.runSettled(run, `turn-${run.id}`); expect(f.added()).toEqual([]); expect(f.runs.get(run.id)!.report).toEqual({ state: "quiet" }); }); it("keeps a run quiet that said nothing and sent nothing, though its tools returned images", () => { const f = fixture(); const run = f.finished(null); const page = { kind: "image" as const, path: "page-1.png", mime: "image/png", fromTool: true as const }; f.store.appendMessage(run.threadId!, { role: "bot", kind: "text", text: "", attachments: [page], turnId: `turn-${run.id}`, turnTerminal: true }); f.reports.runSettled(run, `turn-${run.id}`); expect(f.added()).toEqual([]); expect(f.runs.get(run.id)!.report).toEqual({ state: "quiet" }); }); it("reads a report still waiting after a restart again, and writes nothing if the run's last reply had nothing new", () => { const f = fixture(); const run = f.finished("Checked again.\nNOTHING NEW"); f.runs.get(run.id)!.report = { state: "pending" }; f.create().flush(); expect(f.added()).toEqual([]); expect(f.runs.get(run.id)!.report).toEqual({ state: "quiet" }); }); it("never writes into another run's hidden conversation, even when a routine points there", () => { const f = fixture(); const other = f.finished(null, "Other", { status: "running" }); const run = f.finished("Acme replied.", "Inbox brief", { resultsThreadId: other.threadId }); f.reports.runSettled(run, `turn-${run.id}`); expect(f.store.messagesFor(other.threadId!).some((message) => message.automation)).toBe(false); expect(f.notified).toEqual([{ kind: "done", threadId: run.threadId, detail: "Acme replied." }]); }); it("sends the answer to a copied card only to a run of the same bot", () => { const f = fixture(); const sam = f.store.createBot({ name: "Sam" }); const samsRun = f.store.createTask(sam.id, "Sam's run", false)!.threadId; f.store.appendMessage(f.main, { role: "bot", kind: "options", card: { title: "Q", subtitle: "Which?", options: [], requestId: "ask-9", requestType: "question" }, automation: { routineId: "r", runId: "x", name: "Inbox brief", trigger: "schedule", executionThreadId: samsRun, scheduledFor: 0, needsYou: true }, }); expect(f.reports.answeredIn(f.main, "ask-9")).toBe(f.main); }); it("leaves a missed run, or one failed before it had a conversation, to its card, since no line can say so", () => { const f = fixture(); const missed = f.finished(null, "Inbox brief", { status: "missed", threadId: undefined, error: "This computer was offline" }); const missedAfterRequeue = f.finished(null, "Inbox brief", { status: "missed", error: "The routine ended before this run could start" }); const unstarted = f.finished(null, "Inbox brief", { status: "failed", threadId: undefined, error: "Could not create a task for this run" }); const queued = f.finished(null, "Inbox brief", { status: "queued", threadId: undefined }); const failed = f.finished(null, "Inbox brief", { status: "failed", error: "Signed out" }); expect([missed, missedAfterRequeue, unstarted, queued, failed].map((run) => f.reports.speaksFor(run))).toEqual([false, false, false, true, true]); }); it("leaves a missed run of a routine that delivers to the brief to the brief, with no card in the chat", () => { const f = fixture(); const missed = f.finished(null, "Watch", { status: "missed", threadId: undefined, deliver: "brief", error: "This computer was offline" }); const unstarted = f.finished(null, "Watch", { status: "failed", threadId: undefined, deliver: "brief", error: "Could not create a task for this run" }); expect([missed, unstarted].map((run) => f.reports.speaksFor(run))).toEqual([true, true]); }); it("knows which conversations are a run's own whose last reply goes to another chat", () => { const f = fixture(); const own = f.finished(null, "Inbox brief", { status: "running" }); const joined = f.finished(null, "Continue", { status: "running", threadId: f.main }); const gone = f.finished(null, "Orphan", { status: "running", resultsThreadId: "deleted-thread" }); expect(f.reports.reportsElsewhere(own.threadId!)).toBe(true); expect(f.reports.reportsElsewhere(f.main)).toBe(false); expect(f.reports.reportsElsewhere(gone.threadId!)).toBe(false); expect(f.reports.reportsElsewhere("no-run-here")).toBe(false); expect(joined.threadId).toBe(f.main); }); it("starts working through what was waiting once the routines are loaded", () => { const f = fixture(); f.offline(); const run = f.finished("Acme replied."); f.runs.get(run.id)!.report = { state: "pending" }; const reports = f.create(); reports.flush(); expect(f.lines()).toEqual([]); f.online(); reports.flush(); expect(f.lines().map((line: Message) => line.text)).toEqual(["Acme replied."]); }); });