Bots can offer an exact tool call as a card. Nothing runs until the user taps Accept; the call then runs as its own approved run of the bot it was meant for, and the card shows the outcome. Open suggestions also wait in the inbox and expire after a week. Claude-Session: https://claude.ai/code/session_01UmSCnoCgXqBp2fU3vwz4o1 Co-authored-by: Claude <noreply@anthropic.com>
338 lines
10 KiB
TypeScript
338 lines
10 KiB
TypeScript
import { buildSuggestionCard, withSuggestionStatus } from "@rakazo/core";
|
|
import { describe, expect, it } from "vitest";
|
|
import {
|
|
buildInboxItems,
|
|
type InboxMessageRow,
|
|
type InboxRunRow,
|
|
type InboxSuggestionRow,
|
|
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 } });
|
|
});
|
|
it("lists an open suggestion with its card, and drops it once settled", () => {
|
|
const card = buildSuggestionCard({
|
|
id: "effect-9",
|
|
effectId: "effect-9",
|
|
tool: "schedule_create",
|
|
args: { name: "Morning walk", prompt: "Take a walk." },
|
|
});
|
|
const row = (blocks: unknown[]): InboxSuggestionRow => ({
|
|
spaceId: "space-home",
|
|
createdAt: at(3),
|
|
bot: scout,
|
|
thread: { botId: scout.id, groupId: null, group: null },
|
|
messageId: "msg-card",
|
|
cardId: "effect-9",
|
|
blocks,
|
|
});
|
|
const items = buildInboxItems({
|
|
runs: [],
|
|
messages: [],
|
|
unreadThreads: [unreadThread(scout, { id: "msg-card", text: "", minute: 3 })],
|
|
suggestions: [row([card])],
|
|
});
|
|
expect(items).toEqual([
|
|
{
|
|
kind: "suggestion",
|
|
bot: scout,
|
|
target: { botId: scout.id },
|
|
messageId: "msg-card",
|
|
card,
|
|
createdAt: at(3).toISOString(),
|
|
},
|
|
]);
|
|
expect(
|
|
buildInboxItems({
|
|
runs: [],
|
|
messages: [],
|
|
unreadThreads: [],
|
|
suggestions: [row([withSuggestionStatus(card, "dismissed")])],
|
|
}),
|
|
).toEqual([]);
|
|
});
|
|
});
|