evOo-local/apps/api/src/inbox.test.ts
Claude 77e822b9a7
Merge main into the Phase 2 integration branch
Main now gives every project its own main agent. Coaching stays with the
user's personal agent, the main bot of the home space: only it gets the
goal, habit, check-in, brief and nudge tools, the coach summary and the
open-onboarding note, and the tools refuse a project's main bot too. The
onboarding questions start with the personal agent only. A project the
scan creates gets its main agent like any other, and the inbox lists asks
from another space's main conversation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
2026-10-03 23:24:21 +00:00

295 lines
8.7 KiB
TypeScript

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<InboxRunRow["thread"]> = {},
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 } });
});
});