Merge inbox: what specialists need from the user, in the main conversation
Conflicts were additive: the inbox route, contract and authorization cases sit next to the delegated task ones. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
This commit is contained in:
commit
84a95f0a3b
230
apps/api/src/inbox.test.ts
Normal file
230
apps/api/src/inbox.test.ts
Normal file
@ -0,0 +1,230 @@
|
|||||||
|
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() });
|
||||||
|
});
|
||||||
|
});
|
||||||
236
apps/api/src/inbox.ts
Normal file
236
apps/api/src/inbox.ts
Normal file
@ -0,0 +1,236 @@
|
|||||||
|
import type { Actor, InboxItem, InboxThreadTarget } from "@rakazo/contracts";
|
||||||
|
import { MessageBlock } from "@rakazo/contracts";
|
||||||
|
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. */
|
||||||
|
const INBOX_RUN_LIMIT = 50;
|
||||||
|
const INBOX_UNREAD_LIMIT = 50;
|
||||||
|
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 };
|
||||||
|
|
||||||
|
export interface InboxRunRow {
|
||||||
|
id: string;
|
||||||
|
status: string;
|
||||||
|
updatedAt: Date;
|
||||||
|
bot: InboxBot;
|
||||||
|
thread: {
|
||||||
|
botId: string | null;
|
||||||
|
groupId: string | null;
|
||||||
|
group: { kind: string; archivedAt: Date | null } | null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InboxMessageRow {
|
||||||
|
id: string;
|
||||||
|
runId: string | null;
|
||||||
|
blocks: unknown;
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InboxUnreadThreadRow {
|
||||||
|
createdAt: Date;
|
||||||
|
bot: InboxBot | null;
|
||||||
|
/** The newest bot message, if any. */
|
||||||
|
messages: Array<Omit<InboxMessageRow, "runId">>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseBlocks(blocks: unknown): MessageBlock[] {
|
||||||
|
const parsed = MessageBlock.array().safeParse(blocks);
|
||||||
|
return parsed.success ? parsed.data : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function pendingAsk(blocks: MessageBlock[]): AskBlock | undefined {
|
||||||
|
return blocks.find(
|
||||||
|
(block): block is AskBlock => block.kind === "ask" && block.status !== "answered",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function takeoverRequest(blocks: MessageBlock[]): ComputerBlock | undefined {
|
||||||
|
return blocks.find((block): block is ComputerBlock => block.kind === "computer");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The newest message whose blocks hold what `pick` looks for. */
|
||||||
|
function newest<T>(messages: InboxMessageRow[], pick: (blocks: MessageBlock[]) => T | undefined) {
|
||||||
|
for (const message of messages) {
|
||||||
|
const block = pick(parseBlocks(message.blocks));
|
||||||
|
if (block) return { message, block };
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function previewText(blocks: unknown): string {
|
||||||
|
for (const block of parseBlocks(blocks)) {
|
||||||
|
if ("text" in block && typeof block.text === "string" && block.text.trim()) {
|
||||||
|
return promptSnippet(block.text, INBOX_PREVIEW_LENGTH);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where an ask can be answered from. The main conversation already shows its own asks, and an
|
||||||
|
* archived group or an external conversation has no thread target to answer through.
|
||||||
|
*/
|
||||||
|
function askTarget(thread: InboxRunRow["thread"]): InboxThreadTarget | null {
|
||||||
|
if (thread.groupId) {
|
||||||
|
if (!thread.group || thread.group.kind === "main" || thread.group.archivedAt) return null;
|
||||||
|
return { groupId: thread.groupId };
|
||||||
|
}
|
||||||
|
return thread.botId ? { botId: thread.botId } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function targetKey(target: InboxThreadTarget): string {
|
||||||
|
return "botId" in target ? `bot:${target.botId}` : `group:${target.groupId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function byCreatedAt(left: InboxItem, right: InboxItem): number {
|
||||||
|
return left.createdAt.localeCompare(right.createdAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
export function buildInboxItems(input: {
|
||||||
|
runs: InboxRunRow[];
|
||||||
|
/** Ask and takeover messages of those runs, newest first. */
|
||||||
|
messages: InboxMessageRow[];
|
||||||
|
unreadThreads: InboxUnreadThreadRow[];
|
||||||
|
}): InboxItem[] {
|
||||||
|
const messagesByRun = new Map<string, InboxMessageRow[]>();
|
||||||
|
for (const message of input.messages) {
|
||||||
|
if (!message.runId) continue;
|
||||||
|
const list = messagesByRun.get(message.runId) ?? [];
|
||||||
|
list.push(message);
|
||||||
|
messagesByRun.set(message.runId, list);
|
||||||
|
}
|
||||||
|
|
||||||
|
const waiting: InboxItem[] = [];
|
||||||
|
for (const run of input.runs) {
|
||||||
|
const messages = messagesByRun.get(run.id) ?? [];
|
||||||
|
if (run.status === "waiting_input") {
|
||||||
|
const target = askTarget(run.thread);
|
||||||
|
const pending = target ? newest(messages, pendingAsk) : undefined;
|
||||||
|
if (!target || !pending) continue;
|
||||||
|
waiting.push({
|
||||||
|
kind: isApprovalAskBlock(pending.block) ? "approval" : "ask",
|
||||||
|
bot: run.bot,
|
||||||
|
target,
|
||||||
|
runId: run.id,
|
||||||
|
messageId: pending.message.id,
|
||||||
|
ask: pending.block,
|
||||||
|
createdAt: pending.message.createdAt.toISOString(),
|
||||||
|
});
|
||||||
|
} else if (run.status === "waiting_takeover") {
|
||||||
|
// Takeover controls live in the bot's own thread, so the item stays even for a run that
|
||||||
|
// asked in the main conversation.
|
||||||
|
const request = newest(messages, takeoverRequest);
|
||||||
|
waiting.push({
|
||||||
|
kind: "takeover",
|
||||||
|
bot: run.bot,
|
||||||
|
target: { botId: run.bot.id },
|
||||||
|
runId: run.id,
|
||||||
|
messageId: request?.message.id ?? null,
|
||||||
|
text: request?.block.text ?? "",
|
||||||
|
createdAt: (request?.message.createdAt ?? run.updatedAt).toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const covered = new Set(waiting.map((item) => targetKey(item.target)));
|
||||||
|
const unread: InboxItem[] = [];
|
||||||
|
for (const thread of input.unreadThreads) {
|
||||||
|
if (!thread.bot) continue;
|
||||||
|
const target = { botId: thread.bot.id };
|
||||||
|
if (covered.has(targetKey(target))) continue;
|
||||||
|
const latest = thread.messages[0];
|
||||||
|
unread.push({
|
||||||
|
kind: "unread",
|
||||||
|
bot: thread.bot,
|
||||||
|
target,
|
||||||
|
messageId: latest?.id ?? null,
|
||||||
|
text: previewText(latest?.blocks),
|
||||||
|
createdAt: (latest?.createdAt ?? thread.createdAt).toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...waiting.sort(byCreatedAt), ...unread.sort(byCreatedAt)];
|
||||||
|
}
|
||||||
|
|
||||||
|
const botSelect = { id: true, name: true, color: true } as const;
|
||||||
|
|
||||||
|
/** The current space's inbox: only the actor's own bots, runs and threads. */
|
||||||
|
export async function listInbox(prisma: PrismaClient, actor: Actor): Promise<InboxItem[]> {
|
||||||
|
const ownBot = { userId: actor.userId, archivedAt: null };
|
||||||
|
const [runs, unreadThreads] = await Promise.all([
|
||||||
|
prisma.run.findMany({
|
||||||
|
where: {
|
||||||
|
spaceId: actor.spaceId,
|
||||||
|
userId: actor.userId,
|
||||||
|
status: { in: ["waiting_input", "waiting_takeover"] },
|
||||||
|
bot: ownBot,
|
||||||
|
thread: { userId: actor.userId },
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
status: true,
|
||||||
|
threadId: true,
|
||||||
|
updatedAt: true,
|
||||||
|
bot: { select: botSelect },
|
||||||
|
thread: {
|
||||||
|
select: {
|
||||||
|
botId: true,
|
||||||
|
groupId: true,
|
||||||
|
group: { select: { kind: true, archivedAt: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: [{ updatedAt: "asc" }, { id: "asc" }],
|
||||||
|
take: INBOX_RUN_LIMIT,
|
||||||
|
}),
|
||||||
|
// Only specialists' own threads; group threads, the main conversation included, stay out.
|
||||||
|
prisma.thread.findMany({
|
||||||
|
where: {
|
||||||
|
spaceId: actor.spaceId,
|
||||||
|
userId: actor.userId,
|
||||||
|
unread: true,
|
||||||
|
bot: { ...ownBot, kind: "specialist" },
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
createdAt: true,
|
||||||
|
bot: { select: botSelect },
|
||||||
|
messages: {
|
||||||
|
where: { role: "bot" },
|
||||||
|
orderBy: { seq: "desc" },
|
||||||
|
take: 1,
|
||||||
|
select: { id: true, blocks: true, createdAt: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: [{ createdAt: "asc" }, { id: "asc" }],
|
||||||
|
take: INBOX_UNREAD_LIMIT,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
const messages =
|
||||||
|
runs.length === 0
|
||||||
|
? []
|
||||||
|
: await prisma.message.findMany({
|
||||||
|
where: {
|
||||||
|
threadId: { in: [...new Set(runs.map((run) => run.threadId))] },
|
||||||
|
runId: { in: runs.map((run) => run.id) },
|
||||||
|
role: "bot",
|
||||||
|
OR: [
|
||||||
|
{ blocks: { array_contains: [{ kind: "ask" }] } },
|
||||||
|
{ blocks: { array_contains: [{ kind: "computer" }] } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
orderBy: { seq: "desc" },
|
||||||
|
select: { id: true, runId: true, blocks: true, createdAt: true },
|
||||||
|
});
|
||||||
|
return buildInboxItems({ runs, messages, unreadThreads });
|
||||||
|
}
|
||||||
@ -152,6 +152,7 @@ import {
|
|||||||
resolveBusyBotName,
|
resolveBusyBotName,
|
||||||
toComputerStatus,
|
toComputerStatus,
|
||||||
} from "./computer-status.js";
|
} from "./computer-status.js";
|
||||||
|
import { listInbox } from "./inbox.js";
|
||||||
import { searchIntegrationCatalog } from "./integration-catalog.js";
|
import { searchIntegrationCatalog } from "./integration-catalog.js";
|
||||||
import { buildMcpUpdateMaterial } from "./mcp-material.js";
|
import { buildMcpUpdateMaterial } from "./mcp-material.js";
|
||||||
import {
|
import {
|
||||||
@ -4669,6 +4670,11 @@ export function createRouter(deps: RouterDeps) {
|
|||||||
listDelegatedTasks(deps.prisma, context.actor, input),
|
listDelegatedTasks(deps.prisma, context.actor, input),
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
inbox: {
|
||||||
|
list: authed.inbox.list.handler(async ({ context }) => ({
|
||||||
|
items: await listInbox(deps.prisma, context.actor),
|
||||||
|
})),
|
||||||
|
},
|
||||||
voice: {
|
voice: {
|
||||||
catalog: authed.voice.catalog.handler(async () => listVoiceCatalog()),
|
catalog: authed.voice.catalog.handler(async () => listVoiceCatalog()),
|
||||||
status: authed.voice.status.handler(async ({ context }) => {
|
status: authed.voice.status.handler(async ({ context }) => {
|
||||||
|
|||||||
@ -10,7 +10,7 @@ import type { PrismaClient } from "@rakazo/db";
|
|||||||
const RECENT_LIMIT = 20;
|
const RECENT_LIMIT = 20;
|
||||||
const TERMINAL_STATUSES = ["completed", "failed", "cancelled"] as const;
|
const TERMINAL_STATUSES = ["completed", "failed", "cancelled"] as const;
|
||||||
|
|
||||||
function promptSnippet(prompt: string, max = 120): string {
|
export function promptSnippet(prompt: string, max = 120): string {
|
||||||
const oneLine = prompt.replace(/\s+/g, " ").trim();
|
const oneLine = prompt.replace(/\s+/g, " ").trim();
|
||||||
if (oneLine.length <= max) return oneLine;
|
if (oneLine.length <= max) return oneLine;
|
||||||
return `${oneLine.slice(0, max - 1)}…`;
|
return `${oneLine.slice(0, max - 1)}…`;
|
||||||
|
|||||||
85
apps/web/e2e/inbox.spec.ts
Normal file
85
apps/web/e2e/inbox.spec.ts
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
import { expect, type Page, test } from "@playwright/test";
|
||||||
|
import type { Group } from "@rakazo/contracts";
|
||||||
|
import { captureScreenshot, routePatchRpc, rpc, signup, waitForChatComposer } from "./helpers";
|
||||||
|
|
||||||
|
test("answers a specialist's question from the main conversation", async ({ page }, testInfo) => {
|
||||||
|
const stamp = Date.now();
|
||||||
|
await signup(page, `inbox-${stamp}@rakazo.test`, "password12", "Inbox");
|
||||||
|
// Bots come from RPC: this spec is about the inbox, not the onboarding flow.
|
||||||
|
await page.waitForURL(/\/(onboarding|app)/, { timeout: 30_000 });
|
||||||
|
const researcher = await createBot(page, "Researcher");
|
||||||
|
|
||||||
|
await rpc(page, "threads/send", { botId: researcher.id, text: "ask me which city to use" });
|
||||||
|
await expect
|
||||||
|
.poll(async () => (await activeRun(page, researcher.id))?.status ?? null, { timeout: 60_000 })
|
||||||
|
.toBe("waiting_input");
|
||||||
|
|
||||||
|
await openMainConversation(page, researcher.id);
|
||||||
|
const inbox = page.getByRole("region", { name: "Needs you" });
|
||||||
|
const ask = inbox.locator("[data-kind='ask']");
|
||||||
|
await expect(ask).toBeVisible({ timeout: 30_000 });
|
||||||
|
await expect(ask).toContainText("Researcher");
|
||||||
|
await expect(ask).toContainText("Which city should I use?");
|
||||||
|
await expect(page.getByRole("combobox", { name: "Message" })).toBeInViewport();
|
||||||
|
await captureScreenshot(page, testInfo, "inbox-ask");
|
||||||
|
|
||||||
|
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(async () => activeRun(page, researcher.id), { timeout: 60_000 }).toBeNull();
|
||||||
|
|
||||||
|
// The answer resumed the specialist in its own thread; its reply arrives as unread.
|
||||||
|
const reply = inbox.locator("[data-kind='unread']").filter({ hasText: "Researcher" });
|
||||||
|
await expect(reply).toContainText("Paris", { timeout: 30_000 });
|
||||||
|
await captureScreenshot(page, testInfo, "inbox-unread-reply");
|
||||||
|
|
||||||
|
await reply.getByRole("button").click();
|
||||||
|
await page.waitForURL(new RegExp(`/app/${researcher.id}$`));
|
||||||
|
await expect(page.getByText("Answered: Paris", { exact: true })).toBeVisible({ timeout: 30_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
async function createBot(page: Page, name: string) {
|
||||||
|
return rpc<{ id: string }>(page, "bots/create", {
|
||||||
|
name,
|
||||||
|
title: "",
|
||||||
|
description: "",
|
||||||
|
notifyOnFinish: true,
|
||||||
|
computerMode: "team",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function activeRun(page: Page, botId: string) {
|
||||||
|
const snapshot = await rpc<{ run?: { status: string } | null }>(page, "threads/get", { botId });
|
||||||
|
return snapshot.run ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens the user's main conversation. Until the app sets one up, a group with the specialist
|
||||||
|
* stands in for it and is reported to the client as the main conversation.
|
||||||
|
*/
|
||||||
|
async function openMainConversation(page: Page, specialistId: string) {
|
||||||
|
// The shell's first load is where the app sets up the main conversation.
|
||||||
|
await rpc(page, "bootstrap", {});
|
||||||
|
const groups = await rpc<Group[]>(page, "groups/list", {});
|
||||||
|
let main = groups.find((group) => group.kind === "main");
|
||||||
|
if (!main) {
|
||||||
|
const lead = await createBot(page, "Lead");
|
||||||
|
const stand = await rpc<Group>(page, "groups/create", {
|
||||||
|
name: "Main",
|
||||||
|
botIds: [lead.id, specialistId],
|
||||||
|
});
|
||||||
|
const asMain = (list: unknown) =>
|
||||||
|
Array.isArray(list)
|
||||||
|
? list.map((group) => (group?.id === stand.id ? { ...group, kind: "main" } : group))
|
||||||
|
: list;
|
||||||
|
await routePatchRpc(page, "bootstrap", (json) => ({ ...json, groups: asMain(json.groups) }));
|
||||||
|
await routePatchRpc(page, "spaces/list", (json) => {
|
||||||
|
const current = json.current as { groups?: unknown } | undefined;
|
||||||
|
return current ? { ...json, current: { ...current, groups: asMain(current.groups) } } : json;
|
||||||
|
});
|
||||||
|
main = stand;
|
||||||
|
}
|
||||||
|
await page.goto(`/app/g/${main.id}`, { waitUntil: "domcontentloaded" });
|
||||||
|
await waitForChatComposer(page);
|
||||||
|
}
|
||||||
@ -3,7 +3,7 @@ import { Trans, useLingui } from "@lingui/react/macro";
|
|||||||
import { ChatMarkdown } from "@rakazo/chat-ui/web";
|
import { ChatMarkdown } from "@rakazo/chat-ui/web";
|
||||||
import type { ThreadMessage } from "@rakazo/contracts";
|
import type { ThreadMessage } from "@rakazo/contracts";
|
||||||
import { isApprovalAskBlock, isSecretAskBlock, selectedAskActionLabel } from "@rakazo/core";
|
import { isApprovalAskBlock, isSecretAskBlock, selectedAskActionLabel } from "@rakazo/core";
|
||||||
import { Button, Input } from "@rakazo/ui-web";
|
import { Button, cn, Input } from "@rakazo/ui-web";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
export type AskBlock = Extract<ThreadMessage["blocks"][number], { kind: "ask" }>;
|
export type AskBlock = Extract<ThreadMessage["blocks"][number], { kind: "ask" }>;
|
||||||
@ -49,10 +49,12 @@ export function AskCard({
|
|||||||
block,
|
block,
|
||||||
canAnswer,
|
canAnswer,
|
||||||
onAnswer,
|
onAnswer,
|
||||||
|
className,
|
||||||
}: {
|
}: {
|
||||||
block: AskBlock;
|
block: AskBlock;
|
||||||
canAnswer: boolean;
|
canAnswer: boolean;
|
||||||
onAnswer: (text: string) => Promise<void>;
|
onAnswer: (text: string) => Promise<void>;
|
||||||
|
className?: string;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useLingui();
|
const { t } = useLingui();
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
@ -86,7 +88,7 @@ export function AskCard({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-testid={secretInput ? "secret-ask-card" : undefined}
|
data-testid={secretInput ? "secret-ask-card" : undefined}
|
||||||
className="max-w-[74%] rounded-2xl border border-border bg-card px-5 py-4"
|
className={cn("max-w-[74%] rounded-2xl border border-border bg-card px-5 py-4", className)}
|
||||||
>
|
>
|
||||||
<div className="text-ui-body leading-[1.5] text-foreground">
|
<div className="text-ui-body leading-[1.5] text-foreground">
|
||||||
<ChatMarkdown>{block.text}</ChatMarkdown>
|
<ChatMarkdown>{block.text}</ChatMarkdown>
|
||||||
|
|||||||
219
apps/web/src/components/MainInbox.test.tsx
Normal file
219
apps/web/src/components/MainInbox.test.tsx
Normal file
@ -0,0 +1,219 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
|
||||||
|
import type { InboxItem } from "@rakazo/contracts";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { act } from "react";
|
||||||
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
|
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const api = vi.hoisted(() => ({ list: vi.fn(), answer: vi.fn(), markRead: vi.fn() }));
|
||||||
|
vi.mock("../lib/rpc", () => ({
|
||||||
|
rpc: { inbox: { list: api.list }, threads: { answer: api.answer, markRead: api.markRead } },
|
||||||
|
}));
|
||||||
|
vi.mock("@lingui/react/macro", () => {
|
||||||
|
const t = (parts: TemplateStringsArray) => parts.join("");
|
||||||
|
return { useLingui: () => ({ t }), Trans: ({ children }: { children: ReactNode }) => children };
|
||||||
|
});
|
||||||
|
vi.mock("@rakazo/ui-web", () => ({
|
||||||
|
BotAvatar: () => <span data-avatar="" />,
|
||||||
|
parseBotAvatar: (color: string) => ({ color }),
|
||||||
|
resolvePersonaColorDef: (_id: string, color: string) => ({ light: color }),
|
||||||
|
}));
|
||||||
|
vi.mock("./AskCard", () => ({
|
||||||
|
AskCard: ({
|
||||||
|
block,
|
||||||
|
onAnswer,
|
||||||
|
}: {
|
||||||
|
block: { text: string };
|
||||||
|
onAnswer: (text: string) => Promise<void>;
|
||||||
|
}) => (
|
||||||
|
// Like the real card, show a failed answer instead of rejecting.
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-answer={block.text}
|
||||||
|
onClick={() => void onAnswer("Paris").catch(() => undefined)}
|
||||||
|
>
|
||||||
|
{block.text}
|
||||||
|
</button>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { MainInbox } from "./MainInbox";
|
||||||
|
|
||||||
|
const scout = { id: "bot-scout", name: "Scout", color: "#3B82F6" };
|
||||||
|
const writer = { id: "bot-writer", name: "Writer", color: "#D9508A" };
|
||||||
|
const createdAt = "2026-10-02T09:00:00.000Z";
|
||||||
|
|
||||||
|
const askInOwnThread: InboxItem = {
|
||||||
|
kind: "ask",
|
||||||
|
bot: scout,
|
||||||
|
target: { botId: scout.id },
|
||||||
|
runId: "run-ask",
|
||||||
|
messageId: "msg-ask",
|
||||||
|
ask: { kind: "ask", text: "Which city should I use?", status: "pending" },
|
||||||
|
createdAt,
|
||||||
|
};
|
||||||
|
const approvalInGroup: InboxItem = {
|
||||||
|
kind: "approval",
|
||||||
|
bot: writer,
|
||||||
|
target: { groupId: "group-ops" },
|
||||||
|
runId: "run-approval",
|
||||||
|
messageId: "msg-approval",
|
||||||
|
ask: { kind: "ask", text: "Review before writing to notes", status: "pending" },
|
||||||
|
createdAt,
|
||||||
|
};
|
||||||
|
const takeover: InboxItem = {
|
||||||
|
kind: "takeover",
|
||||||
|
bot: writer,
|
||||||
|
target: { botId: writer.id },
|
||||||
|
runId: "run-takeover",
|
||||||
|
messageId: "msg-takeover",
|
||||||
|
text: "Sign in to continue.",
|
||||||
|
createdAt,
|
||||||
|
};
|
||||||
|
const unread: InboxItem = {
|
||||||
|
kind: "unread",
|
||||||
|
bot: scout,
|
||||||
|
target: { botId: scout.id },
|
||||||
|
messageId: "msg-unread",
|
||||||
|
text: "Draft ready for review",
|
||||||
|
createdAt,
|
||||||
|
};
|
||||||
|
|
||||||
|
const idleBots = [
|
||||||
|
{ id: scout.id, status: "idle", unread: false, preview: "" },
|
||||||
|
{ id: writer.id, status: "idle", unread: false, preview: "" },
|
||||||
|
];
|
||||||
|
|
||||||
|
let container: HTMLDivElement;
|
||||||
|
let root: Root;
|
||||||
|
const onOpenBot = vi.fn();
|
||||||
|
const onOpenGroup = vi.fn();
|
||||||
|
|
||||||
|
function render(bots = idleBots) {
|
||||||
|
return act(async () =>
|
||||||
|
root.render(<MainInbox bots={bots} onOpenBot={onOpenBot} onOpenGroup={onOpenGroup} />),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function itemOf(kind: InboxItem["kind"]) {
|
||||||
|
const found = container.querySelector<HTMLElement>(
|
||||||
|
`[data-testid='main-inbox-item'][data-kind='${kind}']`,
|
||||||
|
);
|
||||||
|
if (!found) throw new Error(`Missing ${kind} item`);
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
|
||||||
|
api.list.mockReset();
|
||||||
|
api.answer.mockReset();
|
||||||
|
api.markRead.mockReset().mockResolvedValue({ ok: true });
|
||||||
|
onOpenBot.mockReset();
|
||||||
|
onOpenGroup.mockReset();
|
||||||
|
container = document.createElement("div");
|
||||||
|
document.body.append(container);
|
||||||
|
root = createRoot(container);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await act(async () => root.unmount());
|
||||||
|
container.remove();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders nothing until a bot needs the user", async () => {
|
||||||
|
api.list.mockResolvedValue({ items: [] });
|
||||||
|
await render();
|
||||||
|
|
||||||
|
expect(api.list).toHaveBeenCalledOnce();
|
||||||
|
expect(container.querySelector("[data-testid='main-inbox']")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows each item with its bot as the sender", async () => {
|
||||||
|
api.list.mockResolvedValue({ items: [askInOwnThread, approvalInGroup, takeover, unread] });
|
||||||
|
await render();
|
||||||
|
|
||||||
|
const inbox = container.querySelector("[data-testid='main-inbox']");
|
||||||
|
expect(inbox?.getAttribute("aria-label")).toBe("Needs you");
|
||||||
|
expect(itemOf("ask").textContent).toContain("Scout");
|
||||||
|
expect(itemOf("ask").textContent).toContain("Which city should I use?");
|
||||||
|
expect(itemOf("approval").textContent).toContain("Writer");
|
||||||
|
expect(itemOf("takeover").textContent).toContain("Sign in to continue.");
|
||||||
|
expect(itemOf("unread").textContent).toContain("Draft ready for review");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("answers through the thread the ask came from and drops only that item", async () => {
|
||||||
|
api.list
|
||||||
|
.mockResolvedValueOnce({ items: [askInOwnThread, approvalInGroup] })
|
||||||
|
.mockReturnValue(new Promise(() => undefined));
|
||||||
|
api.answer.mockResolvedValue({ ok: true });
|
||||||
|
await render();
|
||||||
|
|
||||||
|
await act(async () =>
|
||||||
|
itemOf("approval").querySelector<HTMLButtonElement>("[data-answer]")?.click(),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(api.answer).toHaveBeenCalledWith({
|
||||||
|
groupId: "group-ops",
|
||||||
|
runId: "run-approval",
|
||||||
|
messageId: "msg-approval",
|
||||||
|
answer: "Paris",
|
||||||
|
});
|
||||||
|
expect(api.markRead).not.toHaveBeenCalled();
|
||||||
|
expect(api.list).toHaveBeenCalledTimes(2);
|
||||||
|
expect(container.querySelectorAll("[data-testid='main-inbox-item']")).toHaveLength(1);
|
||||||
|
expect(itemOf("ask")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks the bot's own thread read before answering, so the question does not return as unread", async () => {
|
||||||
|
api.list.mockResolvedValueOnce({ items: [askInOwnThread] }).mockResolvedValue({ items: [] });
|
||||||
|
api.answer.mockResolvedValue({ ok: true });
|
||||||
|
await render();
|
||||||
|
|
||||||
|
await act(async () => itemOf("ask").querySelector<HTMLButtonElement>("[data-answer]")?.click());
|
||||||
|
|
||||||
|
expect(api.markRead).toHaveBeenCalledWith({ botId: scout.id });
|
||||||
|
expect(api.markRead.mock.invocationCallOrder[0]).toBeLessThan(
|
||||||
|
api.answer.mock.invocationCallOrder[0]!,
|
||||||
|
);
|
||||||
|
expect(container.querySelector("[data-testid='main-inbox']")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refreshes after a failed answer so a stale ask disappears", async () => {
|
||||||
|
api.list.mockResolvedValueOnce({ items: [askInOwnThread] }).mockResolvedValue({ items: [] });
|
||||||
|
api.answer.mockRejectedValue(new Error("This prompt is no longer awaiting an answer"));
|
||||||
|
await render();
|
||||||
|
|
||||||
|
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(container.querySelector("[data-testid='main-inbox']")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens the bot's thread for takeovers and unread threads, and the source thread for asks", async () => {
|
||||||
|
api.list.mockResolvedValue({ items: [approvalInGroup, takeover, unread] });
|
||||||
|
await render();
|
||||||
|
|
||||||
|
await act(async () => itemOf("takeover").querySelector("button")?.click());
|
||||||
|
await act(async () => itemOf("unread").querySelector("button")?.click());
|
||||||
|
await act(async () => itemOf("approval").querySelector("button")?.click());
|
||||||
|
|
||||||
|
expect(onOpenBot.mock.calls).toEqual([[writer.id], [scout.id]]);
|
||||||
|
expect(onOpenGroup.mock.calls).toEqual([["group-ops"]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refreshes when a bot's state changes, not on every render", async () => {
|
||||||
|
api.list.mockResolvedValue({ items: [] });
|
||||||
|
await render();
|
||||||
|
await render([...idleBots]);
|
||||||
|
expect(api.list).toHaveBeenCalledOnce();
|
||||||
|
|
||||||
|
await render([{ ...idleBots[0]!, status: "waiting_input" }, idleBots[1]!]);
|
||||||
|
expect(api.list).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
169
apps/web/src/components/MainInbox.tsx
Normal file
169
apps/web/src/components/MainInbox.tsx
Normal file
@ -0,0 +1,169 @@
|
|||||||
|
import { useLingui } from "@lingui/react/macro";
|
||||||
|
import type { Bot, InboxItem, InboxThreadTarget } from "@rakazo/contracts";
|
||||||
|
import { BotAvatar, parseBotAvatar, resolvePersonaColorDef } from "@rakazo/ui-web";
|
||||||
|
import { Monitor } from "lucide-react";
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { inboxItemKey, inboxRefreshSignal } from "../lib/main-inbox";
|
||||||
|
import { rpc } from "../lib/rpc";
|
||||||
|
import { AskCard } from "./AskCard";
|
||||||
|
|
||||||
|
/** Catches what the bots' state does not reveal, such as a second ask with the same text. */
|
||||||
|
const INBOX_POLL_MS = 15_000;
|
||||||
|
|
||||||
|
type PendingAskItem = Extract<InboxItem, { kind: "ask" | "approval" }>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
export function MainInbox({
|
||||||
|
bots,
|
||||||
|
onOpenBot,
|
||||||
|
onOpenGroup,
|
||||||
|
}: {
|
||||||
|
/** 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;
|
||||||
|
}) {
|
||||||
|
const { t } = useLingui();
|
||||||
|
const [items, setItems] = useState<InboxItem[]>([]);
|
||||||
|
const latestRequest = useRef(0);
|
||||||
|
const signal = useMemo(() => inboxRefreshSignal(bots), [bots]);
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
const request = ++latestRequest.current;
|
||||||
|
try {
|
||||||
|
const listed = await rpc.inbox.list();
|
||||||
|
if (request === latestRequest.current) setItems(listed.items);
|
||||||
|
} catch {
|
||||||
|
// Keep the last list; the next refresh retries.
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// `signal` is the trigger: refetch whenever the bots' state changes.
|
||||||
|
useEffect(() => {
|
||||||
|
void refresh();
|
||||||
|
}, [refresh, signal]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = window.setInterval(() => {
|
||||||
|
if (document.visibilityState === "visible") void refresh();
|
||||||
|
}, INBOX_POLL_MS);
|
||||||
|
return () => {
|
||||||
|
window.clearInterval(timer);
|
||||||
|
// Ignore responses that arrive after unmount.
|
||||||
|
latestRequest.current += 1;
|
||||||
|
};
|
||||||
|
}, [refresh]);
|
||||||
|
|
||||||
|
const open = useCallback(
|
||||||
|
(target: InboxThreadTarget) => {
|
||||||
|
if ("botId" in target) onOpenBot(target.botId);
|
||||||
|
else onOpenGroup(target.groupId);
|
||||||
|
},
|
||||||
|
[onOpenBot, onOpenGroup],
|
||||||
|
);
|
||||||
|
|
||||||
|
const answer = useCallback(
|
||||||
|
async (item: PendingAskItem, text: string) => {
|
||||||
|
try {
|
||||||
|
// 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.answer({
|
||||||
|
...item.target,
|
||||||
|
runId: item.runId,
|
||||||
|
messageId: item.messageId,
|
||||||
|
answer: text,
|
||||||
|
});
|
||||||
|
const key = inboxItemKey(item);
|
||||||
|
setItems((current) => current.filter((entry) => inboxItemKey(entry) !== key));
|
||||||
|
} finally {
|
||||||
|
void refresh();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[refresh],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (items.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
// Shrinks and scrolls before it can push the composer out of view.
|
||||||
|
<section
|
||||||
|
aria-label={t`Needs you`}
|
||||||
|
data-testid="main-inbox"
|
||||||
|
className="max-h-[40vh] min-h-0 overflow-y-auto px-3 md:px-6"
|
||||||
|
>
|
||||||
|
<ul className="mx-auto flex w-full max-w-4xl flex-col gap-3 border-t border-border pt-3">
|
||||||
|
{items.map((item) => (
|
||||||
|
<li key={inboxItemKey(item)} data-testid="main-inbox-item" data-kind={item.kind}>
|
||||||
|
{item.kind === "ask" || item.kind === "approval" ? (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => open(item.target)}
|
||||||
|
className="w-fit rounded-md hover:underline"
|
||||||
|
>
|
||||||
|
<Sender bot={item.bot} />
|
||||||
|
</button>
|
||||||
|
<AskCard
|
||||||
|
block={item.ask}
|
||||||
|
canAnswer
|
||||||
|
onAnswer={(text) => answer(item, text)}
|
||||||
|
className="max-w-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => open(item.target)}
|
||||||
|
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} />
|
||||||
|
{item.text ? (
|
||||||
|
<span
|
||||||
|
className="mt-0.5 line-clamp-2 text-ui-body-sm text-muted-foreground"
|
||||||
|
dir="auto"
|
||||||
|
>
|
||||||
|
{item.text}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
|
{item.kind === "takeover" ? (
|
||||||
|
<Monitor
|
||||||
|
aria-hidden="true"
|
||||||
|
className="mt-0.5 size-icon-md shrink-0 text-warning"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="mt-2 size-2 shrink-0 rounded-full bg-primary"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Sender({ bot }: { bot: InboxItem["bot"] }) {
|
||||||
|
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"
|
||||||
|
style={{ color }}
|
||||||
|
dir="auto"
|
||||||
|
>
|
||||||
|
<BotAvatar color={bot.color} identity={bot.id} size={22} className="shrink-0" />
|
||||||
|
<span className="truncate">{bot.name}</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
40
apps/web/src/lib/main-inbox.test.ts
Normal file
40
apps/web/src/lib/main-inbox.test.ts
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import type { InboxItem } from "@rakazo/contracts";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { inboxItemKey, inboxRefreshSignal } from "./main-inbox";
|
||||||
|
|
||||||
|
const bot = { id: "bot-scout", name: "Scout", color: "#3B82F6" };
|
||||||
|
const base = { bot, target: { botId: bot.id }, createdAt: "2026-10-02T09:00:00.000Z" };
|
||||||
|
const ask = { kind: "ask", text: "Which city?", status: "pending" } as const;
|
||||||
|
|
||||||
|
describe("inboxItemKey", () => {
|
||||||
|
it("keys asks by message, takeovers by run, and unread threads by bot", () => {
|
||||||
|
const items: InboxItem[] = [
|
||||||
|
{ ...base, kind: "ask", runId: "run-1", messageId: "msg-1", ask },
|
||||||
|
{ ...base, kind: "approval", runId: "run-2", messageId: "msg-2", ask },
|
||||||
|
{ ...base, kind: "takeover", runId: "run-3", messageId: null, text: "" },
|
||||||
|
{ ...base, kind: "unread", messageId: "msg-4", text: "hi" },
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(items.map(inboxItemKey)).toEqual([
|
||||||
|
"ask:msg-1",
|
||||||
|
"ask:msg-2",
|
||||||
|
"takeover:run-3",
|
||||||
|
`unread:${bot.id}`,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("inboxRefreshSignal", () => {
|
||||||
|
const idle = { id: bot.id, status: "idle", unread: false, preview: "done" };
|
||||||
|
|
||||||
|
it("stays the same while nothing the inbox shows has changed", () => {
|
||||||
|
expect(inboxRefreshSignal([idle])).toBe(inboxRefreshSignal([{ ...idle }]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("changes with run status, unread state, or a new message", () => {
|
||||||
|
const signal = inboxRefreshSignal([idle]);
|
||||||
|
expect(inboxRefreshSignal([{ ...idle, status: "waiting_input" }])).not.toBe(signal);
|
||||||
|
expect(inboxRefreshSignal([{ ...idle, unread: true }])).not.toBe(signal);
|
||||||
|
expect(inboxRefreshSignal([{ ...idle, preview: "Which city?" }])).not.toBe(signal);
|
||||||
|
});
|
||||||
|
});
|
||||||
24
apps/web/src/lib/main-inbox.ts
Normal file
24
apps/web/src/lib/main-inbox.ts
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import type { Bot, InboxItem } from "@rakazo/contracts";
|
||||||
|
|
||||||
|
/** One item per pending ask message, waiting takeover run, or unread bot thread. */
|
||||||
|
export function inboxItemKey(item: InboxItem): string {
|
||||||
|
switch (item.kind) {
|
||||||
|
case "ask":
|
||||||
|
case "approval":
|
||||||
|
return `ask:${item.messageId}`;
|
||||||
|
case "takeover":
|
||||||
|
return `takeover:${item.runId}`;
|
||||||
|
case "unread":
|
||||||
|
return `unread:${item.bot.id}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Changes when a bot's run status, unread flag or newest message changes, which is when the
|
||||||
|
* inbox may have changed. The shell already keeps these fresh.
|
||||||
|
*/
|
||||||
|
export function inboxRefreshSignal(
|
||||||
|
bots: readonly Pick<Bot, "id" | "status" | "unread" | "preview">[],
|
||||||
|
): string {
|
||||||
|
return bots.map((bot) => `${bot.id}:${bot.status}:${bot.unread}:${bot.preview}`).join("\n");
|
||||||
|
}
|
||||||
@ -166,6 +166,7 @@ import {
|
|||||||
} from "../components/ComputersUnavailableHint";
|
} from "../components/ComputersUnavailableHint";
|
||||||
import { ComputerUpdateProgress } from "../components/ComputerUpdateProgress";
|
import { ComputerUpdateProgress } from "../components/ComputerUpdateProgress";
|
||||||
import { DataTrustPanel } from "../components/DataTrustPanel";
|
import { DataTrustPanel } from "../components/DataTrustPanel";
|
||||||
|
import { MainInbox } from "../components/MainInbox";
|
||||||
import { MessageHoverMetadata } from "../components/MessageHoverMetadata";
|
import { MessageHoverMetadata } from "../components/MessageHoverMetadata";
|
||||||
import { OoGroupStage, OoStage } from "../components/stage/OoStage";
|
import { OoGroupStage, OoStage } from "../components/stage/OoStage";
|
||||||
import { TradingModeIndicator } from "../components/TradingModeIndicator";
|
import { TradingModeIndicator } from "../components/TradingModeIndicator";
|
||||||
@ -4593,6 +4594,9 @@ export function ShellPage() {
|
|||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{activeGroup?.kind === "main" && !showProjectTimeline ? (
|
||||||
|
<MainInbox bots={bots} onOpenBot={openBot} onOpenGroup={openGroup} />
|
||||||
|
) : null}
|
||||||
{recordingSkill ? (
|
{recordingSkill ? (
|
||||||
<div className="px-6 pb-2 text-center text-ui-body-sm text-destructive">
|
<div className="px-6 pb-2 text-center text-ui-body-sm text-destructive">
|
||||||
<Trans>Teaching in progress. Stop teaching before sending a new message.</Trans>
|
<Trans>Teaching in progress. Stop teaching before sending a new message.</Trans>
|
||||||
@ -5967,7 +5971,8 @@ const Transcript = memo(function Transcript({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex min-h-0 flex-1">
|
// Clip the scroller's padding when the column leaves no room, e.g. beside the main inbox.
|
||||||
|
<div className="relative flex min-h-0 flex-1 overflow-hidden">
|
||||||
<div
|
<div
|
||||||
ref={scrollRef}
|
ref={scrollRef}
|
||||||
data-testid="transcript"
|
data-testid="transcript"
|
||||||
|
|||||||
@ -100,33 +100,36 @@ export const DELEGATED_TASK_STATUSES = [
|
|||||||
export const DelegatedTaskStatusSchema = z.enum(DELEGATED_TASK_STATUSES);
|
export const DelegatedTaskStatusSchema = z.enum(DELEGATED_TASK_STATUSES);
|
||||||
export type DelegatedTaskStatus = z.infer<typeof DelegatedTaskStatusSchema>;
|
export type DelegatedTaskStatus = z.infer<typeof DelegatedTaskStatusSchema>;
|
||||||
|
|
||||||
|
/** A question or approval that pauses its run until the user answers. */
|
||||||
|
export const AskBlockSchema = z.object({
|
||||||
|
kind: z.literal("ask"),
|
||||||
|
text: z.string(),
|
||||||
|
approvalEffectId: Id.optional(),
|
||||||
|
detail: z.string().optional(),
|
||||||
|
input: z.enum(["text", "secret"]).optional(),
|
||||||
|
/** Why the secret is needed; drives field label on the masked card. */
|
||||||
|
purpose: SecretAskPurpose.optional(),
|
||||||
|
credential: BotSecretDestination.optional(),
|
||||||
|
status: z.enum(["pending", "answered"]).optional(),
|
||||||
|
answer: z.string().optional(),
|
||||||
|
actions: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
id: z.string(),
|
||||||
|
label: z.string(),
|
||||||
|
outcome: z.enum(["created", "cancelled"]).optional(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.optional(),
|
||||||
|
});
|
||||||
|
|
||||||
export const MessageBlock = z.discriminatedUnion("kind", [
|
export const MessageBlock = z.discriminatedUnion("kind", [
|
||||||
z.object({ kind: z.literal("text"), text: z.string() }),
|
z.object({ kind: z.literal("text"), text: z.string() }),
|
||||||
z.object({
|
z.object({
|
||||||
kind: z.literal("card"),
|
kind: z.literal("card"),
|
||||||
lines: z.array(z.object({ k: z.string(), v: z.string() })),
|
lines: z.array(z.object({ k: z.string(), v: z.string() })),
|
||||||
}),
|
}),
|
||||||
z.object({
|
AskBlockSchema,
|
||||||
kind: z.literal("ask"),
|
|
||||||
text: z.string(),
|
|
||||||
approvalEffectId: Id.optional(),
|
|
||||||
detail: z.string().optional(),
|
|
||||||
input: z.enum(["text", "secret"]).optional(),
|
|
||||||
/** Why the secret is needed; drives field label on the masked card. */
|
|
||||||
purpose: SecretAskPurpose.optional(),
|
|
||||||
credential: BotSecretDestination.optional(),
|
|
||||||
status: z.enum(["pending", "answered"]).optional(),
|
|
||||||
answer: z.string().optional(),
|
|
||||||
actions: z
|
|
||||||
.array(
|
|
||||||
z.object({
|
|
||||||
id: z.string(),
|
|
||||||
label: z.string(),
|
|
||||||
outcome: z.enum(["created", "cancelled"]).optional(),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.optional(),
|
|
||||||
}),
|
|
||||||
z.object({
|
z.object({
|
||||||
kind: z.literal("choice"),
|
kind: z.literal("choice"),
|
||||||
question: z.string(),
|
question: z.string(),
|
||||||
|
|||||||
50
packages/contracts/src/inbox.ts
Normal file
50
packages/contracts/src/inbox.ts
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
import * as z from "zod";
|
||||||
|
import { AskBlockSchema } from "./events.js";
|
||||||
|
import { Id } from "./ids.js";
|
||||||
|
|
||||||
|
/** The thread an item belongs to, in the shape the thread procedures accept. */
|
||||||
|
export const InboxThreadTargetSchema = z.union([
|
||||||
|
z.object({ botId: Id }),
|
||||||
|
z.object({ groupId: Id }),
|
||||||
|
]);
|
||||||
|
export type InboxThreadTarget = z.infer<typeof InboxThreadTargetSchema>;
|
||||||
|
|
||||||
|
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,
|
||||||
|
createdAt: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const PendingAskItem = InboxItemBase.extend({
|
||||||
|
runId: Id,
|
||||||
|
/** The bot message that holds the pending ask; answer it with threads.answer. */
|
||||||
|
messageId: Id,
|
||||||
|
ask: AskBlockSchema,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
export const InboxItemSchema = z.discriminatedUnion("kind", [
|
||||||
|
PendingAskItem.extend({ kind: z.literal("ask") }),
|
||||||
|
PendingAskItem.extend({ kind: z.literal("approval") }),
|
||||||
|
InboxItemBase.extend({
|
||||||
|
kind: z.literal("takeover"),
|
||||||
|
runId: Id,
|
||||||
|
messageId: Id.nullable(),
|
||||||
|
/** Why the bot needs the screen. */
|
||||||
|
text: z.string(),
|
||||||
|
}),
|
||||||
|
InboxItemBase.extend({
|
||||||
|
kind: z.literal("unread"),
|
||||||
|
messageId: Id.nullable(),
|
||||||
|
/** Preview of the newest bot message. */
|
||||||
|
text: z.string(),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
export type InboxItem = z.infer<typeof InboxItemSchema>;
|
||||||
|
|
||||||
|
export const InboxListOutputSchema = z.object({ items: z.array(InboxItemSchema) });
|
||||||
|
export type InboxListOutput = z.infer<typeof InboxListOutputSchema>;
|
||||||
@ -5,6 +5,7 @@ export * from "./desktop.js";
|
|||||||
export * from "./domain.js";
|
export * from "./domain.js";
|
||||||
export * from "./events.js";
|
export * from "./events.js";
|
||||||
export * from "./ids.js";
|
export * from "./ids.js";
|
||||||
|
export * from "./inbox.js";
|
||||||
export * from "./integration-settings.js";
|
export * from "./integration-settings.js";
|
||||||
export * from "./local-settings.js";
|
export * from "./local-settings.js";
|
||||||
export * from "./mcp.js";
|
export * from "./mcp.js";
|
||||||
|
|||||||
@ -77,6 +77,7 @@ import {
|
|||||||
} from "./domain.js";
|
} from "./domain.js";
|
||||||
import { DelegatedTaskStatusSchema, ProductEventSchema } from "./events.js";
|
import { DelegatedTaskStatusSchema, ProductEventSchema } from "./events.js";
|
||||||
import { Id, IsoDate } from "./ids.js";
|
import { Id, IsoDate } from "./ids.js";
|
||||||
|
import { InboxListOutputSchema } from "./inbox.js";
|
||||||
import {
|
import {
|
||||||
IntegrationProviderConfigSchema,
|
IntegrationProviderConfigSchema,
|
||||||
IntegrationSetupStateSchema,
|
IntegrationSetupStateSchema,
|
||||||
@ -801,6 +802,10 @@ export const appContract = {
|
|||||||
.input(z.object({ threadId: Id.optional(), status: DelegatedTaskStatusSchema.optional() }))
|
.input(z.object({ threadId: Id.optional(), status: DelegatedTaskStatusSchema.optional() }))
|
||||||
.output(z.array(DelegatedTaskSchema)),
|
.output(z.array(DelegatedTaskSchema)),
|
||||||
},
|
},
|
||||||
|
/** Asks, approvals, takeovers and unread threads outside the main conversation. */
|
||||||
|
inbox: {
|
||||||
|
list: oc.output(InboxListOutputSchema),
|
||||||
|
},
|
||||||
voice: {
|
voice: {
|
||||||
catalog: oc.output(z.array(VoiceCatalogEntrySchema)),
|
catalog: oc.output(z.array(VoiceCatalogEntrySchema)),
|
||||||
status: oc.output(VoiceStatusSchema),
|
status: oc.output(VoiceStatusSchema),
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import type { AiConsentStatus, appContract, Space, SpaceNavigation } from "@raka
|
|||||||
import {
|
import {
|
||||||
claimEmptySpaceDeletionForMember,
|
claimEmptySpaceDeletionForMember,
|
||||||
computerHomeKey,
|
computerHomeKey,
|
||||||
|
createThreadMessage,
|
||||||
deleteEmptySpaceForMember,
|
deleteEmptySpaceForMember,
|
||||||
releaseSpaceDeletionClaim,
|
releaseSpaceDeletionClaim,
|
||||||
renewSpaceDeletionClaim,
|
renewSpaceDeletionClaim,
|
||||||
@ -188,6 +189,7 @@ describeWithDatabase("API authorization and resource isolation", () => {
|
|||||||
["notifications/registerPush", { token: "ExponentPushToken[not-real]" }],
|
["notifications/registerPush", { token: "ExponentPushToken[not-real]" }],
|
||||||
["search/query", { q: "anything" }],
|
["search/query", { q: "anything" }],
|
||||||
["tasks/list", {}],
|
["tasks/list", {}],
|
||||||
|
["inbox/list"],
|
||||||
["voice/catalog"],
|
["voice/catalog"],
|
||||||
["voice/status"],
|
["voice/status"],
|
||||||
["voice/credentials"],
|
["voice/credentials"],
|
||||||
@ -309,6 +311,13 @@ describeWithDatabase("API authorization and resource isolation", () => {
|
|||||||
trigger: "user",
|
trigger: "user",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
await createThreadMessage(handles.prisma, {
|
||||||
|
threadId: ownerThread.id,
|
||||||
|
role: "bot",
|
||||||
|
botId: ownerBot.id,
|
||||||
|
runId: ownerRun.id,
|
||||||
|
blocks: [{ kind: "ask", text: "Owner-only question", status: "pending" }],
|
||||||
|
});
|
||||||
|
|
||||||
const botIdCalls: Array<[string, unknown]> = [
|
const botIdCalls: Array<[string, unknown]> = [
|
||||||
["bots/get", { botId: ownerBot.id }],
|
["bots/get", { botId: ownerBot.id }],
|
||||||
@ -461,6 +470,13 @@ describeWithDatabase("API authorization and resource isolation", () => {
|
|||||||
expect(await rpc<unknown[]>(app, intruder, "tasks/list", { threadId: ownerThread.id })).toEqual(
|
expect(await rpc<unknown[]>(app, intruder, "tasks/list", { threadId: ownerThread.id })).toEqual(
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
expect(
|
||||||
|
(await rpc<{ items: Array<{ runId?: string }> }>(app, owner, "inbox/list")).items,
|
||||||
|
).toContainEqual(expect.objectContaining({ kind: "ask", runId: ownerRun.id }));
|
||||||
|
const intruderInbox = JSON.stringify(await rpc(app, intruder, "inbox/list"));
|
||||||
|
expect(intruderInbox).not.toContain(ownerBot.id);
|
||||||
|
expect(intruderInbox).not.toContain("Owner-only question");
|
||||||
|
await expectDenied(app, intruder, "inbox/list", {}, ownerActor.spaceId);
|
||||||
|
|
||||||
// These endpoints are deliberately idempotent for unknown IDs. Success must not mutate
|
// These endpoints are deliberately idempotent for unknown IDs. Success must not mutate
|
||||||
// a row in a different workspace or disclose whether it exists.
|
// a row in a different workspace or disclose whether it exists.
|
||||||
@ -588,6 +604,68 @@ describeWithDatabase("API authorization and resource isolation", () => {
|
|||||||
expect(rejoined[0]?.role).toBe("member");
|
expect(rejoined[0]?.role).toBe("member");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps each shared-space member's inbox to their own bots", async () => {
|
||||||
|
const owner = await signup(app, `inbox-owner-${stamp}@rakazo.test`, "Inbox Owner");
|
||||||
|
const member = await signup(app, `inbox-member-${stamp}@rakazo.test`, "Inbox Member");
|
||||||
|
const ownerActor = await rpc<Actor>(app, owner, "me");
|
||||||
|
const memberActor = await rpc<Actor>(app, member, "me");
|
||||||
|
const sharedSpaceId = ownerActor.spaceId;
|
||||||
|
const sharedRow = await handles.prisma.space.findUniqueOrThrow({
|
||||||
|
where: { id: sharedSpaceId },
|
||||||
|
select: { organizationId: true },
|
||||||
|
});
|
||||||
|
await joinMemberOnSpace(handles.prisma, {
|
||||||
|
id: `inbox-org-member-${stamp}`,
|
||||||
|
organizationId: sharedRow.organizationId,
|
||||||
|
spaceId: sharedSpaceId,
|
||||||
|
userId: memberActor.userId,
|
||||||
|
});
|
||||||
|
const ownerBot = await rpc<Bot>(
|
||||||
|
app,
|
||||||
|
owner,
|
||||||
|
"bots/create",
|
||||||
|
botInput("Owner Inbox Bot"),
|
||||||
|
sharedSpaceId,
|
||||||
|
);
|
||||||
|
const thread = await handles.prisma.thread.findUniqueOrThrow({
|
||||||
|
where: { botId: ownerBot.id },
|
||||||
|
});
|
||||||
|
const task = await handles.prisma.task.create({
|
||||||
|
data: {
|
||||||
|
spaceId: sharedSpaceId,
|
||||||
|
userId: ownerActor.userId,
|
||||||
|
botId: ownerBot.id,
|
||||||
|
threadId: thread.id,
|
||||||
|
prompt: "owner prompt",
|
||||||
|
status: "waiting_input",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const run = await handles.prisma.run.create({
|
||||||
|
data: {
|
||||||
|
spaceId: sharedSpaceId,
|
||||||
|
userId: ownerActor.userId,
|
||||||
|
botId: ownerBot.id,
|
||||||
|
threadId: thread.id,
|
||||||
|
taskId: task.id,
|
||||||
|
status: "waiting_input",
|
||||||
|
trigger: "user",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await createThreadMessage(handles.prisma, {
|
||||||
|
threadId: thread.id,
|
||||||
|
role: "bot",
|
||||||
|
botId: ownerBot.id,
|
||||||
|
runId: run.id,
|
||||||
|
blocks: [{ kind: "ask", text: "Owner-only question", status: "pending" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(
|
||||||
|
(await rpc<{ items: Array<{ runId?: string }> }>(app, owner, "inbox/list", {}, sharedSpaceId))
|
||||||
|
.items,
|
||||||
|
).toContainEqual(expect.objectContaining({ kind: "ask", runId: run.id }));
|
||||||
|
expect(await rpc(app, member, "inbox/list", {}, sharedSpaceId)).toEqual({ items: [] });
|
||||||
|
});
|
||||||
|
|
||||||
it("prevents one shared-space member from hijacking another member's Team Computer takeover", async () => {
|
it("prevents one shared-space member from hijacking another member's Team Computer takeover", async () => {
|
||||||
const owner = await signup(app, `takeover-owner-${stamp}@rakazo.test`, "Takeover Owner");
|
const owner = await signup(app, `takeover-owner-${stamp}@rakazo.test`, "Takeover Owner");
|
||||||
const member = await signup(app, `takeover-member-${stamp}@rakazo.test`, "Takeover Member");
|
const member = await signup(app, `takeover-member-${stamp}@rakazo.test`, "Takeover Member");
|
||||||
|
|||||||
@ -100,6 +100,7 @@ async function main() {
|
|||||||
"packages/testkit/src/attachments.test.ts",
|
"packages/testkit/src/attachments.test.ts",
|
||||||
"packages/testkit/src/voice.test.ts",
|
"packages/testkit/src/voice.test.ts",
|
||||||
"packages/testkit/src/search.test.ts",
|
"packages/testkit/src/search.test.ts",
|
||||||
|
"packages/testkit/src/inbox.test.ts",
|
||||||
"packages/testkit/src/executor-lifecycle.test.ts",
|
"packages/testkit/src/executor-lifecycle.test.ts",
|
||||||
"packages/testkit/src/connections.test.ts",
|
"packages/testkit/src/connections.test.ts",
|
||||||
"packages/testkit/src/bot-secrets.test.ts",
|
"packages/testkit/src/bot-secrets.test.ts",
|
||||||
|
|||||||
317
packages/testkit/src/inbox.test.ts
Normal file
317
packages/testkit/src/inbox.test.ts
Normal file
@ -0,0 +1,317 @@
|
|||||||
|
import { mkdtempSync, rmSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import type { InboxItem, MessageBlock, ThreadSnapshot } from "@rakazo/contracts";
|
||||||
|
import {
|
||||||
|
addMainConversationMembers,
|
||||||
|
createThreadMessage,
|
||||||
|
ensureMainConversation,
|
||||||
|
type PrismaClient,
|
||||||
|
} from "@rakazo/db";
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||||
|
import { sessionCookieHeader } from "./index.js";
|
||||||
|
|
||||||
|
type App = { request: (input: string, init?: RequestInit) => Response | Promise<Response> };
|
||||||
|
|
||||||
|
process.env.WAKEUP_DRIVER = "memory";
|
||||||
|
process.env.SANDBOX_PROVIDER = "fake";
|
||||||
|
process.env.AGENT_RUNTIME = "scripted";
|
||||||
|
|
||||||
|
const hasDb = process.env.VERIFY_DATABASE === "1" && Boolean(process.env.DATABASE_URL);
|
||||||
|
const describeInbox = hasDb ? describe : describe.skip;
|
||||||
|
|
||||||
|
describeInbox("inbox.list", () => {
|
||||||
|
let app: App;
|
||||||
|
let prisma: PrismaClient;
|
||||||
|
let stop: () => Promise<void>;
|
||||||
|
const stamp = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||||
|
const dataDir = mkdtempSync(path.join(tmpdir(), "rakazo-inbox-"));
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const { createApp } = await import("../../../apps/api/src/app.ts");
|
||||||
|
const handles = await createApp({
|
||||||
|
databaseUrl: process.env.DATABASE_URL!,
|
||||||
|
dataDir,
|
||||||
|
sandboxProvider: "fake",
|
||||||
|
agentRuntime: "scripted",
|
||||||
|
wakeupDriver: "memory",
|
||||||
|
});
|
||||||
|
app = handles.app;
|
||||||
|
prisma = handles.prisma;
|
||||||
|
stop = handles.stop;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await stop?.();
|
||||||
|
rmSync(dataDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("answers a specialist's question through the item and then shows its unread reply", async () => {
|
||||||
|
const cookie = await signup(app, `inbox-flow-${stamp}@rakazo.test`, "Inbox Flow");
|
||||||
|
const scout = await createBot(app, cookie, "Scout");
|
||||||
|
|
||||||
|
await rpc(app, cookie, "threads/send", { botId: scout.id, text: "ask me which city to use" });
|
||||||
|
const waiting = await waitForThread(
|
||||||
|
app,
|
||||||
|
cookie,
|
||||||
|
scout.id,
|
||||||
|
(snapshot) => snapshot.run?.status === "waiting_input",
|
||||||
|
);
|
||||||
|
|
||||||
|
const asked = await listInbox(app, cookie);
|
||||||
|
expect(asked).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
kind: "ask",
|
||||||
|
bot: { id: scout.id, name: "Scout", color: scout.color },
|
||||||
|
target: { botId: scout.id },
|
||||||
|
runId: waiting.run?.id,
|
||||||
|
ask: expect.objectContaining({ text: "Which city should I use?", status: "pending" }),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
const item = asked[0];
|
||||||
|
if (item?.kind !== "ask") throw new Error("expected an ask item");
|
||||||
|
|
||||||
|
await rpc(app, cookie, "threads/answer", {
|
||||||
|
...item.target,
|
||||||
|
runId: item.runId,
|
||||||
|
messageId: item.messageId,
|
||||||
|
answer: "Paris",
|
||||||
|
});
|
||||||
|
// A finished run leaves the snapshot.
|
||||||
|
await waitForThread(app, cookie, scout.id, (snapshot) => !snapshot.run);
|
||||||
|
|
||||||
|
const answered = await listInbox(app, cookie);
|
||||||
|
expect(answered).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
kind: "unread",
|
||||||
|
bot: expect.objectContaining({ id: scout.id }),
|
||||||
|
target: { botId: scout.id },
|
||||||
|
text: expect.stringContaining("Paris"),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await rpc(app, cookie, "threads/markRead", { botId: scout.id });
|
||||||
|
expect(await listInbox(app, cookie)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("collects approvals, takeovers and unread threads of this 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");
|
||||||
|
const writer = await createBot(app, cookie, "Writer");
|
||||||
|
const pilot = await createBot(app, cookie, "Pilot");
|
||||||
|
const retired = await createBot(app, cookie, "Retired");
|
||||||
|
const ops = await rpc<{ id: string; threadId: string }>(app, cookie, "groups/create", {
|
||||||
|
name: "Ops",
|
||||||
|
botIds: [scout.id, writer.id],
|
||||||
|
});
|
||||||
|
const main = await ensureMainConversation(prisma, me.userId);
|
||||||
|
await addMainConversationMembers(prisma, { userId: me.userId, botIds: [scout.id] });
|
||||||
|
|
||||||
|
const approval = await seedWaitingRun(prisma, {
|
||||||
|
botId: writer.id,
|
||||||
|
threadId: ops.threadId,
|
||||||
|
status: "waiting_input",
|
||||||
|
blocks: [
|
||||||
|
{
|
||||||
|
kind: "ask",
|
||||||
|
text: "Review before writing to notes",
|
||||||
|
approvalEffectId: `effect-${stamp}`,
|
||||||
|
status: "pending",
|
||||||
|
actions: [
|
||||||
|
{ id: "allow", label: "Allow once" },
|
||||||
|
{ id: "always", label: "Always allow this tool" },
|
||||||
|
{ id: "deny", label: "Deny" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
// Already visible where the inbox lives.
|
||||||
|
await seedWaitingRun(prisma, {
|
||||||
|
botId: scout.id,
|
||||||
|
threadId: main.threadId,
|
||||||
|
status: "waiting_input",
|
||||||
|
blocks: [{ kind: "ask", text: "Asked in the main conversation", status: "pending" }],
|
||||||
|
});
|
||||||
|
const takeover = await seedWaitingRun(prisma, {
|
||||||
|
botId: pilot.id,
|
||||||
|
threadId: pilot.threadId,
|
||||||
|
status: "waiting_takeover",
|
||||||
|
blocks: [{ kind: "computer", state: "Needs you", text: "Sign in to continue." }],
|
||||||
|
});
|
||||||
|
await seedWaitingRun(prisma, {
|
||||||
|
botId: retired.id,
|
||||||
|
threadId: retired.threadId,
|
||||||
|
status: "waiting_input",
|
||||||
|
blocks: [{ kind: "ask", text: "Asked by an archived bot", status: "pending" }],
|
||||||
|
});
|
||||||
|
await prisma.bot.update({ where: { id: retired.id }, data: { archivedAt: new Date() } });
|
||||||
|
await createThreadMessage(prisma, {
|
||||||
|
threadId: scout.threadId,
|
||||||
|
role: "bot",
|
||||||
|
botId: scout.id,
|
||||||
|
blocks: [{ kind: "text", text: "The report\nis ready." }],
|
||||||
|
});
|
||||||
|
// The main bot's own thread is not a specialist thread.
|
||||||
|
await createThreadMessage(prisma, {
|
||||||
|
threadId: (await prisma.thread.findUniqueOrThrow({ where: { botId: main.leadBotId } })).id,
|
||||||
|
role: "bot",
|
||||||
|
botId: main.leadBotId,
|
||||||
|
blocks: [{ kind: "text", text: "Main bot note" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const remoteSpace = await rpc<{ id: string }>(app, cookie, "spaces/create", { name: "Remote" });
|
||||||
|
const remote = await createBot(app, cookie, "Remote", remoteSpace.id);
|
||||||
|
const remoteAsk = await seedWaitingRun(prisma, {
|
||||||
|
botId: remote.id,
|
||||||
|
threadId: remote.threadId,
|
||||||
|
status: "waiting_input",
|
||||||
|
blocks: [{ kind: "ask", text: "Asked in another space", status: "pending" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const items = await listInbox(app, cookie, me.spaceId);
|
||||||
|
expect(items).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
kind: "approval",
|
||||||
|
bot: expect.objectContaining({ id: writer.id, name: "Writer" }),
|
||||||
|
target: { groupId: ops.id },
|
||||||
|
runId: approval.runId,
|
||||||
|
messageId: approval.messageId,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
kind: "takeover",
|
||||||
|
bot: { id: pilot.id, name: "Pilot", color: pilot.color },
|
||||||
|
target: { botId: pilot.id },
|
||||||
|
runId: takeover.runId,
|
||||||
|
messageId: takeover.messageId,
|
||||||
|
text: "Sign in to continue.",
|
||||||
|
createdAt: expect.any(String),
|
||||||
|
},
|
||||||
|
expect.objectContaining({
|
||||||
|
kind: "unread",
|
||||||
|
bot: expect.objectContaining({ id: scout.id }),
|
||||||
|
target: { botId: scout.id },
|
||||||
|
text: "The report is ready.",
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
// The Pilot thread is unread too, but its takeover already covers it.
|
||||||
|
expect(items.filter((item) => item.bot.id === pilot.id)).toHaveLength(1);
|
||||||
|
|
||||||
|
expect(await listInbox(app, cookie, remoteSpace.id)).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
kind: "ask",
|
||||||
|
bot: expect.objectContaining({ id: remote.id }),
|
||||||
|
target: { botId: remote.id },
|
||||||
|
runId: remoteAsk.runId,
|
||||||
|
messageId: remoteAsk.messageId,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
async function seedWaitingRun(
|
||||||
|
prisma: PrismaClient,
|
||||||
|
input: {
|
||||||
|
botId: string;
|
||||||
|
threadId: string;
|
||||||
|
status: "waiting_input" | "waiting_takeover";
|
||||||
|
blocks: MessageBlock[];
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const thread = await prisma.thread.findUniqueOrThrow({ where: { id: input.threadId } });
|
||||||
|
const task = await prisma.task.create({
|
||||||
|
data: {
|
||||||
|
spaceId: thread.spaceId,
|
||||||
|
userId: thread.userId,
|
||||||
|
botId: input.botId,
|
||||||
|
threadId: thread.id,
|
||||||
|
prompt: "inbox fixture",
|
||||||
|
status: input.status,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const run = await prisma.run.create({
|
||||||
|
data: {
|
||||||
|
spaceId: thread.spaceId,
|
||||||
|
userId: thread.userId,
|
||||||
|
botId: input.botId,
|
||||||
|
threadId: thread.id,
|
||||||
|
taskId: task.id,
|
||||||
|
status: input.status,
|
||||||
|
trigger: "user",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const message = await createThreadMessage(prisma, {
|
||||||
|
threadId: thread.id,
|
||||||
|
role: "bot",
|
||||||
|
botId: input.botId,
|
||||||
|
runId: run.id,
|
||||||
|
blocks: input.blocks,
|
||||||
|
});
|
||||||
|
return { runId: run.id, messageId: message.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createBot(app: App, cookie: string, name: string, spaceId?: string) {
|
||||||
|
return rpc<{ id: string; color: string; threadId: string }>(
|
||||||
|
app,
|
||||||
|
cookie,
|
||||||
|
"bots/create",
|
||||||
|
{ name, title: name, description: "", instructions: "", notifyOnFinish: true },
|
||||||
|
spaceId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listInbox(app: App, cookie: string, spaceId?: string) {
|
||||||
|
return (await rpc<{ items: InboxItem[] }>(app, cookie, "inbox/list", {}, spaceId)).items;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForThread(
|
||||||
|
app: App,
|
||||||
|
cookie: string,
|
||||||
|
botId: string,
|
||||||
|
done: (snapshot: ThreadSnapshot) => boolean,
|
||||||
|
): 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 });
|
||||||
|
if (done(last)) return last;
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
}
|
||||||
|
throw new Error(`timeout waiting for thread: ${JSON.stringify(last)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function signup(app: App, email: string, name: string) {
|
||||||
|
const response = await app.request("/api/auth/sign-up/email", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json", origin: "http://127.0.0.1:5173" },
|
||||||
|
body: JSON.stringify({ email, password: "password12", name }),
|
||||||
|
});
|
||||||
|
if (response.status >= 400) {
|
||||||
|
throw new Error(`signup failed ${response.status}: ${await response.text()}`);
|
||||||
|
}
|
||||||
|
return sessionCookieHeader(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rpc<T>(
|
||||||
|
app: App,
|
||||||
|
cookie: string,
|
||||||
|
procedure: string,
|
||||||
|
body: unknown = {},
|
||||||
|
spaceId?: string,
|
||||||
|
): Promise<T> {
|
||||||
|
const response = await app.request(`/rpc/${procedure}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"content-type": "application/json",
|
||||||
|
cookie,
|
||||||
|
origin: "http://127.0.0.1:5173",
|
||||||
|
...(spaceId ? { "x-rakazo-space-id": spaceId } : {}),
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ json: body }),
|
||||||
|
});
|
||||||
|
const payload = (await response.json()) as { json?: T; error?: { message?: string } };
|
||||||
|
if (response.status >= 400 || payload.error) {
|
||||||
|
throw new Error(`${procedure} ${response.status}: ${payload.error?.message ?? "failed"}`);
|
||||||
|
}
|
||||||
|
return payload.json as T;
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user