evOo-local/apps/api/src/inbox.test.ts
Claude 408db28330
feat(inbox): show what specialists need from the user in the main conversation
Specialists ask questions, request approvals and the computer screen in
their own threads, and the user only finds out by opening each thread. With
the main conversation as the single entry point, those requests have to
reach the user there, with the specialist as the visible sender.

- inbox.list returns, for the current space and the user's own bots, pending
  asks and approvals (bot messages with a pending ask block whose run waits
  for input), takeover requests, and unread specialist threads. Asks already
  in the main conversation are left out; takeovers stay because their
  controls live in the bot's own thread. Each item carries the bot, the
  thread target, run and message ids, and the ask block for asks and
  approvals.
- The main conversation shows the items above the composer. Asks and
  approvals are answered in place through threads.answer with the source
  thread target; takeovers and unread threads open the bot's thread. The
  list refreshes when the bots' status, unread state or newest message
  changes, every 15 seconds, and after an answer.
- The inbox shrinks and scrolls so it never pushes the composer out of view.
  The transcript now clips its scroller: with no room left in the column,
  the scroller's padding used to spill over whatever sat below it.
- The ask block schema is extracted from MessageBlock so the inbox contract
  reuses it, and AskCard accepts a className.

Tests: unit tests for the item builder and the component, a Postgres suite
for the query (added to the harness list), inbox cases in the authorization
suite, and an E2E spec in which a specialist asks in its own thread and the
user answers from the main conversation.

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

231 lines
6.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,
): InboxRunRow {
return {
id,
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 },
): InboxUnreadThreadRow {
return {
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() });
});
});