diff --git a/apps/api/src/inbox.test.ts b/apps/api/src/inbox.test.ts index 08360a1..f5e754d 100644 --- a/apps/api/src/inbox.test.ts +++ b/apps/api/src/inbox.test.ts @@ -18,9 +18,11 @@ function run( status: "waiting_input" | "waiting_takeover", thread: Partial = {}, 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 } }); + }); }); diff --git a/apps/api/src/inbox.ts b/apps/api/src/inbox.ts index 549d5a6..23162b6 100644 --- a/apps/api/src/inbox.ts +++ b/apps/api/src/inbox.ts @@ -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; type ComputerBlock = Extract; type InboxBot = { id: string; name: string; color: string }; +type InboxSpace = NonNullable; 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; }): InboxItem[] { + const spaceOf = (spaceId: string) => { + const space = input.otherSpaces?.get(spaceId); + return space ? { space } : {}; + }; const messagesByRun = new Map(); 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> { + 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 { + 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 { + 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( + page: Page, + spaceId: string, + procedure: string, + body: unknown, +): Promise { + 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, diff --git a/apps/web/src/components/MainInbox.test.tsx b/apps/web/src/components/MainInbox.test.tsx index 0d6175d..c208258 100644 --- a/apps/web/src/components/MainInbox.test.tsx +++ b/apps/web/src/components/MainInbox.test.tsx @@ -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(), + root.render( + , + ), ); } @@ -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("[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("[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("[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("[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("[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("[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(); diff --git a/apps/web/src/components/MainInbox.tsx b/apps/web/src/components/MainInbox.tsx index 5f3007b..8ea65bd 100644 --- a/apps/web/src/components/MainInbox.tsx +++ b/apps/web/src/components/MainInbox.tsx @@ -12,20 +12,28 @@ const INBOX_POLL_MS = 15_000; type PendingAskItem = Extract; +/** 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[]; 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([]); @@ -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({
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" > - + {item.text ? ( {bot.name} + {space ? ( + + + {space.name} + + ) : null} ); } diff --git a/apps/web/src/pages/Shell.tsx b/apps/web/src/pages/Shell.tsx index bf25ff9..a3b1e3a 100644 --- a/apps/web/src/pages/Shell.tsx +++ b/apps/web/src/pages/Shell.tsx @@ -4637,7 +4637,12 @@ export function ShellPage() { )} {activeGroup?.kind === "main" && !showProjectTimeline ? ( - + ) : null} {recordingSkill ? (
diff --git a/packages/contracts/src/inbox.ts b/packages/contracts/src/inbox.ts index 7894cd2..3bd318d 100644 --- a/packages/contracts/src/inbox.ts +++ b/packages/contracts/src/inbox.ts @@ -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") }), diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 6640d4c..0d11a12 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -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), }, diff --git a/packages/testkit/src/authorization.test.ts b/packages/testkit/src/authorization.test.ts index ce1e785..a4490cb 100644 --- a/packages/testkit/src/authorization.test.ts +++ b/packages/testkit/src/authorization.test.ts @@ -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 () => { diff --git a/packages/testkit/src/inbox.test.ts b/packages/testkit/src/inbox.test.ts index 2fe3d09..22dfc2f 100644 --- a/packages/testkit/src/inbox.test.ts +++ b/packages/testkit/src/inbox.test.ts @@ -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 { const start = Date.now(); let last: ThreadSnapshot | null = null; while (Date.now() - start < 20_000) { - last = await rpc(app, cookie, "threads/get", { botId }); + last = await rpc(app, cookie, "threads/get", { botId }, spaceId); if (done(last)) return last; await new Promise((resolve) => setTimeout(resolve, 100)); }