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
295 lines
8.7 KiB
TypeScript
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 } });
|
|
});
|
|
});
|