feat(inbox): list and answer items from all of the user's spaces

The inbox in the main conversation only showed asks, approvals, takeovers and
unread threads of the current space. The main conversation lives in the home
space, so anything a bot needed in a project space never reached it.

- inbox.list now covers every space the user belongs to (except spaces being
  deleted), still only the user's own bots, runs and threads. Items from
  another space carry that space's id and name.
- The web inbox shows the space name next to the bot for those items, answers
  them with that space's header so the existing thread procedures check
  membership and ownership for that space, and opens their thread in it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
This commit is contained in:
Claude 2026-10-03 13:27:39 +00:00
parent ddf13adf08
commit e13dbe5ffa
No known key found for this signature in database
10 changed files with 351 additions and 51 deletions

View File

@ -18,9 +18,11 @@ function run(
status: "waiting_input" | "waiting_takeover",
thread: Partial<InboxRunRow["thread"]> = {},
bot = scout,
spaceId = "space-home",
): InboxRunRow {
return {
id,
spaceId,
status,
updatedAt: at(0),
bot,
@ -48,8 +50,10 @@ const approval = {
function unreadThread(
bot: InboxUnreadThreadRow["bot"],
latest?: { id: string; text: string; minute: number },
spaceId = "space-home",
): InboxUnreadThreadRow {
return {
spaceId,
createdAt: at(0),
bot,
messages: latest
@ -227,4 +231,36 @@ describe("buildInboxItems", () => {
});
expect(items[2]).toMatchObject({ messageId: null, text: "", createdAt: at(0).toISOString() });
});
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 } });
});
});

View File

@ -4,7 +4,7 @@ import { isApprovalAskBlock } from "@rakazo/core";
import type { PrismaClient } from "@rakazo/db";
import { promptSnippet } from "./runs.js";
/** Caps per query; a space rarely holds more waiting runs or unread threads than this. */
/** Caps per query; the user's spaces rarely hold more waiting runs or unread threads than this. */
const INBOX_RUN_LIMIT = 50;
const INBOX_UNREAD_LIMIT = 50;
const INBOX_PREVIEW_LENGTH = 280;
@ -12,9 +12,11 @@ const INBOX_PREVIEW_LENGTH = 280;
type AskBlock = Extract<MessageBlock, { kind: "ask" }>;
type ComputerBlock = Extract<MessageBlock, { kind: "computer" }>;
type InboxBot = { id: string; name: string; color: string };
type InboxSpace = NonNullable<InboxItem["space"]>;
export interface InboxRunRow {
id: string;
spaceId: string;
status: string;
updatedAt: Date;
bot: InboxBot;
@ -33,6 +35,7 @@ export interface InboxMessageRow {
}
export interface InboxUnreadThreadRow {
spaceId: string;
createdAt: Date;
bot: InboxBot | null;
/** The newest bot message, if any. */
@ -95,14 +98,20 @@ function byCreatedAt(left: InboxItem, right: InboxItem): number {
/**
* Turns waiting runs, their ask and takeover messages, and unread threads into inbox items:
* everything that waits on the user first, then unread threads not already covered, each
* oldest first.
* oldest first. Items from another space name it.
*/
export function buildInboxItems(input: {
runs: InboxRunRow[];
/** Ask and takeover messages of those runs, newest first. */
messages: InboxMessageRow[];
unreadThreads: InboxUnreadThreadRow[];
/** The user's other spaces by id; rows of any space not listed belong to the current one. */
otherSpaces?: ReadonlyMap<string, InboxSpace>;
}): InboxItem[] {
const spaceOf = (spaceId: string) => {
const space = input.otherSpaces?.get(spaceId);
return space ? { space } : {};
};
const messagesByRun = new Map<string, InboxMessageRow[]>();
for (const message of input.messages) {
if (!message.runId) continue;
@ -122,6 +131,7 @@ export function buildInboxItems(input: {
kind: isApprovalAskBlock(pending.block) ? "approval" : "ask",
bot: run.bot,
target,
...spaceOf(run.spaceId),
runId: run.id,
messageId: pending.message.id,
ask: pending.block,
@ -135,6 +145,7 @@ export function buildInboxItems(input: {
kind: "takeover",
bot: run.bot,
target: { botId: run.bot.id },
...spaceOf(run.spaceId),
runId: run.id,
messageId: request?.message.id ?? null,
text: request?.block.text ?? "",
@ -154,6 +165,7 @@ export function buildInboxItems(input: {
kind: "unread",
bot: thread.bot,
target,
...spaceOf(thread.spaceId),
messageId: latest?.id ?? null,
text: previewText(latest?.blocks),
createdAt: (latest?.createdAt ?? thread.createdAt).toISOString(),
@ -165,13 +177,34 @@ export function buildInboxItems(input: {
const botSelect = { id: true, name: true, color: true } as const;
/** The current space's inbox: only the actor's own bots, runs and threads. */
/**
* The other spaces whose items the inbox shows: every space the user belongs to, except spaces
* being deleted.
*/
async function otherInboxSpaces(
prisma: PrismaClient,
actor: Actor,
): Promise<Map<string, InboxSpace>> {
const memberships = await prisma.spaceMember.findMany({
where: { userId: actor.userId, spaceId: { not: actor.spaceId }, space: { deletingAt: null } },
select: { space: { select: { id: true, name: true } } },
});
return new Map(memberships.map(({ space }) => [space.id, space]));
}
/**
* The inbox across the user's spaces: only the actor's own bots, runs and threads. Items are
* answered and opened in their own space, where the thread procedures check membership and
* ownership again.
*/
export async function listInbox(prisma: PrismaClient, actor: Actor): Promise<InboxItem[]> {
const otherSpaces = await otherInboxSpaces(prisma, actor);
const spaceIds = [actor.spaceId, ...otherSpaces.keys()];
const ownBot = { userId: actor.userId, archivedAt: null };
const [runs, unreadThreads] = await Promise.all([
prisma.run.findMany({
where: {
spaceId: actor.spaceId,
spaceId: { in: spaceIds },
userId: actor.userId,
status: { in: ["waiting_input", "waiting_takeover"] },
bot: ownBot,
@ -179,6 +212,7 @@ export async function listInbox(prisma: PrismaClient, actor: Actor): Promise<Inb
},
select: {
id: true,
spaceId: true,
status: true,
threadId: true,
updatedAt: true,
@ -197,12 +231,13 @@ export async function listInbox(prisma: PrismaClient, actor: Actor): Promise<Inb
// Only specialists' own threads; group threads, the main conversation included, stay out.
prisma.thread.findMany({
where: {
spaceId: actor.spaceId,
spaceId: { in: spaceIds },
userId: actor.userId,
unread: true,
bot: { ...ownBot, kind: "specialist" },
},
select: {
spaceId: true,
createdAt: true,
bot: { select: botSelect },
messages: {
@ -232,5 +267,5 @@ export async function listInbox(prisma: PrismaClient, actor: Actor): Promise<Inb
orderBy: { seq: "desc" },
select: { id: true, runId: true, blocks: true, createdAt: true },
});
return buildInboxItems({ runs, messages, unreadThreads });
return buildInboxItems({ runs, messages, unreadThreads, otherSpaces });
}

View File

@ -39,6 +39,75 @@ test("answers a specialist's question from the main conversation", async ({ page
await expect(page.getByText("Answered: Paris", { exact: true })).toBeVisible({ timeout: 30_000 });
});
test("answers a question from another space in the main conversation", async ({
page,
}, testInfo) => {
const stamp = Date.now();
await signup(page, `inbox-cross-${stamp}@rakazo.test`, "password12", "Inbox Cross");
await page.waitForURL(/\/(onboarding|app)/, { timeout: 30_000 });
const helper = await createBot(page, "Helper");
const side = await rpc<{ id: string }>(page, "spaces/create", { name: "Side project" });
const scout = await rpcInSpace<{ id: string }>(page, side.id, "bots/create", {
name: "Scout",
title: "",
description: "",
notifyOnFinish: true,
computerMode: "team",
});
await rpcInSpace(page, side.id, "threads/send", {
botId: scout.id,
text: "ask me which city to use",
});
const runInSide = async () =>
(
await rpcInSpace<{ run?: { status: string } | null }>(page, side.id, "threads/get", {
botId: scout.id,
})
).run ?? null;
await expect
.poll(async () => (await runInSide())?.status ?? null, { timeout: 60_000 })
.toBe("waiting_input");
await openMainConversation(page, helper.id);
const inbox = page.getByRole("region", { name: "Needs you" });
const ask = inbox.locator("[data-kind='ask']");
await expect(ask).toContainText("Scout", { timeout: 30_000 });
await expect(ask.getByTestId("main-inbox-space")).toHaveText("· Side project");
await captureScreenshot(page, testInfo, "inbox-cross-space");
await ask.getByRole("button", { name: "Edit first" }).click();
await ask.getByRole("textbox", { name: "Answer" }).fill("Paris");
await ask.getByRole("button", { name: "Send answer" }).click();
await expect(ask).toHaveCount(0);
await expect.poll(runInSide, { timeout: 60_000 }).toBeNull();
// The reply arrives as unread from the other space and opens there.
const reply = inbox.locator("[data-kind='unread']").filter({ hasText: "Scout" });
await expect(reply).toContainText("Paris", { timeout: 45_000 });
await expect(reply.getByTestId("main-inbox-space")).toHaveText("· Side project");
await reply.getByRole("button").click();
await page.waitForURL(new RegExp(`/app/${scout.id}$`));
await expect(page.getByText("Answered: Paris", { exact: true })).toBeVisible({ timeout: 30_000 });
expect(await page.evaluate(() => window.localStorage.getItem("rakazo:space-id"))).toBe(side.id);
});
async function rpcInSpace<T = unknown>(
page: Page,
spaceId: string,
procedure: string,
body: unknown,
): Promise<T> {
const response = await page.request.post(`/rpc/${procedure}`, {
data: { json: body },
headers: { "x-rakazo-space-id": spaceId },
});
const parsed = (await response.json()) as { json?: T; error?: { message?: string } };
if (!response.ok() || parsed.error) {
throw new Error(`${procedure} ${response.status()}: ${parsed.error?.message ?? "failed"}`);
}
return parsed.json as T;
}
async function createBot(page: Page, name: string) {
return rpc<{ id: string }>(page, "bots/create", {
name,

View File

@ -79,6 +79,22 @@ const unread: InboxItem = {
text: "Draft ready for review",
createdAt,
};
const side = { id: "space-side", name: "Side project" };
const askInOtherSpace: InboxItem = {
kind: "ask",
bot: writer,
target: { botId: writer.id },
space: side,
runId: "run-side",
messageId: "msg-side",
ask: { kind: "ask", text: "Which title fits?", status: "pending" },
createdAt,
};
const groupAskInOtherSpace: InboxItem = {
...approvalInGroup,
space: side,
messageId: "msg-side-group",
};
const idleBots = [
{ id: scout.id, status: "idle", unread: false, preview: "" },
@ -89,10 +105,18 @@ let container: HTMLDivElement;
let root: Root;
const onOpenBot = vi.fn();
const onOpenGroup = vi.fn();
const onOpenInSpace = vi.fn();
function render(bots = idleBots) {
return act(async () =>
root.render(<MainInbox bots={bots} onOpenBot={onOpenBot} onOpenGroup={onOpenGroup} />),
root.render(
<MainInbox
bots={bots}
onOpenBot={onOpenBot}
onOpenGroup={onOpenGroup}
onOpenInSpace={onOpenInSpace}
/>,
),
);
}
@ -111,6 +135,7 @@ beforeEach(() => {
api.markRead.mockReset().mockResolvedValue({ ok: true });
onOpenBot.mockReset();
onOpenGroup.mockReset();
onOpenInSpace.mockReset();
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
@ -154,12 +179,10 @@ it("answers through the thread the ask came from and drops only that item", asyn
itemOf("approval").querySelector<HTMLButtonElement>("[data-answer]")?.click(),
);
expect(api.answer).toHaveBeenCalledWith({
groupId: "group-ops",
runId: "run-approval",
messageId: "msg-approval",
answer: "Paris",
});
expect(api.answer).toHaveBeenCalledWith(
{ groupId: "group-ops", runId: "run-approval", messageId: "msg-approval", answer: "Paris" },
{ context: {} },
);
expect(api.markRead).not.toHaveBeenCalled();
expect(api.list).toHaveBeenCalledTimes(2);
expect(container.querySelectorAll("[data-testid='main-inbox-item']")).toHaveLength(1);
@ -173,7 +196,7 @@ it("marks the bot's own thread read before answering, so the question does not r
await act(async () => itemOf("ask").querySelector<HTMLButtonElement>("[data-answer]")?.click());
expect(api.markRead).toHaveBeenCalledWith({ botId: scout.id });
expect(api.markRead).toHaveBeenCalledWith({ botId: scout.id }, { context: {} });
expect(api.markRead.mock.invocationCallOrder[0]).toBeLessThan(
api.answer.mock.invocationCallOrder[0]!,
);
@ -187,12 +210,10 @@ it("refreshes after a failed answer so a stale ask disappears", async () => {
await act(async () => itemOf("ask").querySelector<HTMLButtonElement>("[data-answer]")?.click());
expect(api.answer).toHaveBeenCalledWith({
botId: scout.id,
runId: "run-ask",
messageId: "msg-ask",
answer: "Paris",
});
expect(api.answer).toHaveBeenCalledWith(
{ botId: scout.id, runId: "run-ask", messageId: "msg-ask", answer: "Paris" },
{ context: {} },
);
expect(container.querySelector("[data-testid='main-inbox']")).toBeNull();
});
@ -208,6 +229,36 @@ it("opens the bot's thread for takeovers and unread threads, and the source thre
expect(onOpenGroup.mock.calls).toEqual([["group-ops"]]);
});
it("names another space's items and answers and opens them in that space", async () => {
api.list.mockResolvedValueOnce({ items: [askInOtherSpace, askInOwnThread] });
api.list.mockResolvedValue({ items: [askInOwnThread, groupAskInOtherSpace] });
api.answer.mockResolvedValue({ ok: true });
await render();
const items = container.querySelectorAll<HTMLElement>("[data-testid='main-inbox-item']");
expect(items[0]?.querySelector("[data-testid='main-inbox-space']")?.textContent).toBe(
"· Side project",
);
expect(items[1]?.querySelector("[data-testid='main-inbox-space']")).toBeNull();
await act(async () => items[0]?.querySelector<HTMLButtonElement>("[data-answer]")?.click());
expect(api.markRead).toHaveBeenCalledWith(
{ botId: writer.id },
{ context: { spaceId: side.id } },
);
expect(api.answer).toHaveBeenCalledWith(
{ botId: writer.id, runId: "run-side", messageId: "msg-side", answer: "Paris" },
{ context: { spaceId: side.id } },
);
const [own, group] = container.querySelectorAll<HTMLElement>("[data-testid='main-inbox-item']");
await act(async () => group?.querySelector("button")?.click());
await act(async () => own?.querySelector("button")?.click());
expect(onOpenInSpace.mock.calls).toEqual([[side.id, "/app/g/group-ops"]]);
expect(onOpenBot.mock.calls).toEqual([[scout.id]]);
expect(onOpenGroup).not.toHaveBeenCalled();
});
it("refreshes when a bot's state changes, not on every render", async () => {
api.list.mockResolvedValue({ items: [] });
await render();

View File

@ -12,20 +12,28 @@ const INBOX_POLL_MS = 15_000;
type PendingAskItem = Extract<InboxItem, { kind: "ask" | "approval" }>;
/** Thread procedures for an item run in the item's own space. */
function itemContext(item: InboxItem) {
return { context: item.space ? { spaceId: item.space.id } : {} };
}
/**
* What the user's bots need from them outside the main conversation, with each bot as the
* sender: asks and approvals are answered in place, takeovers and unread threads open the
* bot's thread.
* What the user's bots in all of their spaces need from them outside the main conversation, with
* each bot as the sender: asks and approvals are answered in place, takeovers and unread threads
* open the bot's thread.
*/
export function MainInbox({
bots,
onOpenBot,
onOpenGroup,
onOpenInSpace,
}: {
/** The space's bots; a change in their status, unread flag or preview refreshes the inbox. */
bots: readonly Pick<Bot, "id" | "status" | "unread" | "preview">[];
onOpenBot: (botId: string) => void;
onOpenGroup: (groupId: string) => void;
/** Opens an app path, such as a thread, in another of the user's spaces. */
onOpenInSpace: (spaceId: string, path: string) => void;
}) {
const { t } = useLingui();
const [items, setItems] = useState<InboxItem[]>([]);
@ -59,11 +67,16 @@ export function MainInbox({
}, [refresh]);
const open = useCallback(
(target: InboxThreadTarget) => {
if ("botId" in target) onOpenBot(target.botId);
({ target, space }: { target: InboxThreadTarget; space?: InboxItem["space"] }) => {
if (space) {
onOpenInSpace(
space.id,
"botId" in target ? `/app/${target.botId}` : `/app/g/${target.groupId}`,
);
} else if ("botId" in target) onOpenBot(target.botId);
else onOpenGroup(target.groupId);
},
[onOpenBot, onOpenGroup],
[onOpenBot, onOpenGroup, onOpenInSpace],
);
const answer = useCallback(
@ -72,14 +85,12 @@ export function MainInbox({
// Answering reads the ask, so the bot's thread should not come back as unread with the
// same question. Mark it first: the reply to this answer marks it unread again.
if ("botId" in item.target) {
await rpc.threads.markRead(item.target).catch(() => undefined);
await rpc.threads.markRead(item.target, itemContext(item)).catch(() => undefined);
}
await rpc.threads.answer({
...item.target,
runId: item.runId,
messageId: item.messageId,
answer: text,
});
await rpc.threads.answer(
{ ...item.target, runId: item.runId, messageId: item.messageId, answer: text },
itemContext(item),
);
const key = inboxItemKey(item);
setItems((current) => current.filter((entry) => inboxItemKey(entry) !== key));
} finally {
@ -105,10 +116,10 @@ export function MainInbox({
<div className="flex flex-col gap-1.5">
<button
type="button"
onClick={() => open(item.target)}
onClick={() => open(item)}
className="w-fit rounded-md hover:underline"
>
<Sender bot={item.bot} />
<Sender bot={item.bot} space={item.space} />
</button>
<AskCard
block={item.ask}
@ -120,11 +131,11 @@ export function MainInbox({
) : (
<button
type="button"
onClick={() => open(item.target)}
onClick={() => open(item)}
className="flex w-full items-start gap-2.5 rounded-xl px-2 py-1.5 text-start hover:bg-accent"
>
<span className="min-w-0 flex-1">
<Sender bot={item.bot} />
<Sender bot={item.bot} space={item.space} />
{item.text ? (
<span
className="mt-0.5 line-clamp-2 text-ui-body-sm text-muted-foreground"
@ -154,16 +165,27 @@ export function MainInbox({
);
}
function Sender({ bot }: { bot: InboxItem["bot"] }) {
/** The bot, and the space it works in when that is not the current one. */
function Sender({ bot, space }: { bot: InboxItem["bot"]; space?: InboxItem["space"] }) {
const color = resolvePersonaColorDef(bot.id, parseBotAvatar(bot.color).color).light;
return (
<span
className="flex items-center gap-2 text-ui-body-sm font-semibold tracking-tight"
className="flex min-w-0 items-center gap-2 text-ui-body-sm font-semibold tracking-tight"
style={{ color }}
dir="auto"
>
<BotAvatar color={bot.color} identity={bot.id} size={22} className="shrink-0" />
<span className="truncate">{bot.name}</span>
{space ? (
<span
data-testid="main-inbox-space"
className="truncate font-normal text-muted-foreground"
dir="auto"
>
<span aria-hidden="true">· </span>
{space.name}
</span>
) : null}
</span>
);
}

View File

@ -4637,7 +4637,12 @@ export function ShellPage() {
</>
)}
{activeGroup?.kind === "main" && !showProjectTimeline ? (
<MainInbox bots={bots} onOpenBot={openBot} onOpenGroup={openGroup} />
<MainInbox
bots={bots}
onOpenBot={openBot}
onOpenGroup={openGroup}
onOpenInSpace={openSpaceChat}
/>
) : null}
{recordingSkill ? (
<div className="px-6 pb-2 text-center text-ui-body-sm text-destructive">

View File

@ -13,6 +13,11 @@ const InboxItemBase = z.object({
/** The bot that needs the user, shown as the sender. */
bot: z.object({ id: Id, name: z.string(), color: z.string() }),
target: InboxThreadTargetSchema,
/**
* Set when the item belongs to another of the user's spaces: call the thread procedures and
* open the thread in that space.
*/
space: z.object({ id: Id, name: z.string() }).optional(),
createdAt: z.string(),
});
@ -24,8 +29,8 @@ const PendingAskItem = InboxItemBase.extend({
});
/**
* What a bot needs from the user outside the main conversation: an answer, an approval, the
* computer screen, or a look at an unread thread.
* What a bot needs from the user outside the main conversation, in any of the user's spaces: an
* answer, an approval, the computer screen, or a look at an unread thread.
*/
export const InboxItemSchema = z.discriminatedUnion("kind", [
PendingAskItem.extend({ kind: z.literal("ask") }),

View File

@ -815,7 +815,7 @@ export const appContract = {
.input(z.object({ threadId: Id.optional(), status: DelegatedTaskStatusSchema.optional() }))
.output(z.array(DelegatedTaskSchema)),
},
/** Asks, approvals, takeovers and unread threads outside the main conversation. */
/** Asks, approvals, takeovers and unread threads outside the main conversation, in all spaces. */
inbox: {
list: oc.output(InboxListOutputSchema),
},

View File

@ -712,6 +712,16 @@ describeWithDatabase("API authorization and resource isolation", () => {
.items,
).toContainEqual(expect.objectContaining({ kind: "ask", runId: run.id }));
expect(await rpc(app, member, "inbox/list", {}, sharedSpaceId)).toEqual({ items: [] });
// From another of the member's spaces the inbox spans the shared one too, still without
// the owner's runs.
const memberSpace = await rpc<{ id: string }>(
app,
member,
"spaces/create",
{ name: "Member side" },
sharedSpaceId,
);
expect(await rpc(app, member, "inbox/list", {}, memberSpace.id)).toEqual({ items: [] });
});
it("prevents one shared-space member from hijacking another member's Team Computer takeover", async () => {

View File

@ -94,7 +94,7 @@ describeInbox("inbox.list", () => {
expect(await listInbox(app, cookie)).toEqual([]);
});
it("collects approvals, takeovers and unread threads of this space outside the main conversation", async () => {
it("collects approvals, takeovers and unread threads of every space outside the main conversation", async () => {
const cookie = await signup(app, `inbox-mixed-${stamp}@rakazo.test`, "Inbox Mixed");
const me = await rpc<{ userId: string; spaceId: string }>(app, cookie, "me");
const scout = await createBot(app, cookie, "Scout");
@ -187,6 +187,14 @@ describeInbox("inbox.list", () => {
text: "Sign in to continue.",
createdAt: expect.any(String),
},
expect.objectContaining({
kind: "ask",
bot: expect.objectContaining({ id: remote.id }),
target: { botId: remote.id },
space: { id: remoteSpace.id, name: "Remote" },
runId: remoteAsk.runId,
messageId: remoteAsk.messageId,
}),
expect.objectContaining({
kind: "unread",
bot: expect.objectContaining({ id: scout.id }),
@ -196,16 +204,74 @@ describeInbox("inbox.list", () => {
]);
// The Pilot thread is unread too, but its takeover already covers it.
expect(items.filter((item) => item.bot.id === pilot.id)).toHaveLength(1);
// Items of the current space carry no space.
expect(items.filter((item) => item.space).map((item) => item.bot.id)).toEqual([remote.id]);
expect(await listInbox(app, cookie, remoteSpace.id)).toEqual([
// From the other space, the same items with the sides swapped.
const fromRemote = await listInbox(app, cookie, remoteSpace.id);
expect(fromRemote.map((item) => [item.kind, item.space?.id ?? null])).toEqual([
["approval", me.spaceId],
["takeover", me.spaceId],
["ask", null],
["unread", me.spaceId],
]);
});
it("answers an item from another space only in that space and only for its owner", async () => {
const cookie = await signup(app, `inbox-cross-${stamp}@rakazo.test`, "Inbox Cross");
const intruder = await signup(app, `inbox-intruder-${stamp}@rakazo.test`, "Inbox Intruder");
const me = await rpc<{ spaceId: string }>(app, cookie, "me");
const side = await rpc<{ id: string }>(app, cookie, "spaces/create", { name: "Side" });
const scout = await createBot(app, cookie, "Scout", side.id);
await rpc(
app,
cookie,
"threads/send",
{ botId: scout.id, text: "ask me which city to use" },
side.id,
);
await waitForThread(
app,
cookie,
scout.id,
(snapshot) => snapshot.run?.status === "waiting_input",
side.id,
);
const [item] = await listInbox(app, cookie, me.spaceId);
if (item?.kind !== "ask") throw new Error("expected an ask item");
expect(item).toMatchObject({
space: { id: side.id, name: "Side" },
target: { botId: scout.id },
});
const answer = {
...item.target,
runId: item.runId,
messageId: item.messageId,
answer: "Paris",
};
// The thread is not in the current space, and the intruder belongs to neither space.
await expect(rpc(app, cookie, "threads/answer", answer, me.spaceId)).rejects.toThrow();
await expect(rpc(app, intruder, "threads/answer", answer, side.id)).rejects.toThrow();
await expect(rpc(app, intruder, "threads/answer", answer)).rejects.toThrow();
expect(await listInbox(app, intruder)).toEqual([]);
expect((await listInbox(app, cookie, me.spaceId)).map((entry) => entry.kind)).toEqual(["ask"]);
await rpc(app, cookie, "threads/answer", answer, item.space?.id);
await waitForThread(app, cookie, scout.id, (snapshot) => !snapshot.run, side.id);
expect(await listInbox(app, cookie, me.spaceId)).toEqual([
expect.objectContaining({
kind: "ask",
bot: expect.objectContaining({ id: remote.id }),
target: { botId: remote.id },
runId: remoteAsk.runId,
messageId: remoteAsk.messageId,
kind: "unread",
target: { botId: scout.id },
space: { id: side.id, name: "Side" },
text: expect.stringContaining("Paris"),
}),
]);
await rpc(app, cookie, "threads/markRead", { botId: scout.id }, side.id);
expect(await listInbox(app, cookie, me.spaceId)).toEqual([]);
});
});
@ -269,11 +335,12 @@ async function waitForThread(
cookie: string,
botId: string,
done: (snapshot: ThreadSnapshot) => boolean,
spaceId?: string,
): Promise<ThreadSnapshot> {
const start = Date.now();
let last: ThreadSnapshot | null = null;
while (Date.now() - start < 20_000) {
last = await rpc<ThreadSnapshot>(app, cookie, "threads/get", { botId });
last = await rpc<ThreadSnapshot>(app, cookie, "threads/get", { botId }, spaceId);
if (done(last)) return last;
await new Promise((resolve) => setTimeout(resolve, 100));
}