Ctrl K
ComunidadGuías…

server/automation-reports.test.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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<string, RoutineRun>();
  const busy = new Set<string>();
  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<RoutineRun> = {}) => {
    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<string>();
    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."]);
  });
});