Merge main into the Phase 2 integration branch

Main now gives every project its own main agent. Coaching stays with the
user's personal agent, the main bot of the home space: only it gets the
goal, habit, check-in, brief and nudge tools, the coach summary and the
open-onboarding note, and the tools refuse a project's main bot too. The
onboarding questions start with the personal agent only. A project the
scan creates gets its main agent like any other, and the inbox lists asks
from another space's main conversation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
This commit is contained in:
Claude 2026-10-03 23:24:21 +00:00
commit 77e822b9a7
No known key found for this signature in database
44 changed files with 971 additions and 367 deletions

View File

@ -246,6 +246,7 @@ jobs:
publish-mobile-update:
name: Publish compatible mobile update
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
continue-on-error: true
needs: [lint, check, build, test, test-integration, test-e2e]
runs-on: [self-hosted, evoo-ci, Linux]
timeout-minutes: 20
@ -344,23 +345,30 @@ jobs:
fi
echo "publish=$publish" >> "$GITHUB_OUTPUT"
- name: Require Expo automation token
- name: Check Expo automation token
id: expo_token
if: steps.ota.outputs.publish == 'true'
env:
EXPO_TOKEN: ${{ secrets.EXPO_TOKEN }}
run: |
if [[ -z "$EXPO_TOKEN" ]]; then
echo "Add an EXPO_TOKEN repository secret with access to the inbox-zero/rakazo Expo project." >&2
exit 1
{
echo "### Mobile OTA skipped"
echo
echo "No \`EXPO_TOKEN\` repository secret is configured, so CI will not publish a production update. Add a token with access to the inbox-zero/rakazo Expo project when OTA from main is desired."
} >> "$GITHUB_STEP_SUMMARY"
echo "ready=false" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "ready=true" >> "$GITHUB_OUTPUT"
- uses: pnpm/action-setup@f40ffcd9367d9f12939873eb1018b921a783ffaa # v4
if: steps.ota.outputs.publish == 'true'
if: steps.ota.outputs.publish == 'true' && steps.expo_token.outputs.ready == 'true'
id: pnpm-setup
with:
dest: ${{ runner.temp }}/setup-pnpm-${{ github.run_id }}-${{ github.job }}
- name: Isolate pnpm store and PATH
if: steps.ota.outputs.publish == 'true'
if: steps.ota.outputs.publish == 'true' && steps.expo_token.outputs.ready == 'true'
shell: bash
run: |
set -euo pipefail
@ -377,25 +385,25 @@ jobs:
mkdir -p "$store"
echo "npm_config_store_dir=${store}" >> "$GITHUB_ENV"
- uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6
if: steps.ota.outputs.publish == 'true'
if: steps.ota.outputs.publish == 'true' && steps.expo_token.outputs.ready == 'true'
with:
node-version: 24
cache: pnpm
- name: Set up Expo
if: steps.ota.outputs.publish == 'true'
if: steps.ota.outputs.publish == 'true' && steps.expo_token.outputs.ready == 'true'
uses: expo/expo-github-action@eab7a230208c952974db8c3245cfd78402c7b385 # v9
with:
eas-version: latest
packager: pnpm
token: ${{ secrets.EXPO_TOKEN }}
- name: Install dependencies
if: steps.ota.outputs.publish == 'true'
if: steps.ota.outputs.publish == 'true' && steps.expo_token.outputs.ready == 'true'
run: pnpm install --frozen-lockfile
- name: Verify mobile project
if: steps.ota.outputs.publish == 'true'
if: steps.ota.outputs.publish == 'true' && steps.expo_token.outputs.ready == 'true'
run: pnpm --filter @rakazo/mobile check
- name: Publish production update
if: steps.ota.outputs.publish == 'true'
if: steps.ota.outputs.publish == 'true' && steps.expo_token.outputs.ready == 'true'
working-directory: apps/mobile
run: |
set -euo pipefail

View File

@ -232,6 +232,34 @@ describe("buildInboxItems", () => {
expect(items[2]).toMatchObject({ messageId: null, text: "", createdAt: at(0).toISOString() });
});
it("lists asks from another space's main conversation, but not the open space's own", () => {
const project = { id: "space-launch", name: "Launch" };
const main = (groupId: string) => ({
botId: null,
groupId,
group: { kind: "main", archivedAt: null },
});
const items = buildInboxItems({
runs: [
run("home-main", "waiting_input", main("group-main-home")),
run("project-main", "waiting_input", main("group-main-launch"), writer, project.id),
],
messages: [
message("ask-home", "home-main", [question], 1),
message("ask-project", "project-main", [question], 2),
],
unreadThreads: [],
otherSpaces: new Map([[project.id, project]]),
});
expect(items).toHaveLength(1);
expect(items[0]).toMatchObject({
runId: "project-main",
space: project,
target: { groupId: "group-main-launch" },
});
});
it("names the space of items from the user's other spaces", () => {
const side = { id: "space-side", name: "Side project" };
const items = buildInboxItems({

View File

@ -76,12 +76,14 @@ function previewText(blocks: unknown): string {
}
/**
* 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.
* Where an ask can be answered from. The open space's main conversation already shows its own
* asks (a project's main conversation does not), and an archived group or an external
* conversation has no thread target to answer through.
*/
function askTarget(thread: InboxRunRow["thread"]): InboxThreadTarget | null {
function askTarget(thread: InboxRunRow["thread"], currentSpace: boolean): InboxThreadTarget | null {
if (thread.groupId) {
if (!thread.group || thread.group.kind === "main" || thread.group.archivedAt) return null;
if (!thread.group || thread.group.archivedAt) return null;
if (thread.group.kind === "main" && currentSpace) return null;
return { groupId: thread.groupId };
}
return thread.botId ? { botId: thread.botId } : null;
@ -124,7 +126,7 @@ export function buildInboxItems(input: {
for (const run of input.runs) {
const messages = messagesByRun.get(run.id) ?? [];
if (run.status === "waiting_input") {
const target = askTarget(run.thread);
const target = askTarget(run.thread, !input.otherSpaces?.has(run.spaceId));
const pending = target ? newest(messages, pendingAsk) : undefined;
if (!target || !pending) continue;
waiting.push({

View File

@ -43,13 +43,20 @@ export async function assertNotMainConversation(
if (group?.kind === "main") throw new ORPCError("BAD_REQUEST", { message });
}
/** The main conversation carries the main bot's name. */
export async function renameMainConversation(prisma: PrismaClient, userId: string, name: string) {
await prisma.chatGroup.updateMany({ where: { userId, kind: "main" }, data: { name } });
/** A main conversation carries its main bot's name. */
export async function renameMainConversation(
prisma: PrismaClient,
main: { userId: string; spaceId: string },
name: string,
) {
await prisma.chatGroup.updateMany({
where: { userId: main.userId, spaceId: main.spaceId, kind: "main" },
data: { name },
});
}
/**
* Same-space bots the user addresses in the main conversation, by `@Name` or a mention chip,
* Same-space bots the user addresses in a main conversation, by `@Name` or a mention chip,
* join it first, so the member mention rules route the message to them.
*/
export async function joinAddressedBots(
@ -71,5 +78,9 @@ export async function joinAddressedBots(
.map((bot) => bot.id);
if (addressed.length === 0) return;
// Subscribers pick up the group.updated event with the send that follows.
await joinMainConversation(prisma, { userId: actor.userId, botIds: addressed });
await joinMainConversation(prisma, {
userId: actor.userId,
spaceId: actor.spaceId,
botIds: addressed,
});
}

View File

@ -19,6 +19,7 @@ import { ONBOARDING_SCAN_MAX_FACTS, ONBOARDING_SCAN_MAX_PROJECTS } from "@rakazo
import type { PrismaClient } from "@rakazo/db";
import {
createSpaceForMember,
ensureMainConversation,
InvalidProjectWorkspacePathError,
InvalidSpaceNameError,
InvalidUserFactError,
@ -368,6 +369,8 @@ async function projectSpace(
name: name.slice(0, 60),
});
await wireProjectWorkspace(prisma, { spaceId: space.id, workspacePath: folder });
// Like any new project, it starts with its own main agent.
await ensureMainConversation(prisma, actor.userId, space.id);
return space;
}

View File

@ -10,6 +10,7 @@ import {
createThreadMessageInTransaction,
ensureMainConversation,
IsolationError,
isPersonalMainBot,
type Prisma,
type PrismaClient,
type ThreadEvents,
@ -58,7 +59,7 @@ async function requireBotThread(deps: OnboardingDeps, actor: Actor, botId: strin
});
if (!bot?.thread) throw new IsolationError();
if (bot.kind === "main") {
const conversation = await ensureMainConversation(deps.prisma, actor.userId);
const conversation = await ensureMainConversation(deps.prisma, actor.userId, bot.spaceId);
return { bot, thread: { id: conversation.threadId } };
}
return { bot, thread: bot.thread };
@ -167,7 +168,8 @@ export async function promptFocus(
): Promise<void> {
const { bot, thread } = await requireBotThread(deps, actor, botId);
const target = { spaceId: actor.spaceId, botId: bot.id, threadId: thread.id };
const main = bot.kind === "main";
// The intake runs with the personal main agent; a project's main bot starts like any other.
const main = await isPersonalMainBot(deps.prisma, bot);
// Check + insert + event in one transaction so concurrent callers cannot duplicate cards,
// and a concurrent user send cannot publish first.
const committed = await deps.prisma.$transaction(async (tx: Prisma.TransactionClient) => {

View File

@ -89,7 +89,15 @@ import {
verifyMcpInstall,
} from "@rakazo/adapters";
import type { Auth } from "@rakazo/auth";
import type { Actor, ComputerStatus, McpServer, Me, SpaceNavigation } from "@rakazo/contracts";
import type {
Actor,
ComputerStatus,
McpServer,
Me,
SpaceBot,
SpaceGroup,
SpaceNavigation,
} from "@rakazo/contracts";
import {
appContract,
CoachRoutineKeySchema,
@ -134,6 +142,7 @@ import {
listSpaceUserBotIds,
newestModelCredentialOrder,
newestVoiceCredentialOrder,
ownMainAgentsBlockingSpaceDeletion,
Prisma,
parseComputerMode,
projectWorkspaceDto,
@ -512,6 +521,26 @@ export interface RouterDeps {
* keep the deletion claim renewed forever and block stale-claim recovery. */
const SPACE_TEARDOWN_TIMEOUT_MS = 120_000;
async function removeGroupWithArtifacts(
deps: RouterDeps,
groupRepos: ReturnType<typeof createGroupRepos>,
actor: Actor,
groupId: string,
) {
const removed = await groupRepos.removeGroup(actor, groupId);
const cleanup = await Promise.allSettled(
removed.artifactStorageKeys.map((storageKey) =>
deps.artifacts.remove(
storageKey,
computerContext(actor, removed.contextBotId, `group-remove:${groupId}`),
),
),
);
for (const result of cleanup) {
if (result.status === "rejected") getLogger().error("group artifact cleanup", result.reason);
}
}
function spaceTeardownTimeoutMs(): number {
const override = Number(process.env.SPACE_TEARDOWN_TIMEOUT_MS ?? "");
return Number.isFinite(override) && override > 0 ? override : SPACE_TEARDOWN_TIMEOUT_MS;
@ -678,6 +707,13 @@ export function createRouter(deps: RouterDeps) {
const workspace = workspacePath
? await wireProjectWorkspace(deps.prisma, { spaceId: space.id, workspacePath })
: null;
// A project starts as a conversation with its own main agent.
await ensureMainConversation(deps.prisma, context.actor.userId, space.id);
const spaceActor = await requireMembership(deps.prisma, context.actor.userId, space.id);
const [bots, groups] = await Promise.all([
repos.listBots(spaceActor),
groupRepos.listGroups(spaceActor),
]);
return {
id: space.id,
name: space.name,
@ -685,12 +721,26 @@ export function createRouter(deps: RouterDeps) {
hasContent: false,
canDelete: true,
workspace,
bots: [],
groups: [],
bots: bots.map(spaceBotDto),
groups: groups.map(spaceGroupDto),
externalConversations: [],
botSections: [],
};
}),
ensureMain: authed.spaces.ensureMain.handler(async ({ context, input }) => {
const conversation = await ensureMainConversation(
deps.prisma,
context.actor.userId,
input.spaceId,
).catch((error: unknown) => {
throw mapSpaceLifecycleError(error);
});
return {
botId: conversation.leadBotId,
groupId: conversation.groupId,
spaceId: conversation.spaceId,
};
}),
remove: authed.spaces.remove.handler(async ({ context, input }) => {
let claimId: string | null = null;
let claimActive = false;
@ -703,6 +753,41 @@ export function createRouter(deps: RouterDeps) {
spaceId: input.spaceId,
};
try {
// The project's own main agent goes with the project; anything else must be
// cleared first.
const ownMain = await ownMainAgentsBlockingSpaceDeletion(deps.prisma, deleteInput);
if (ownMain.groupIds.length > 0 || ownMain.botIds.length > 0) {
const spaceActor = await requireMembership(
deps.prisma,
context.actor.userId,
input.spaceId,
);
for (const groupId of ownMain.groupIds) {
await removeGroupWithArtifacts(deps, groupRepos, spaceActor, groupId);
}
for (const botId of ownMain.botIds) {
const bot = await repos.getBot(spaceActor, botId, { includeArchived: true });
await destroyBot(
{
prisma: deps.prisma,
sandbox: deps.sandbox,
home: deps.home,
jobs: deps.jobs,
artifacts: deps.artifacts,
dataDir: deps.dataDir,
},
bot,
{
operationId: "spaces.remove",
traceId: "spaces.remove",
spaceId: input.spaceId,
userId: context.actor.userId,
signal: new AbortController().signal,
},
{ deleteMemories: true },
);
}
}
// Claim emptiness before external teardown. Bot and group creation
// take the same lifecycle lock and reject the Space until deletion
// finishes or this claim is released.
@ -1301,7 +1386,11 @@ export function createRouter(deps: RouterDeps) {
},
});
if (existing.kind === "main" && input.name !== undefined && input.name !== existing.name) {
await renameMainConversation(deps.prisma, context.actor.userId, input.name);
await renameMainConversation(
deps.prisma,
{ userId: context.actor.userId, spaceId: existing.spaceId },
input.name,
);
}
const bots = await repos.listBots(context.actor);
const bot = bots.find((b) => b.id === input.botId);
@ -1589,19 +1678,7 @@ export function createRouter(deps: RouterDeps) {
input.groupId,
"The main conversation can't be deleted.",
);
const removed = await groupRepos.removeGroup(context.actor, input.groupId);
const cleanup = await Promise.allSettled(
removed.artifactStorageKeys.map((storageKey) =>
deps.artifacts.remove(
storageKey,
computerContext(context.actor, removed.contextBotId, `group-remove:${input.groupId}`),
),
),
);
for (const result of cleanup) {
if (result.status === "rejected")
getLogger().error("group artifact cleanup", result.reason);
}
await removeGroupWithArtifacts(deps, groupRepos, context.actor, input.groupId);
return { ok: true as const };
}),
},
@ -5081,6 +5158,11 @@ async function spaceNavigationDto(
orderBy: { createdAt: "asc" },
});
const spaceIds = memberships.map((membership) => membership.spaceId);
const ownProjectMain = {
userId: actor.userId,
kind: "main",
space: { isDefault: false },
} as const;
const inactiveSpaceIds = spaceIds.filter((spaceId) => spaceId !== actor.spaceId);
const [
currentBots,
@ -5101,13 +5183,14 @@ async function spaceNavigationDto(
// Active-only navigation lists miss archived content in other spaces; count any
// bot/group in the actor's spaces (including another member's) so a shared
// non-empty space cannot look empty for onboarding redirects.
// The user's own main agent of a project is part of the project, not content to clear.
deps.prisma.bot.findMany({
where: { spaceId: { in: spaceIds } },
where: { spaceId: { in: spaceIds }, NOT: ownProjectMain },
select: { spaceId: true },
distinct: ["spaceId"],
}),
deps.prisma.chatGroup.findMany({
where: { spaceId: { in: spaceIds } },
where: { spaceId: { in: spaceIds }, NOT: ownProjectMain },
select: { spaceId: true },
distinct: ["spaceId"],
}),
@ -5153,7 +5236,20 @@ async function spaceNavigationDto(
(membership.space.deletingAt === null || membership.space.deletingAt < staleClaimBefore),
// A linked folder is on the owner's host; other members see neither its path nor remote.
workspace: actor.isDeploymentOwner ? projectWorkspaceDto(membership.space) : null,
bots: spaceBots.map((bot) => ({
bots: spaceBots.map(spaceBotDto),
groups: spaceGroups.map(spaceGroupDto),
externalConversations: externalConversations.filter(
(conversation) => conversation.spaceId === membership.spaceId,
),
botSections: sectionsFor(membership.spaceId),
};
}),
main,
};
}
function spaceBotDto(bot: SpaceBot): SpaceBot {
return {
id: bot.id,
spaceId: bot.spaceId,
kind: bot.kind,
@ -5167,8 +5263,11 @@ async function spaceNavigationDto(
preview: bot.preview,
status: bot.status,
updatedAt: bot.updatedAt,
})),
groups: spaceGroups.map((group) => ({
};
}
function spaceGroupDto(group: SpaceGroup): SpaceGroup {
return {
id: group.id,
spaceId: group.spaceId,
kind: group.kind,
@ -5179,14 +5278,6 @@ async function spaceNavigationDto(
preview: group.preview,
unread: group.unread,
updatedAt: group.updatedAt,
})),
externalConversations: externalConversations.filter(
(conversation) => conversation.spaceId === membership.spaceId,
),
botSections: sectionsFor(membership.spaceId),
};
}),
main,
};
}

View File

@ -28,7 +28,11 @@ export default function NewSpace() {
return;
}
router.dismissAll();
router.replace("/");
// A new project opens in the conversation with its own main agent.
const main = space.groups.find((group) => group.kind === "main");
router.replace(
main ? { pathname: "/group-thread", params: { groupId: main.id, name: main.name } } : "/",
);
} catch (reason) {
setError(reason instanceof Error ? reason.message : t("Could not create space"));
setPending(false);

View File

@ -728,7 +728,7 @@ export type MobileBot = Pick<
| "modelId"
| "thinkingLevel"
> &
Partial<Pick<Bot, "parentBotId" | "spaceId">>;
Partial<Pick<Bot, "parentBotId" | "spaceId" | "kind">>;
export type MobileBotSection = BotSection;

View File

@ -111,6 +111,29 @@ describe("spaceInboxItems", () => {
expect(items.map((item) => item.type)).toEqual(["bot", "group"]);
});
it("lists a project's main conversation instead of its main bot", () => {
const main = {
id: "group-main",
name: "Chief",
kind: "main" as const,
preview: "",
pinned: false,
sectionId: null,
archivedAt: null,
unread: false,
updatedAt: "2026-01-01T00:00:00.000Z",
members: [],
};
const lead: MobileBot = { ...bot, id: "bot-lead", name: "Chief", kind: "main" };
const items = spaceInboxItems([space({ bots: [lead, bot], groups: [main] })]);
expect(items.map((item) => (item.type === "heading" ? item.title : item.type))).toEqual([
"Work",
"bot",
"group",
]);
expect(items.some((item) => item.type === "bot" && item.bot.id === lead.id)).toBe(false);
});
it("keeps empty spaces beside a populated space", () => {
const personal = space({ id: "personal", isDefault: true, bots: [bot] });
expect(spaceInboxItems([personal, space()]).map((item) => item.type)).toEqual([

View File

@ -32,7 +32,10 @@ export function canDeleteInboxSpace(
export function spaceInboxItems(spaces: InboxSpace[]): InboxSpaceItem[] {
return spaces.flatMap((space): InboxSpaceItem[] => {
const chats = [
...space.bots.map((chat) => ({ type: "bot" as const, bot: chat, ...chat })),
// A main bot is reached through its space's main conversation, not listed again.
...space.bots
.filter((chat) => chat.kind !== "main")
.map((chat) => ({ type: "bot" as const, bot: chat, ...chat })),
...space.groups.map((chat) => ({ type: "group" as const, group: chat, ...chat })),
];
const items: InboxSpaceItem[] = [];

View File

@ -1,98 +0,0 @@
import { expect, type Locator, test } from "@playwright/test";
import { completeOnboarding, rpc, signup, unterhaltungenOnly } from "./helpers";
async function selectProjectSpace(projekte: Locator, name: string) {
const row = projekte.locator('[data-sidebar-group^="project-space:"]').filter({ hasText: name });
await row.getByRole("button", { name: `Select ${name}` }).click();
}
async function projectSidebarStart(projekte: Locator, name: string) {
const row = projekte.locator('[data-sidebar-group^="project-space:"]').filter({ hasText: name });
const groupAttr = await row.getAttribute("data-sidebar-group");
const spaceId = groupAttr?.match(/^project-space:([^:]+)/)?.[1];
if (!spaceId) throw new Error(`missing project space id for ${name}`);
return projekte.getByTestId(`project-start-${spaceId}`);
}
test("empty project shows Start instead of a fallback bot", async ({ page }) => {
const stamp = Date.now();
await signup(page, `empty-project-${stamp}@rakazo.test`, "password12", "Empty Project");
await completeOnboarding(page);
await page.goto("/app");
await page.waitForURL(/\/app\/[^/]+$/);
const projekte = page.locator(".sidebar-projekte");
await projekte.getByTestId("create-project-trigger").click();
const dialog = page.getByRole("dialog", { name: "New project" });
await dialog.getByLabel("Name").fill("Launch");
await dialog.getByRole("button", { name: "Create project", exact: true }).click();
await page.waitForURL(/\/app(?:\/)?$/);
await expect(projekte.getByRole("button", { name: /^Chief/ })).toHaveCount(0);
await expect(projekte.getByRole("button", { name: "Start", exact: true })).toBeVisible();
await expect(page.getByTestId("project-start-main")).toBeVisible();
await page.getByTestId("project-start-main").click();
await expect(page.getByTestId("side-panel")).toHaveAttribute("data-panel", "create");
});
test("Start in an onboarded project opens the group create flow", async ({ page }) => {
const stamp = Date.now();
await signup(page, `project-start-${stamp}@rakazo.test`, "password12", "Project Start");
await completeOnboarding(page);
const projekte = page.locator(".sidebar-projekte");
await projekte.getByTestId("create-project-trigger").click();
const dialog = page.getByRole("dialog", { name: "New project" });
await dialog.getByLabel("Name").fill("Website");
await dialog.getByRole("button", { name: "Create project", exact: true }).click();
await page.waitForURL(/\/app(?:\/)?$/);
await selectProjectSpace(projekte, "Website");
await page.waitForURL(/\/app(?:\/)?$/);
await expect(page.getByTestId("shell-root")).toHaveAttribute("data-ready", "true", {
timeout: 60_000,
});
await selectProjectSpace(projekte, "Website");
await page.waitForURL(/\/app(?:\/)?$/);
await expect(unterhaltungenOnly(page).getByRole("button", { name: /^Chief/ })).toHaveCount(1);
await expect(projekte.getByRole("button", { name: /^Chief/ })).toHaveCount(0);
const sidebarStart = await projectSidebarStart(projekte, "Website");
await expect(sidebarStart).toBeVisible({ timeout: 30_000 });
await sidebarStart.click();
await expect(page.getByTestId("side-panel")).toHaveAttribute("data-panel", "create-group", {
timeout: 30_000,
});
await expect(page.getByText("New group", { exact: true })).toBeVisible();
await expect(
page.getByTestId("side-panel").getByRole("button", { name: /^Chief/ }),
).toBeVisible();
});
test("archiving the only bot keeps Start visible in the project sidebar", async ({ page }) => {
const stamp = Date.now();
await signup(page, `project-archive-${stamp}@rakazo.test`, "password12", "Project Archive");
await completeOnboarding(page);
const projekte = page.locator(".sidebar-projekte");
await projekte.getByTestId("create-project-trigger").click();
const dialog = page.getByRole("dialog", { name: "New project" });
await dialog.getByLabel("Name").fill("Side");
await dialog.getByRole("button", { name: "Create project", exact: true }).click();
await page.waitForURL(/\/app(?:\/)?$/);
const sideBot = await rpc<{ id: string }>(page, "bots/create", {
name: "Side bot",
title: "",
description: "",
notifyOnFinish: true,
computerMode: "team",
});
await rpc(page, "bots/archive", { botId: sideBot.id });
await page.goto("/app");
await page.waitForURL(/\/app(?:\/)?$/);
await expect(projekte.getByRole("button", { name: "Start", exact: true })).toBeVisible();
await expect(page.getByTestId("project-start-main")).toBeVisible();
});

View File

@ -75,7 +75,11 @@ test("local settings open and save integrations without an app session", async (
});
test("ordinary browsers cannot use local settings", async ({ page }) => {
await page.goto("/desktop-settings");
await expect(page.getByRole("alert")).toContainText("Could not open local settings");
await expect(async () => {
await page.goto("/desktop-settings", { waitUntil: "domcontentloaded" });
await expect(page.getByRole("alert")).toContainText("Could not open local settings", {
timeout: 5_000,
});
}).toPass({ timeout: 45_000 });
await expect(page.getByRole("button", { name: "Models", exact: true })).toBeHidden();
});

View File

@ -19,7 +19,10 @@ test("onboarding lands in the main conversation, its lead answers, and addressed
const homeSpaceId = await page.evaluate(() => window.localStorage.getItem("rakazo:space-id"));
const chief = await rpc<{ id: string }>(page, "bots/ensureMain", {});
const sidebar = page.locator("aside").first();
const mainEntry = sidebar.getByRole("button", { name: "Chief, main conversation" });
// Projects list their own lead's main conversation too; this is the pinned home one.
const mainEntry = sidebar
.getByRole("button", { name: "Chief, main conversation" })
.and(sidebar.locator("[data-main-conversation]"));
await expect(mainEntry).toBeVisible();
// The main bot is reached through the main conversation, not listed again.
await expect(sidebar.locator(`[data-roster-bot-id="${chief.id}"]`)).toHaveCount(0);
@ -78,7 +81,8 @@ test("onboarding lands in the main conversation, its lead answers, and addressed
const dialog = page.getByRole("dialog", { name: "New project" });
await dialog.getByLabel("Name").fill("Launch");
await dialog.getByRole("button", { name: "Create project", exact: true }).click();
await page.waitForURL(/\/app(?:\/)?$/);
// The project opens its own main conversation.
await page.waitForURL((url) => /\/app\/g\/[^/]+$/.test(url.pathname) && url.href !== mainUrl);
await expect
.poll(() => page.evaluate(() => window.localStorage.getItem("rakazo:space-id")))
.not.toBe(homeSpaceId);

View File

@ -0,0 +1,87 @@
import { expect, type Locator, test } from "@playwright/test";
import { captureScreenshot, completeOnboarding, rpc, signup, waitForChatComposer } from "./helpers";
function projectSpace(projekte: Locator, name: string) {
return projekte.locator('[data-sidebar-group^="project-space:"]').filter({ hasText: name });
}
test("a new project opens straight into the conversation with its own main agent", async ({
page,
}, testInfo) => {
const stamp = Date.now();
await signup(page, `project-main-${stamp}@rakazo.test`, "password12", "Project Main");
await completeOnboarding(page);
const homeSpaceId = await page.evaluate(() => window.localStorage.getItem("rakazo:space-id"));
const homeMain = await rpc<{ id: string }>(page, "bots/ensureMain", {});
const projekte = page.locator(".sidebar-projekte");
await projekte.getByTestId("create-project-trigger").click();
const dialog = page.getByRole("dialog", { name: "New project" });
await dialog.getByLabel("Name").fill("Launch");
await dialog.getByRole("button", { name: "Create project", exact: true }).click();
await page.waitForURL(/\/app\/g\/[^/]+$/, { timeout: 30_000 });
await expect
.poll(() => page.evaluate(() => window.localStorage.getItem("rakazo:space-id")))
.not.toBe(homeSpaceId);
await waitForChatComposer(page);
// The project is one conversation, led by a main agent of its own.
const projectEntry = projectSpace(projekte, "Launch").getByRole("button", {
name: "Chief, main conversation",
});
await expect(projectEntry).toBeVisible({ timeout: 30_000 });
await expect(projekte.getByRole("button", { name: "Starten", exact: true })).toHaveCount(0);
const groupId = new URL(page.url()).pathname.split("/").at(-1)!;
const snapshot = await rpc<{ leadBotId?: string }>(page, "threads/get", { groupId });
expect(snapshot.leadBotId).toBeTruthy();
expect(snapshot.leadBotId).not.toBe(homeMain.id);
const composer = page.getByRole("combobox", { name: "Message" });
await composer.fill("What should we do first?");
await composer.press("Enter");
const transcript = page.getByTestId("transcript");
await expect(transcript).toContainText(/on it|handled/i, { timeout: 60_000 });
await captureScreenshot(page, testInfo, "project-main-conversation");
});
test("a project's main agent hands work to the project's specialist", async ({
page,
}, testInfo) => {
const stamp = Date.now();
await signup(page, `project-delegate-${stamp}@rakazo.test`, "password12", "Project Delegate");
await completeOnboarding(page);
const projekte = page.locator(".sidebar-projekte");
await projekte.getByTestId("create-project-trigger").click();
const dialog = page.getByRole("dialog", { name: "New project" });
await dialog.getByLabel("Name").fill("Website");
await dialog.getByRole("button", { name: "Create project", exact: true }).click();
await page.waitForURL(/\/app\/g\/[^/]+$/, { timeout: 30_000 });
await waitForChatComposer(page);
const groupId = new URL(page.url()).pathname.split("/").at(-1)!;
const designer = await rpc<{ id: string }>(page, "bots/create", {
name: "Designer",
title: "",
description: "",
instructions: "",
notifyOnFinish: true,
});
const composer = page.getByRole("combobox", { name: "Message" });
await composer.fill("delegate to the bot named Designer: sketch a landing page");
await composer.press("Enter");
const transcript = page.getByTestId("transcript");
const card = transcript.getByTestId("delegated-task-card");
await expect(card).toBeVisible({ timeout: 60_000 });
await expect(card).toContainText("Designer");
await expect(card).toHaveAttribute("data-status", "done", { timeout: 60_000 });
await expect(
transcript.getByText("closing the task: sketch a landing page.").first(),
).toBeVisible({ timeout: 60_000 });
const snapshot = await rpc<{ members?: Array<{ botId: string }> }>(page, "threads/get", {
groupId,
});
expect(snapshot.members?.map((member) => member.botId)).toContain(designer.id);
await captureScreenshot(page, testInfo, "project-main-agent-delegates");
});

View File

@ -16,7 +16,7 @@ function projectSpace(projekte: Locator, name: string) {
});
}
test("Projects + opens new project dialog instead of group form", async ({ page }) => {
test("Projects + opens new project dialog instead of group form", async ({ page }, testInfo) => {
const stamp = Date.now();
await signup(page, `proj-create-${stamp}@rakazo.test`, "password12", "Project Create");
await completeOnboarding(page);
@ -34,16 +34,21 @@ test("Projects + opens new project dialog instead of group form", async ({ page
await dialog.getByLabel("Name").fill("Customer project");
await dialog.getByRole("button", { name: "Create project", exact: true }).click();
await expect(page).not.toHaveURL(/\/onboarding/);
await page.waitForURL(/\/app/);
await page.waitForLoadState("networkidle");
// The new project opens its conversation with the project's main agent.
await page.waitForURL(/\/app\/g\/[^/]+$/, { timeout: 30_000 });
await expect(projekte.getByText("Customer project", { exact: true })).toBeVisible({
timeout: 30_000,
});
await expect(unterhaltungenOnly(page).getByText("Customer project", { exact: true })).toHaveCount(
0,
);
await expect(page.getByTestId("project-start-main")).toBeVisible();
await expect(page.getByTestId("project-start-main")).toHaveText(/Start/);
await expect(
projectSpace(projekte, "Customer project").getByRole("button", {
name: "Chief, main conversation",
}),
).toBeVisible();
await expect(page.getByTestId("project-start-main")).toHaveCount(0);
await captureScreenshot(page, testInfo, "new-project-main-conversation");
});
for (const owner of [true, false]) {
@ -119,17 +124,18 @@ test("spaces stay invisible by default and chat creation requires approval", asy
const supportSpaceGroup = await supportSpace.getAttribute("data-sidebar-group");
const supportSpaceId = supportSpaceGroup?.match(/^project-space:([^:]+)/)?.[1];
expect(supportSpaceId).toBeTruthy();
await supportSpace.getByRole("button", { name: "Select Customer support" }).click();
await page.waitForURL(/\/app(?:\/)?$/);
// A project created from chat comes with its own main agent, reached through its conversation.
await supportSpace.getByRole("button", { name: "Chief, main conversation" }).click();
await page.waitForURL(/\/app\/g\/[^/]+$/);
await expect
.poll(() => page.evaluate(() => window.localStorage.getItem("rakazo:space-id")))
.toBe(supportSpaceId);
// The main conversation stays the first entry while another space is open.
// The home main conversation stays the first entry while another space is open.
await expect(projekte.getByText("Customer support", { exact: true })).toBeVisible();
await expect(unterhaltungenOnly(page).getByRole("button", { name: /^Chief/ })).toHaveCount(1);
await expect(projekte.getByRole("button", { name: /^Chief/ })).toHaveCount(0);
await expect(projekte.getByRole("button", { name: "Start", exact: true })).toBeVisible();
await expect(projekte.getByRole("button", { name: /^Chief/ })).toHaveCount(1);
await expect(projekte.getByRole("button", { name: "Start", exact: true })).toHaveCount(0);
await captureScreenshot(page, testInfo, "spaces-sidebar");
await page.evaluate((id) => {
@ -143,7 +149,9 @@ test("spaces stay invisible by default and chat creation requires approval", asy
await expect(projekte.getByText("Customer support", { exact: true })).toBeVisible();
});
test("a new space opens empty and can be deleted from its menu", async ({ page }, testInfo) => {
test("a new space opens its main conversation and can be deleted from its menu", async ({
page,
}, testInfo) => {
const stamp = Date.now();
await signup(page, `spaces-delete-${stamp}@rakazo.test`, "password12", "Space Owner");
await completeOnboarding(page);
@ -154,18 +162,20 @@ test("a new space opens empty and can be deleted from its menu", async ({ page }
await dialog.getByLabel("Name").fill("Temporary");
await dialog.getByRole("button", { name: "Create space", exact: true }).click();
// Onboarding runs once per user: a new space opens directly, with no bot of its own.
await page.waitForURL(/\/app(?:\/)?$/);
// Onboarding runs once per user: a new space opens directly in the conversation with its
// own main agent, its only bot.
await page.waitForURL(/\/app\/g\/[^/]+$/, { timeout: 30_000 });
const projekte = page.locator(".sidebar-projekte");
await expect(projekte.getByText("Temporary", { exact: true })).toBeVisible();
await expect(unterhaltungenOnly(page).getByRole("button", { name: /^Chief/ })).toHaveCount(1);
await expect(projekte.getByRole("button", { name: /^Chief/ })).toHaveCount(0);
await expect(projekte.getByRole("button", { name: "Start", exact: true })).toBeVisible();
expect(await rpc<unknown[]>(page, "bots/list", {})).toEqual([]);
await captureScreenshot(page, testInfo, "new-space-empty");
// The empty space stays selectable instead of trapping the user.
await expect(projekte.getByRole("button", { name: "Select Temporary" }).first()).toBeVisible();
await expect(
projectSpace(projekte, "Temporary").getByRole("button", { name: "Chief, main conversation" }),
).toBeVisible();
await expect(projekte.getByRole("button", { name: "Start", exact: true })).toHaveCount(0);
expect(await rpc<Array<{ kind: string }>>(page, "bots/list", {})).toEqual([
expect.objectContaining({ kind: "main" }),
]);
await captureScreenshot(page, testInfo, "new-space-main-conversation");
// Deletion is discoverable through the visible Space actions menu.
await projectSpace(projekte, "Temporary")
@ -180,6 +190,7 @@ test("a new space opens empty and can be deleted from its menu", async ({ page }
await deleteSpaceDialog.getByRole("button", { name: "Delete", exact: true }).click();
await expect(sidebar.getByText("Temporary", { exact: true })).toHaveCount(0);
await expect(page).not.toHaveURL(/\/onboarding/);
// The project's main agent went with it.
await expect(sidebar.getByRole("button", { name: /^Chief/ })).toHaveCount(1);
// A stale onboarding URL recovers without duplicating the bot.
@ -212,7 +223,7 @@ test("deleting the last bot in a space stays in the app after first use", async
const dialog = page.getByRole("dialog", { name: "New space" });
await dialog.getByLabel("Name").fill("Side");
await dialog.getByRole("button", { name: "Create space", exact: true }).click();
await page.waitForURL(/\/app(?:\/)?$/);
await page.waitForURL(/\/app\/g\/[^/]+$/, { timeout: 30_000 });
const projekte = page.locator(".sidebar-projekte");
await expect(projekte.getByText("Side", { exact: true })).toBeVisible();
@ -229,8 +240,11 @@ test("deleting the last bot in a space stays in the app after first use", async
await expect(page).not.toHaveURL(/\/onboarding/);
await page.goto("/app");
await expect(page).not.toHaveURL(/\/onboarding/);
await expect(projekte.getByRole("button", { name: "Select Side" }).first()).toBeVisible();
await expect(page.getByTestId("project-start-main")).toBeVisible();
// The project's main conversation stays, so the project opens there.
await page.waitForURL(/\/app\/g\/[^/]+$/, { timeout: 30_000 });
await expect(
projectSpace(projekte, "Side").getByRole("button", { name: "Chief, main conversation" }),
).toBeVisible();
await projectSpace(projekte, "Side").getByRole("button", { name: "Actions for Side" }).click();
await expect(page.getByRole("menuitem", { name: "Delete space" })).toBeVisible();

View File

@ -14,7 +14,6 @@ import {
showSessionUnavailable,
} from "./lib/session-gate";
import { IntegrationSetupPage } from "./pages/IntegrationSetup";
import { LocalSettingsPage } from "./pages/LocalSettings";
import { McpOAuthCallbackPage } from "./pages/McpOAuthCallback";
import { ShellPage } from "./pages/Shell";
@ -30,9 +29,18 @@ const OnboardingPage = lazyWithRecovery("pages/Onboarding", () =>
const WelcomePage = lazyWithRecovery("pages/Welcome", () =>
import("./pages/Welcome").then((module) => ({ default: module.WelcomePage })),
);
const LocalSettingsPage = lazyWithRecovery("pages/LocalSettings", () =>
import("./pages/LocalSettings").then((module) => ({ default: module.LocalSettingsPage })),
);
export function App() {
if (window.location.pathname === LOCAL_SETTINGS_PAGE) return <LocalSettingsPage />;
if (window.location.pathname === LOCAL_SETTINGS_PAGE) {
return (
<Suspense fallback={<div className="h-full bg-background" data-rakazo-app-state="loading" />}>
<LocalSettingsPage />
</Suspense>
);
}
return <SessionApp />;
}

View File

@ -1,14 +1,15 @@
import { describe, expect, it } from "vitest";
import { isEmptyProjectSpace, projectGroupChatsInSpace } from "./project-empty-state.js";
import { isEmptyProjectSpace, projectMainGroup } from "./project-empty-state.js";
describe("projectGroupChatsInSpace", () => {
it("returns project groups in a space without the main conversation", () => {
describe("projectMainGroup", () => {
it("finds the main conversation of a space", () => {
const groups = [
{ id: "g1", kind: "group" as const, spaceId: "space-a" },
{ id: "g2", kind: "main" as const, spaceId: "space-a" },
{ id: "g3", kind: "group" as const, spaceId: "space-b" },
{ id: "g3", kind: "main" as const, spaceId: "space-b" },
];
expect(projectGroupChatsInSpace(groups, "space-a").map((group) => group.id)).toEqual(["g1"]);
expect(projectMainGroup(groups, "space-a")?.id).toBe("g2");
expect(projectMainGroup(groups, "space-c")).toBeUndefined();
});
});
@ -22,7 +23,7 @@ describe("isEmptyProjectSpace", () => {
expect(isEmptyProjectSpace("home", spaces, [], "home")).toBe(false);
});
it("is true for a project space without group chats", () => {
it("is true for a project space without conversations", () => {
expect(isEmptyProjectSpace("project", spaces, [], "home")).toBe(true);
});
@ -31,8 +32,8 @@ describe("isEmptyProjectSpace", () => {
expect(isEmptyProjectSpace("project", spaces, groups, "home")).toBe(false);
});
it("ignores the main conversation when deciding emptiness", () => {
it("is false once the project has its main conversation", () => {
const groups = [{ id: "g1", kind: "main" as const, spaceId: "project" }];
expect(isEmptyProjectSpace("project", spaces, groups, "home")).toBe(true);
expect(isEmptyProjectSpace("project", spaces, groups, "home")).toBe(false);
});
});

View File

@ -1,14 +1,14 @@
import type { Group, Space } from "@rakazo/contracts";
/** Project-scoped group chats in a space (excludes the main conversation). */
export function projectGroupChatsInSpace(
groups: readonly Pick<Group, "id" | "kind" | "spaceId">[],
/** The project's own main conversation, where the user talks to its main agent. */
export function projectMainGroup<T extends Pick<Group, "kind" | "spaceId">>(
groups: readonly T[],
spaceId: string,
): readonly Pick<Group, "id">[] {
return groups.filter((group) => group.spaceId === spaceId && group.kind !== "main");
): T | undefined {
return groups.find((group) => group.spaceId === spaceId && group.kind === "main");
}
/** Non-default project space with no project group conversations yet. */
/** Non-default project space without any conversation yet, not even its main one. */
export function isEmptyProjectSpace(
spaceId: string | null | undefined,
spaces: readonly Pick<Space, "id" | "isDefault">[],
@ -18,5 +18,5 @@ export function isEmptyProjectSpace(
if (!spaceId || !homeSpaceId || spaceId === homeSpaceId) return false;
const space = spaces.find((item) => item.id === spaceId);
if (!space || space.isDefault) return false;
return projectGroupChatsInSpace(groups, spaceId).length === 0;
return !groups.some((group) => group.spaceId === spaceId);
}

View File

@ -1,20 +1,31 @@
import { Trans } from "@lingui/react/macro";
import { Button } from "@rakazo/ui-web";
import { useEffect, useState } from "react";
import { IntegrationSetup } from "../components/integrations/IntegrationSetup";
import { Suspense, useEffect, useState } from "react";
import { desktopBridge } from "../lib/desktop";
import { lazyWithRecovery } from "../lib/lazy-recovery";
import { rpc } from "../lib/rpc";
import { ModelSettingsOverlay } from "./ModelSettingsOverlay";
const IntegrationSetup = lazyWithRecovery("integrations/IntegrationSetup", () =>
import("../components/integrations/IntegrationSetup").then((module) => ({
default: module.IntegrationSetup,
})),
);
const ModelSettingsOverlay = lazyWithRecovery("pages/ModelSettingsOverlay", () =>
import("./ModelSettingsOverlay").then((module) => ({ default: module.ModelSettingsOverlay })),
);
export function LocalSettingsPage() {
const [section, setSection] = useState<"models" | "integrations" | null>(null);
const hasLocalBridge = Boolean(desktopBridge()?.localSettings);
const [ready, setReady] = useState(false);
const [error, setError] = useState(false);
const [error, setError] = useState(!hasLocalBridge);
useEffect(() => {
if (!hasLocalBridge) return;
void rpc.integrationSetup
.get()
.then(() => setReady(true))
.catch(() => setError(true));
}, []);
}, [hasLocalBridge]);
return (
<main className="h-full overflow-auto bg-background px-6 py-12">
<div className="mx-auto max-w-xl space-y-6">
@ -43,9 +54,15 @@ export function LocalSettingsPage() {
</Button>
</nav>
{section === "models" ? (
<Suspense fallback={null}>
<ModelSettingsOverlay onClose={() => setSection(null)} localOwner />
</Suspense>
) : null}
{section === "integrations" ? (
<Suspense fallback={null}>
<IntegrationSetup serverSetup managedOnly />
</Suspense>
) : null}
{section === "integrations" ? <IntegrationSetup serverSetup managedOnly /> : null}
</>
) : null}
</div>

View File

@ -1032,14 +1032,15 @@ export function ShellPage() {
}
return;
}
const inProject = Boolean(refreshSpaceId && refreshSpaceId !== refreshHomeSpaceId);
if (currentGroupId) {
if (!groupList.some((group) => group.id === currentGroupId)) {
navigate(firstThreadRoute(list, groupList), { replace: true });
navigate(firstThreadRoute(list, groupList, inProject), { replace: true });
}
return;
}
if (!currentBotId || !list.some((bot) => bot.id === currentBotId)) {
navigate(firstThreadRoute(list, groupList), { replace: true });
navigate(firstThreadRoute(list, groupList, inProject), { replace: true });
}
} finally {
botsRefreshInFlight.current -= 1;
@ -1276,12 +1277,17 @@ export function ShellPage() {
}
return;
}
const inProject = bootstrapSpaceId !== bootstrapHomeSpaceId;
if (groupId) {
if (!groupList.some((group) => group.id === groupId)) {
navigate(firstThreadRoute(bootstrap.bots, groupList), { replace: true });
navigate(firstThreadRoute(bootstrap.bots, groupList, inProject), { replace: true });
}
return;
}
if (inProject && !botId) {
navigate(firstThreadRoute(bootstrap.bots, groupList, true), { replace: true });
return;
}
const selectedBotId = bootstrap.thread?.botId ?? bootstrap.bots[0]?.id;
if (selectedBotId && selectedBotId !== botId) {
navigate(`/app/${selectedBotId}`, { replace: true });
@ -1658,6 +1664,8 @@ export function ShellPage() {
: group.title,
showLock: showSpaceNames,
emptySpaceId: undefined as string | undefined,
/** A project without its main conversation yet offers to start it. */
startSpaceId: undefined as string | undefined,
spaceId: space.id,
spaceName: space.name,
canDeleteSpace: index === 0 && space.canDelete === true,
@ -1674,6 +1682,7 @@ export function ShellPage() {
sectionId: null,
showLock: true,
emptySpaceId: space.id,
startSpaceId: undefined,
spaceId: space.id,
spaceName: space.name,
canDeleteSpace: space.canDelete === true,
@ -1737,11 +1746,13 @@ export function ShellPage() {
if (space.id === homeSpaceId || seenProjectSpaceIds.has(space.id)) continue;
seenProjectSpaceIds.add(space.id);
const groupsInSpace = sidebarGroups.flatMap((group) =>
group.spaceId === space.id
? group.bots.filter((item) => item.kind === "group" && item.chat.kind !== "main")
: [],
group.spaceId === space.id ? group.bots.filter((item) => item.kind === "group") : [],
);
const projectBots = dedupeSidebarBots(groupsInSpace);
// The project's main conversation, where the user talks to its main agent, comes first.
const projectBots = dedupeSidebarBots(groupsInSpace).sort(
(left, right) => Number(right.chat.kind === "main") - Number(left.chat.kind === "main"),
);
const hasMain = projectBots.some((item) => item.chat.kind === "main");
entries.push({
key:
projectBots.length > 0 ? `project-space:${space.id}` : `project-space:${space.id}:empty`,
@ -1750,6 +1761,7 @@ export function ShellPage() {
sectionId: null,
showLock: true,
emptySpaceId: projectBots.length > 0 ? undefined : space.id,
startSpaceId: hasMain ? undefined : space.id,
spaceId: space.id,
spaceName: space.name,
canDeleteSpace: space.canDelete === true,
@ -1834,12 +1846,15 @@ export function ShellPage() {
[bootstrapMe?.spaceId, navigate],
);
const openProjectStart = useCallback(
(spaceId: string, flow: "create" | "group") => {
// A project is a conversation with its own main agent, created when first opened.
const startProject = useCallback(
async (spaceId: string) => {
setActiveNavPillar("projekte");
openSpaceChat(spaceId, `/app?project=start&flow=${flow}`);
const main = await rpc.spaces.ensureMain({ spaceId });
if (spaceId === (selectedSpaceId() ?? bootstrapMe?.spaceId)) await refreshBots();
openSpaceChat(spaceId, `/app/g/${main.groupId}`);
},
[openSpaceChat],
[bootstrapMe?.spaceId, openSpaceChat, refreshBots],
);
const openMainConversation = useCallback(() => {
@ -2083,36 +2098,6 @@ export function ShellPage() {
}
}, [botId, emptyProjectSpace, groupId, initialBotsLoaded]);
useEffect(() => {
if (searchParams.get("project") !== "start" || !emptyProjectSpace || !initialBotsLoaded) {
return;
}
setActiveNavPillar("projekte");
const flow = searchParams.get("flow");
setPanel(
flow === "create"
? "create"
: flow === "group" && homeSpaceBots.length > 0
? "create-group"
: flow === "group"
? "create"
: bots.length === 0
? "create"
: "create-group",
);
const next = new URLSearchParams(searchParams);
next.delete("project");
next.delete("flow");
setSearchParams(next, { replace: true });
}, [
bots.length,
emptyProjectSpace,
homeSpaceBots.length,
initialBotsLoaded,
searchParams,
setSearchParams,
]);
const activeSnapshot = inGroup
? snapshot?.groupId === groupId
? snapshot
@ -4005,7 +3990,7 @@ export function ShellPage() {
className="flex min-w-0 flex-1 items-center justify-between gap-2 rounded-lg pl-8 py-1 text-ui-label font-semibold tracking-wider uppercase text-foreground/65 dark:text-muted-foreground/80 hover:text-foreground focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-ring"
onClick={() => {
if (group.emptySpaceId) {
openSpaceChat(group.emptySpaceId, "/app");
void startProject(group.emptySpaceId);
return;
}
toggleSidebarSection(group.key);
@ -4088,14 +4073,14 @@ export function ShellPage() {
) : null}
</div>
) : null}
{!collapsed && group.emptySpaceId ? (
{!collapsed && group.startSpaceId ? (
<div className="px-3 pb-2">
<Button
variant="outline"
size="sm"
className="w-full text-ui-label"
data-testid={`project-start-${group.spaceId}`}
onClick={() => openProjectStart(group.spaceId, "group")}
onClick={() => void startProject(group.spaceId)}
>
<Plus className="mr-1 size-icon-sm" />
{t`Start`}
@ -4110,6 +4095,8 @@ export function ShellPage() {
aria-label={
item.kind === "bot"
? t`${item.chat.name}, 1:1 conversation`
: item.chat.kind === "main"
? t`${item.chat.name}, main conversation`
: t`${item.chat.name}, group conversation`
}
onClick={() => {
@ -4122,7 +4109,12 @@ export function ShellPage() {
);
}}
onContextMenu={(event) => {
if (item.chat.spaceId !== bootstrapMe?.spaceId) return;
// A project's main conversation has no chat actions.
if (
item.chat.spaceId !== bootstrapMe?.spaceId ||
item.chat.kind === "main"
)
return;
event.preventDefault();
botMenuAnchor.current = event.currentTarget;
setBotMenu({
@ -4572,7 +4564,7 @@ export function ShellPage() {
<div className="grid flex-1 place-items-center">
<Button
data-testid="project-start-main"
onClick={() => currentSpaceId && openProjectStart(currentSpaceId, "create")}
onClick={() => currentSpaceId && void startProject(currentSpaceId)}
>
<Plus aria-hidden="true" className="size-icon-md" />
{t`Start`}
@ -4955,7 +4947,10 @@ export function ShellPage() {
const remainingGroups = groups.filter((group) => group.id !== activeGroup.id);
setGroups(remainingGroups);
setPanel(null);
navigate(firstThreadRoute(bots, remainingGroups), { replace: true });
navigate(
firstThreadRoute(bots, remainingGroups, currentSpaceId !== homeSpaceId),
{ replace: true },
);
await refreshBots().catch(() => undefined);
}}
/>
@ -7276,8 +7271,12 @@ function MessageHoverActions({
function firstThreadRoute(
bots: readonly Pick<Bot, "id">[],
groups: readonly Pick<Group, "id">[],
groups: readonly Pick<Group, "id" | "kind">[],
/** In a project the user lands in its main conversation. */
inProject = false,
): string {
const main = inProject ? groups.find((group) => group.kind === "main") : undefined;
if (main) return `/app/g/${main.id}`;
if (bots[0]) return `/app/${bots[0].id}`;
if (groups[0]) return `/app/g/${groups[0].id}`;
return "/app";

View File

@ -0,0 +1,47 @@
import { readFileSync } from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { parse } from "yaml";
const workflowText = readFileSync(
path.resolve(import.meta.dirname, "../../../.github/workflows/ci.yml"),
"utf8",
);
interface WorkflowJob {
if?: string;
needs?: string[];
"continue-on-error"?: boolean;
}
const workflow = parse(workflowText) as {
jobs: Record<string, WorkflowJob>;
};
describe("ci workflow merge gate", () => {
it("aggregates only product jobs in Product CI", () => {
const productGate = workflow.jobs["product-gate"];
expect(productGate?.needs).toEqual([
"lint",
"check",
"build",
"test",
"test-integration",
"test-e2e",
]);
expect(productGate?.if).toBe("always()");
});
it("soft-fails mobile OTA so missing Expo credentials do not fail the workflow", () => {
const mobile = workflow.jobs["publish-mobile-update"];
expect(mobile?.["continue-on-error"]).toBe(true);
expect(workflowText).toContain('echo "ready=false" >> "$GITHUB_OUTPUT"');
expect(workflowText).not.toMatch(
/Check Expo automation token[\s\S]*?exit 1[\s\S]*?Require Expo/,
);
expect(workflowText).not.toContain(
"Add an EXPO_TOKEN repository secret with access to the inbox-zero/rakazo Expo project.",
);
expect(workflowText).toContain("steps.expo_token.outputs.ready == 'true'");
});
});

View File

@ -5,6 +5,8 @@ import { selectBuiltinToolsForRun } from "./executor-tools.js";
function toolNames(options: {
mainBot?: boolean;
/** A project's main bot rather than the user's personal agent. */
projectLead?: boolean;
messagingChannelRun?: boolean;
foreignAgentRun?: boolean;
trigger?: string;
@ -20,60 +22,81 @@ function toolNames(options: {
kind: options.mainBot ? "main" : "specialist",
restrictBuiltinTools: false,
allowedBuiltinTools: [],
personalAgent: options.mainBot && !options.projectLead,
},
}).map((tool) => tool.name);
}
/** Any database access fails the test: refusals and invalid input must not reach it. */
const untouchedPrisma = new Proxy(
{},
/**
* Only the space lookup that tells the personal agent from a project lead may reach the
* database: refusals and invalid input must not write anything.
*/
const lookupOnlyPrisma = new Proxy(
{
get(_target, property) {
space: {
findUnique: async ({ where }: { where: { id: string } }) => ({
isDefault: where.id === "space-home",
}),
},
},
{
get(target, property) {
if (property === "space") return target.space;
throw new Error(`unexpected database access: ${String(property)}`);
},
},
) as PrismaClient;
) as unknown as PrismaClient;
const context = (kind: string) => ({ userId: "user-1", bot: { id: "bot-1", kind } });
const context = (kind: string, spaceId = "space-home") => ({
userId: "user-1",
spaceId,
bot: { id: "bot-1", kind },
});
describe("coach tools", () => {
it("are the main bot's alone, also in its check-in routines, and never for other people", () => {
it("are the personal main agent's alone, also in its check-in routines, and never for other people", () => {
for (const name of COACH_TOOL_NAMES) {
expect(toolNames({ mainBot: true })).toContain(name);
expect(toolNames({ mainBot: true, trigger: "routine" })).toContain(name);
expect(toolNames({ mainBot: false })).not.toContain(name);
expect(toolNames({ mainBot: true, projectLead: true })).not.toContain(name);
expect(toolNames({ mainBot: true, messagingChannelRun: true })).not.toContain(name);
expect(toolNames({ mainBot: true, foreignAgentRun: true })).not.toContain(name);
}
});
it("refuse specialists even when they call a tool anyway", async () => {
it("refuse specialists and project leads even when they call a tool anyway", async () => {
for (const name of COACH_TOOL_NAMES) {
for (const caller of [context("specialist"), context("main", "space-project")]) {
await expect(
executeCoachTool(untouchedPrisma, context("specialist"), name, { title: "Run" }),
executeCoachTool(lookupOnlyPrisma, caller, name, { title: "Run" }),
).resolves.toEqual({ error: "Only the user's main bot can coach the user." });
}
}
});
it("reject invalid or incomplete arguments before writing", async () => {
const main = context("main");
await expect(executeCoachTool(untouchedPrisma, main, "update_goal", {})).resolves.toEqual({
await expect(executeCoachTool(lookupOnlyPrisma, main, "update_goal", {})).resolves.toEqual({
error: "A new goal needs a title.",
});
await expect(
executeCoachTool(untouchedPrisma, main, "update_goal", { title: "x", target_date: "soon" }),
executeCoachTool(lookupOnlyPrisma, main, "update_goal", { title: "x", target_date: "soon" }),
).resolves.toEqual({ error: expect.stringContaining("target_date") });
await expect(
executeCoachTool(untouchedPrisma, main, "update_habit", { title: "Walk", cadence: "hourly" }),
executeCoachTool(lookupOnlyPrisma, main, "update_habit", {
title: "Walk",
cadence: "hourly",
}),
).resolves.toEqual({ error: expect.stringContaining("cadence") });
await expect(
executeCoachTool(untouchedPrisma, main, "log_check_in", { habit: "Walk", goal: "10k" }),
executeCoachTool(lookupOnlyPrisma, main, "log_check_in", { habit: "Walk", goal: "10k" }),
).resolves.toEqual({ error: "Log a habit and a goal in separate calls." });
await expect(
executeCoachTool(untouchedPrisma, main, "log_check_in", { mood: 9 }),
executeCoachTool(lookupOnlyPrisma, main, "log_check_in", { mood: 9 }),
).resolves.toEqual({ error: expect.stringContaining("mood") });
await expect(
executeCoachTool(untouchedPrisma, main, "write_daily_brief", { text: " " }),
executeCoachTool(lookupOnlyPrisma, main, "write_daily_brief", { text: " " }),
).resolves.toEqual({ error: expect.stringContaining("text") });
});
});

View File

@ -16,6 +16,7 @@ import {
createGoal,
createHabit,
InvalidCoachInputError,
isPersonalMainBot,
logCheckIn,
updateGoal,
updateHabit,
@ -142,6 +143,8 @@ const WriteDailyBriefArgs = z.object({
export interface CoachToolContext {
userId: string;
/** The space the run happens in. */
spaceId: string;
bot: { id: string; kind: string };
}
@ -333,14 +336,14 @@ const HANDLERS: Record<
write_daily_brief: writeDailyBriefFromTool,
};
/** Runs one coach tool for the user's main bot; every other bot is refused. */
/** Runs one coach tool for the user's personal main agent; every other bot is refused. */
export async function executeCoachTool(
prisma: PrismaClient,
context: CoachToolContext,
name: CoachToolName,
rawArgs: Record<string, unknown>,
): Promise<ToolResult> {
if (context.bot.kind !== "main") {
if (!(await isPersonalMainBot(prisma, { kind: context.bot.kind, spaceId: context.spaceId }))) {
return { error: "Only the user's main bot can coach the user." };
}
try {

View File

@ -53,9 +53,10 @@ async function notifyThreads(deps: DelegationDeps, seqs: ThreadSeq[]) {
}
/**
* Hands work from the main bot to a specialist as a tracked task. The card is posted in the main
* conversation; a specialist of the same space joins it and works there, one of another space
* works in its own chat. Replays of the same tool call return the task created first.
* Hands work from a main bot to a specialist as a tracked task. The card is posted in the main
* conversation of the main bot's space; a specialist of that space joins it and works there, one
* of another space works in its own chat. Replays of the same tool call return the task created
* first.
*/
export async function delegateTask(
deps: DelegationDeps,
@ -63,6 +64,7 @@ export async function delegateTask(
id: string;
botId: string;
userId: string;
spaceId: string;
sourceMessageId?: string | null;
},
sender: { id: string; name: string },
@ -91,8 +93,8 @@ export async function delegateTask(
if (deadlineAt.getTime() <= Date.now()) return { error: "deadline must be in the future" };
}
// Resolving the conversation also settles who the main bot is; only its lead delegates.
const conversation = await ensureMainConversation(deps.prisma, run.userId);
// Resolving the conversation also settles who the space's main bot is; only its lead delegates.
const conversation = await ensureMainConversation(deps.prisma, run.userId, run.spaceId);
if (conversation.leadBotId !== sender.id) {
return { error: "only the main agent can delegate tasks" };
}
@ -115,8 +117,9 @@ export async function delegateTask(
const candidates = await deps.prisma.bot.findMany({
where: {
userId: run.userId,
// Main bots lead their own space and are never delegated to.
kind: "specialist",
archivedAt: null,
id: { not: sender.id },
thread: { isNot: null },
space: { memberships: { some: { userId: run.userId } } },
},
@ -139,7 +142,11 @@ export async function delegateTask(
const targetThreadId = target.thread.id;
const inConversation = target.spaceId === conversation.spaceId;
if (inConversation) {
await joinMainConversation(deps.prisma, { userId: run.userId, botIds: [target.id] });
await joinMainConversation(deps.prisma, {
userId: run.userId,
spaceId: conversation.spaceId,
botIds: [target.id],
});
}
const deliveryKey = `delegated-task:${input.deliveryKey}`;

View File

@ -88,6 +88,7 @@ async function runBot(policy: BotPolicy, calls: Call[] = [], models: Record<stri
updateMany: vi.fn(async () => ({ count: 1 })),
},
thread: { findUniqueOrThrow: vi.fn(async () => ({ id: run.threadId, groupId: null })) },
space: { findUnique: vi.fn(async () => ({ name: "Home", isDefault: true })) },
message: { findMany: vi.fn(async () => []) },
task: { findUniqueOrThrow: vi.fn(async () => ({ id: run.taskId, prompt: "Work" })) },
connection: { findMany: vi.fn(async () => []) },

View File

@ -33,6 +33,8 @@ interface FixtureOptions {
spaceId?: string;
space?: { name: string };
}>;
/** The space the run happens in; a main bot outside the home space leads that project. */
space?: { name: string; isDefault: boolean };
}
/** Runs one turn through the real executor and returns the request the runtime received. */
@ -96,6 +98,9 @@ async function runOnce(options: FixtureOptions = {}) {
})),
},
chatGroup: { findUnique: vi.fn(async () => options.group ?? null) },
space: {
findUnique: vi.fn(async () => options.space ?? { name: "Home", isDefault: true }),
},
message: {
findMany: vi.fn(async () => []),
findUnique: vi.fn(async () => ({ blocks: options.sourceBlocks ?? [] })),
@ -404,6 +409,17 @@ describe("run instructions", () => {
expect(specialist.request.instructions).not.toContain("Active goals");
expect(specialist.request.instructions).not.toContain("You are also their coach");
expect(specialist.request.tools.map((tool) => tool.name)).not.toContain("update_goal");
// A project's main bot leads its project; coaching stays with the personal agent.
const lead = await runOnce({
bot: { kind: "main", name: "Chief", title: "Project lead", instructions: "" },
space: { name: "Launch", isDefault: false },
...coach,
});
expect(lead.request.instructions).not.toContain("Active goals");
expect(lead.request.instructions).not.toContain("You are also their coach");
expect(lead.request.tools.map((tool) => tool.name)).not.toContain("update_goal");
expect(lead.request.tools.map((tool) => tool.name)).not.toContain("notify_user");
});
it("leaves open onboarding questions to their cards, for the main bot only", async () => {
@ -417,6 +433,23 @@ describe("run instructions", () => {
).not.toContain(note);
expect((await runOnce({ bot: main })).request.instructions).not.toContain(note);
expect((await runOnce({ onboarding: open })).request.instructions).not.toContain(note);
const project = { name: "Launch", isDefault: false };
expect(
(await runOnce({ bot: main, space: project, onboarding: open })).request.instructions,
).not.toContain(note);
});
it("describes a project's main bot as the lead of that project", async () => {
const { request } = await runOnce({
bot: { kind: "main", name: "Chief", title: "Project lead", instructions: "" },
space: { name: "Launch", isDefault: false },
});
expect(request.instructions).toContain(
'You lead the project "Launch" for the user: they talk to you about it, and only to you.',
);
expect(request.instructions).not.toContain("You are the user's main agent");
expect(request.instructions).toContain("Give a specialist tracked work with delegate_task");
});
it("shows the main bot every specialist it can delegate to, its own space first", async () => {
@ -447,6 +480,7 @@ describe("run instructions", () => {
expect.objectContaining({
where: expect.objectContaining({
userId: "user-1",
kind: "specialist",
space: { memberships: { some: { userId: "user-1" } } },
}),
}),

View File

@ -32,10 +32,22 @@ export function formatBotIdentity(bot: BotIdentity): string {
* The main bot's role. Tools are named only when this run has them, so the paragraph never
* points at a tool the model cannot call.
*/
export function formatMainAgentRole(toolNames: ReadonlySet<string>): string {
export function formatMainAgentRole(
toolNames: ReadonlySet<string>,
/** Name of the project this main bot leads; omitted for the user's personal main agent. */
project?: string,
): string {
const projectName = project?.trim() ? escapePromptData(project.trim()) : undefined;
return [
"You are the user's main agent: the one they talk to first and the one who knows them best.",
"Keep the overview of their work and coordinate their specialist bots instead of doing every job yourself; when a specialist delivers, tell the user what it means for them.",
projectName
? `You lead the project "${projectName}" for the user: they talk to you about it, and only to you.`
: "You are the user's main agent: the one they talk to first and the one who knows them best.",
projectName
? "Keep the overview of the project and coordinate its specialist bots instead of doing every job yourself; when a specialist delivers, tell the user what it means for them."
: "Keep the overview of their work and coordinate their specialist bots instead of doing every job yourself; when a specialist delivers, tell the user what it means for them.",
projectName && toolNames.has("spawn_bot")
? "When the project needs a specialist nobody covers yet, create one with spawn_bot."
: undefined,
toolNames.has("delegate_task")
? "Give a specialist tracked work with delegate_task; its result comes back to you."
: undefined,

View File

@ -4,6 +4,7 @@ import {
builtinToolNamesForBot,
filterBuiltinToolsForBot,
MAIN_ONLY_BUILTIN_TOOLS,
PERSONAL_AGENT_TOOLS,
selectBuiltinToolsForRun,
unknownBuiltinToolNames,
} from "./executor-tools.js";
@ -81,6 +82,17 @@ describe("builtin tools per bot", () => {
}
});
it("keeps the personal agent's tools from a project's main bot", () => {
const main = { kind: "main", restrictBuiltinTools: false, allowedBuiltinTools: [] };
const personal = filterBuiltinToolsForBot(builtinAgentTools, { ...main, personalAgent: true });
const lead = filterBuiltinToolsForBot(builtinAgentTools, main).map((tool) => tool.name);
expect(personal.map((tool) => tool.name)).toEqual(
expect.arrayContaining([...PERSONAL_AGENT_TOOLS]),
);
for (const name of PERSONAL_AGENT_TOOLS) expect(lead).not.toContain(name);
expect(lead).toEqual(expect.arrayContaining(["delegate_task", "update_user_profile"]));
});
it("lists agent connection tools only for chat-linked bots", () => {
expect(builtinToolNamesForBot({ kind: "specialist", chatLinked: false })).toEqual(
specialistToolNames,

View File

@ -17,12 +17,20 @@ export const PAGE_BROWSER_TOOL_NAMES = new Set([
"browser_act",
]);
/**
* Main-only tools that stay with the user's personal main agent: a project's main bot leads its
* project but does not coach or nudge the user.
*/
export const PERSONAL_AGENT_TOOLS: ReadonlySet<string> = new Set<string>([
"notify_user",
...COACH_TOOL_NAMES,
]);
/** Builtin tools only the user's main bot may use; every other bot's runs never see them. */
export const MAIN_ONLY_BUILTIN_TOOLS: ReadonlySet<string> = new Set<string>([
"delegate_task",
"notify_user",
"update_user_profile",
...COACH_TOOL_NAMES,
...PERSONAL_AGENT_TOOLS,
]);
/** Tools that write what the user's bots know about the user, so only the user may drive them. */
@ -39,6 +47,8 @@ export interface BuiltinToolPolicy {
kind: string;
restrictBuiltinTools: boolean;
allowedBuiltinTools: readonly string[];
/** The main bot of the user's home space; only it gets the personal agent's tools. */
personalAgent?: boolean;
}
/**
@ -65,11 +75,13 @@ export function filterBuiltinToolsForBot(
bot: BuiltinToolPolicy,
): ConnectorTool[] {
const main = bot.kind === "main";
const personal = main && bot.personalAgent === true;
const allowed = new Set(bot.allowedBuiltinTools);
return tools
.filter(
(tool) =>
(main || !MAIN_ONLY_BUILTIN_TOOLS.has(tool.name)) &&
(personal || !PERSONAL_AGENT_TOOLS.has(tool.name)) &&
(!bot.restrictBuiltinTools || allowed.has(tool.name)),
)
.map((tool) => (main && tool.name === "schedule_create" ? withScheduleTargetBot(tool) : tool));

View File

@ -92,6 +92,7 @@ import {
createSpaceForMember,
createThreadMessageInTransaction,
effectiveMemoryScope,
ensureMainConversation,
findDefaultModelCredential,
findModelCredential,
findSpaceModelTier,
@ -1479,6 +1480,16 @@ export function createRunExecutor(deps: ExecutorDeps) {
select: { id: true },
}))
: false;
// A main bot outside the home space leads that project; the home one is the user's
// personal agent, the only one that coaches and nudges them.
const mainAgentSpace =
bot.kind === "main"
? await deps.prisma.space.findUnique({
where: { id: run.spaceId },
select: { name: true, isDefault: true },
})
: null;
const personalAgent = mainAgentSpace?.isDefault === true;
const builtins = selectBuiltinToolsForRun({
graphicalToolsAllowed,
pageBrowserAllowed,
@ -1489,7 +1500,7 @@ export function createRunExecutor(deps: ExecutorDeps) {
messagingChannelRun,
agentConnectionsEnabled: hasMessagingIdentity,
foreignAgentRun,
bot,
bot: { ...bot, personalAgent },
});
const exposedConnectorTools = discovered.filter(
(tool) => !builtinAgentTools.some((builtin) => builtin.name === tool.name),
@ -2618,7 +2629,12 @@ export function createRunExecutor(deps: ExecutorDeps) {
}
if (isCoachToolName(name)) {
return finish(
await executeCoachTool(deps.prisma, { userId: run.userId, bot }, name, args),
await executeCoachTool(
deps.prisma,
{ userId: run.userId, spaceId: run.spaceId, bot },
name,
args,
),
);
}
if (name === "web_search") {
@ -3231,6 +3247,8 @@ export function createRunExecutor(deps: ExecutorDeps) {
userId: run.userId,
name: String(args.name ?? ""),
});
// A project starts as a conversation with its own main agent.
await ensureMainConversation(deps.prisma, run.userId, space.id);
return finish({ ok: true, spaceId: space.id, name: space.name });
} catch (error) {
if (error instanceof SpaceLimitError || error instanceof InvalidSpaceNameError) {
@ -3680,8 +3698,9 @@ export function createRunExecutor(deps: ExecutorDeps) {
await deps.prisma.bot.findMany({
where: {
userId: run.userId,
// Main bots lead their own space and are never delegated to.
kind: "specialist",
archivedAt: null,
id: { not: bot.id },
thread: { isNot: null },
space: { memberships: { some: { userId: run.userId } } },
},
@ -3739,12 +3758,14 @@ export function createRunExecutor(deps: ExecutorDeps) {
)
: undefined;
const timeInstruction = formatCurrentTimeInstruction(userContext.timeZone);
const mainAgentProject =
mainAgentSpace && !mainAgentSpace.isDefault ? mainAgentSpace.name : undefined;
// Replies to another owner's agent, like a shared channel, only get the basics.
const basicsOnly = messagingChannelRun || foreignAgentRun;
const userInstruction = formatUserContext(userContext, {
basicsOnly,
coach:
bot.kind === "main" && !basicsOnly
personalAgent && !basicsOnly
? await loadCoachSummary(deps.prisma, run.userId)
: undefined,
});
@ -3776,10 +3797,12 @@ export function createRunExecutor(deps: ExecutorDeps) {
instructions: [
formatBotIdentity(bot),
bot.instructions,
bot.kind === "main" ? formatMainAgentRole(exposedToolNames) : undefined,
bot.kind === "main"
? formatMainAgentRole(exposedToolNames, mainAgentProject)
: undefined,
timeInstruction,
userBlock,
bot.kind === "main" &&
personalAgent &&
userContext.onboardingOpen &&
!(messagingChannelRun || foreignAgentRun)
? ONBOARDING_OPEN_INSTRUCTION

View File

@ -5,6 +5,7 @@ import {
claimDailyNudge,
countPushSubscriptions,
ensureMainConversation,
isPersonalMainBot,
loadNotificationPolicy,
} from "@rakazo/db";
import { z } from "zod";
@ -36,7 +37,9 @@ export async function notifyUserFromTool(
context: NotifyUserToolContext,
rawArgs: Record<string, unknown>,
): Promise<NotifyUserResult> {
if (context.bot.kind !== "main") {
if (
!(await isPersonalMainBot(deps.prisma, { kind: context.bot.kind, spaceId: context.spaceId }))
) {
return { error: "Only the user's main bot can notify the user." };
}
const parsed = NotifyUserArgs.safeParse(rawArgs);

View File

@ -3,6 +3,7 @@ import type { PrismaClient } from "@rakazo/db";
import {
bootstrapUserSpace,
createDb,
createSpaceForMember,
ensureMainConversation,
savePushSubscription,
updateNotificationSettings,
@ -116,7 +117,7 @@ describePostgres("push delivery and notify_user (PostgreSQL)", () => {
expect(devices[0]).toMatchObject({ endpoint: browser, lastUsedAt: expect.any(Date) });
});
it("lets only the main bot nudge, within the user's settings and the daily cap", async () => {
it("lets only the personal main agent nudge, within the user's settings and the daily cap", async () => {
const user = await newUser();
const web = recordingSender();
const deps = {
@ -129,6 +130,20 @@ describePostgres("push delivery and notify_user (PostgreSQL)", () => {
body: "Hi",
}),
).resolves.toEqual({ error: "Only the user's main bot can notify the user." });
// A project's main bot leads its project; nudging the user is the personal agent's job.
const project = await createSpaceForMember(prisma, {
currentSpaceId: user.conversation.spaceId,
userId: user.userId,
name: "Launch",
});
const lead = await ensureMainConversation(prisma, user.userId, project.id);
await expect(
notifyUserFromTool(
deps,
{ ...toolContext(user, { ...user.main, id: lead.leadBotId }), spaceId: project.id },
{ body: "Hi" },
),
).resolves.toEqual({ error: "Only the user's main bot can notify the user." });
await expect(notifyUserFromTool(deps, toolContext(user), {})).resolves.toMatchObject({
error: expect.stringContaining("body is required"),
});

View File

@ -313,8 +313,10 @@ export async function consolidateProfile(
// The scripted runtime answers with canned text keyed off the prompt; facts "learned" from it
// would be nonsense. Skip without touching the cursor.
if (!deps.runtime.describe().capabilities.compaction) return;
// Every project has its own main bot; the user's personal one lives in the home space.
const main = await deps.prisma.bot.findFirst({
where: { userId, kind: "main", archivedAt: null },
orderBy: [{ space: { isDefault: "desc" } }, { createdAt: "asc" }],
select: { id: true, spaceId: true, thread: { select: { id: true } } },
});
if (!main?.thread) return;

View File

@ -40,7 +40,7 @@ export const AgentSecretInputSchema = z.object({
});
export type AgentSecretInput = z.infer<typeof AgentSecretInputSchema>;
/** "main" is the user's single lead agent; every other bot is a "specialist". */
/** "main" leads a space for the user (their home space or a project); every other bot is a "specialist". */
export const BOT_KINDS = ["main", "specialist"] as const;
export const BotKindSchema = z.enum(BOT_KINDS);
export type BotKind = z.infer<typeof BotKindSchema>;
@ -113,7 +113,7 @@ export const REPLY_QUOTE_MAX_LENGTH = 2_000;
export const GROUP_MEMBER_MIN = 2;
export const GROUP_MEMBER_MAX = 6;
/** "main" is the user's main conversation, led by the main bot; it has no member limits. */
/** "main" is a space's main conversation, led by its main bot; it has no member limits. */
export const GROUP_KINDS = ["group", "main"] as const;
export const GroupKindSchema = z.enum(GROUP_KINDS);
export type GroupKind = z.infer<typeof GroupKindSchema>;
@ -304,7 +304,7 @@ export const SpaceSchema = z.object({
});
export type Space = z.infer<typeof SpaceSchema>;
/** Where the user's main bot and main conversation live; the space may differ from the current one. */
/** Where a main bot and its main conversation live; the space may differ from the current one. */
export const MainConversationRefSchema = z.object({
botId: Id,
groupId: Id,

View File

@ -51,6 +51,7 @@ import {
GroupDetailSchema,
GroupSchema,
IntegrationCatalogResultSchema,
MainConversationRefSchema,
McpServerConfigInput,
McpServerSchema,
MemoryDocumentSchema,
@ -249,6 +250,8 @@ export const appContract = {
remove: oc
.input(z.object({ spaceId: Id }))
.output(z.object({ ok: z.literal(true), activeSpaceId: Id })),
/** The space's main conversation, created with its main bot when the user first opens it. */
ensureMain: oc.input(z.object({ spaceId: Id })).output(MainConversationRefSchema),
},
bootstrap: oc.input(z.object({ botId: Id.optional() })).output(AppBootstrapSchema),
deployment: {

View File

@ -0,0 +1,7 @@
-- Every space a user works in (their home space and each project) gets its own main agent and
-- main conversation, so uniqueness moves from the user to the user within a space.
DROP INDEX "bots_userId_main_key";
DROP INDEX "chat_groups_userId_main_key";
CREATE UNIQUE INDEX "bots_userId_spaceId_main_key" ON "bots"("userId", "spaceId") WHERE "kind" = 'main';
CREATE UNIQUE INDEX "chat_groups_userId_spaceId_main_key" ON "chat_groups"("userId", "spaceId") WHERE "kind" = 'main';

View File

@ -410,7 +410,7 @@ model Bot {
webhookSecretId String?
teamChatAmbientEnabled Boolean @default(false)
teamChatRules String @default("")
/// "main" for the user's single lead agent, "specialist" for every other bot.
/// "main" for the user's lead agent of the space (one per user and space), "specialist" otherwise.
kind String @default("specialist")
tags String[] @default([])
restrictBuiltinTools Boolean @default(false)
@ -435,7 +435,7 @@ model Bot {
externalConversations ExternalConversation[]
@@unique([spaceId, spawnKey])
// CHECK bots_kind_chk and partial unique index bots_userId_main_key are created in SQL.
// CHECK bots_kind_chk and partial unique index bots_userId_spaceId_main_key are created in SQL.
@@index([spaceId, userId, archivedAt, pinned, updatedAt])
@@index([sectionId])
@@index([computerId])
@ -481,7 +481,7 @@ model ChatGroup {
space Space @relation(fields: [spaceId], references: [id], onDelete: Cascade)
userId String
name String
/// "main" for the user's main conversation led by the main bot, "group" otherwise.
/// "main" for the space's main conversation led by its main bot, "group" otherwise.
kind String @default("group")
leadBotId String?
leadBot Bot? @relation("ChatGroupLead", fields: [leadBotId], references: [id], onDelete: SetNull)
@ -499,7 +499,7 @@ model ChatGroup {
@@index([spaceId, userId, archivedAt, pinned, updatedAt])
@@index([sectionId])
@@index([leadBotId])
// CHECK chat_groups_kind_chk and partial unique index chat_groups_userId_main_key are created in SQL.
// CHECK chat_groups_kind_chk and partial unique index chat_groups_userId_spaceId_main_key are created in SQL.
@@map("chat_groups")
}

View File

@ -11,6 +11,7 @@ import {
joinMainConversation,
loadUserContext,
MAIN_BOT_SPAWN_KEY,
PROJECT_MAIN_BOT_DEFAULTS,
} from "./main-agent.js";
import { createRepos } from "./repos.js";
import { requireMembership } from "./scope.js";
@ -79,6 +80,37 @@ describePostgres("main agent and main conversation (PostgreSQL)", () => {
expect(await findMainBotId(prisma, userId)).toBe(bots[0]?.id);
});
it("gives every project its own main bot and main conversation next to the home one", async () => {
const { userId, homeSpaceId } = await newUser();
const home = await ensureMainConversation(prisma, userId);
const project = await createSpaceForMember(prisma, {
currentSpaceId: homeSpaceId,
userId,
name: "Launch",
});
const [a, b] = await Promise.all([
ensureMainConversation(prisma, userId, project.id),
ensureMainConversation(prisma, userId, project.id),
]);
expect(a.groupId).toBe(b.groupId);
expect(a).toMatchObject({ spaceId: project.id });
expect(a.groupId).not.toBe(home.groupId);
expect(a.leadBotId).not.toBe(home.leadBotId);
const lead = await prisma.bot.findUniqueOrThrow({ where: { id: a.leadBotId } });
expect(lead).toMatchObject({
kind: "main",
spaceId: project.id,
spawnKey: null,
name: PROJECT_MAIN_BOT_DEFAULTS.name,
title: PROJECT_MAIN_BOT_DEFAULTS.title,
});
expect(await findMainBotId(prisma, userId)).toBe(home.leadBotId);
expect(await findMainBotId(prisma, userId, project.id)).toBe(a.leadBotId);
// The home conversation keeps its own main bot.
expect(await ensureMainConversation(prisma, userId)).toEqual(home);
});
it("creates the main bot without the spawn key when another user holds it in a shared space", async () => {
const owner = await newUser();
const ownerMain = await ensureMainBot(prisma, owner.userId);

View File

@ -16,6 +16,13 @@ export const MAIN_BOT_DEFAULTS = {
description: "Coordinates specialists — routes work, never a do-everything generalist.",
} as const;
/** Name and role of a project's main bot, the one the user talks to about that project. */
export const PROJECT_MAIN_BOT_DEFAULTS = {
name: "Chief",
title: "Project lead",
description: "Leads this project and hands its work to specialists.",
} as const;
/** Upper bound on facts handed to a run, so the user block stays small. */
export const USER_CONTEXT_FACT_LIMIT = 60;
@ -58,51 +65,92 @@ const mainBotSelect = {
thread: { select: { id: true } },
} as const;
async function findMainBot(prisma: PrismaClient, userId: string) {
return prisma.bot.findFirst({ where: { userId, kind: "main" }, select: mainBotSelect });
async function findMainBot(prisma: PrismaClient, userId: string, spaceId: string) {
return prisma.bot.findFirst({ where: { userId, spaceId, kind: "main" }, select: mainBotSelect });
}
export async function findMainBotId(prisma: PrismaClient, userId: string): Promise<string | null> {
/**
* The user's active main bot in `spaceId`, or in their home space when it is omitted. Every
* space the user works in has its own main bot: the home one is their personal main agent, a
* project's leads that project.
*/
export async function findMainBotId(
prisma: PrismaClient,
userId: string,
spaceId?: string,
): Promise<string | null> {
const space = spaceId ?? (await requireMembership(prisma, userId)).spaceId;
const bot = await prisma.bot.findFirst({
where: { userId, kind: "main", archivedAt: null },
where: { userId, spaceId: space, kind: "main", archivedAt: null },
select: { id: true },
});
return bot?.id ?? null;
}
/**
* Returns the user's main bot, creating it in the home space when it does not exist yet.
* A first bot that onboarding already created there is promoted instead of duplicated, and
* an archived main bot is restored, because the main bot cannot be retired.
* Whether a bot is the user's personal main agent, the main bot of their home space. Goals,
* habits, check-ins and nudges belong to it alone; a project's main bot leads only that project.
*/
export async function ensureMainBot(prisma: PrismaClient, userId: string): Promise<MainBotRef> {
let main = await findMainBot(prisma, userId);
export async function isPersonalMainBot(
prisma: Pick<PrismaClient, "space">,
bot: { kind: string; spaceId: string },
): Promise<boolean> {
if (bot.kind !== "main") return false;
const space = await prisma.space.findUnique({
where: { id: bot.spaceId },
select: { isDefault: true },
});
return space?.isDefault === true;
}
/**
* Returns the user's main bot in `spaceId` (default: the home space), creating it when it does
* not exist yet. In the home space, a first bot that onboarding already created is promoted
* instead of duplicated. An archived main bot is restored, because a main bot cannot be retired.
*/
export async function ensureMainBot(
prisma: PrismaClient,
userId: string,
spaceId?: string,
): Promise<MainBotRef> {
const actor = await requireMembership(prisma, userId, spaceId);
let main = await findMainBot(prisma, userId, actor.spaceId);
if (!main) {
const home = await requireMembership(prisma, userId);
const first = await prisma.bot.findFirst({
const space = await prisma.space.findUnique({
where: { id: actor.spaceId },
select: { isDefault: true },
});
const home = space?.isDefault === true || spaceId === undefined;
const first = home
? await prisma.bot.findFirst({
where: {
userId,
spaceId: home.spaceId,
spaceId: actor.spaceId,
spawnKey: MAIN_BOT_SPAWN_KEY,
archivedAt: null,
},
select: { id: true },
});
})
: null;
try {
if (first) {
await prisma.bot.update({ where: { id: first.id }, data: { kind: "main" } });
} else {
// spawnKey is unique per space, so in a space shared with another user who already
// holds it, the main bot is created without one.
const keyHolder = await prisma.bot.findUnique({
where: { spaceId_spawnKey: { spaceId: home.spaceId, spawnKey: MAIN_BOT_SPAWN_KEY } },
// holds it, the main bot is created without one. Projects never carry it.
const keyHolder = home
? await prisma.bot.findUnique({
where: { spaceId_spawnKey: { spaceId: actor.spaceId, spawnKey: MAIN_BOT_SPAWN_KEY } },
select: { userId: true },
});
await createRepos(prisma).createBot(home, {
...MAIN_BOT_DEFAULTS,
})
: null;
await createRepos(prisma).createBot(actor, {
...(home ? MAIN_BOT_DEFAULTS : PROJECT_MAIN_BOT_DEFAULTS),
instructions: "",
notifyOnFinish: true,
...(keyHolder && keyHolder.userId !== userId ? {} : { spawnKey: MAIN_BOT_SPAWN_KEY }),
...(home && (!keyHolder || keyHolder.userId === userId)
? { spawnKey: MAIN_BOT_SPAWN_KEY }
: {}),
kind: "main",
});
}
@ -110,7 +158,7 @@ export async function ensureMainBot(prisma: PrismaClient, userId: string): Promi
// A concurrent call promoted or created the main bot first.
if (!isUniqueViolation(error)) throw error;
}
main = await findMainBot(prisma, userId);
main = await findMainBot(prisma, userId, actor.spaceId);
if (!main) throw new IsolationError("Main bot could not be created");
}
if (main.archivedAt) {
@ -120,31 +168,36 @@ export async function ensureMainBot(prisma: PrismaClient, userId: string): Promi
return { id: main.id, spaceId: main.spaceId, threadId: main.thread.id, name: main.name };
}
async function findMainConversation(prisma: PrismaClient, userId: string, mainBotId: string) {
async function findMainConversation(
prisma: PrismaClient,
main: { userId: string; spaceId: string; botId: string },
) {
return prisma.chatGroup.findFirst({
where: { userId, kind: "main" },
where: { userId: main.userId, spaceId: main.spaceId, kind: "main" },
select: {
id: true,
spaceId: true,
leadBotId: true,
archivedAt: true,
thread: { select: { id: true } },
members: { where: { botId: mainBotId }, select: { id: true } },
members: { where: { botId: main.botId }, select: { id: true } },
},
});
}
/**
* Returns the user's main conversation: a group thread in the main bot's space whose lead and
* default responder is the main bot. It is created on first use and repaired when its lead
* was lost or it was archived.
* Returns the user's main conversation in `spaceId` (default: the home space): a group thread
* whose lead and default responder is that space's main bot. It is created on first use and
* repaired when its lead was lost or it was archived.
*/
export async function ensureMainConversation(
prisma: PrismaClient,
userId: string,
spaceId?: string,
): Promise<MainConversation> {
const main = await ensureMainBot(prisma, userId);
let group = await findMainConversation(prisma, userId, main.id);
const main = await ensureMainBot(prisma, userId, spaceId);
const key = { userId, spaceId: main.spaceId, botId: main.id };
let group = await findMainConversation(prisma, key);
if (!group) {
try {
await prisma.$transaction(async (tx) => {
@ -167,7 +220,7 @@ export async function ensureMainConversation(
// A concurrent call created the main conversation first.
if (!isUniqueViolation(error)) throw error;
}
group = await findMainConversation(prisma, userId, main.id);
group = await findMainConversation(prisma, key);
} else {
const repairs: { leadBotId?: string; archivedAt?: null } = {};
if (group.leadBotId !== main.id) repairs.leadBotId = main.id;
@ -192,17 +245,17 @@ export async function ensureMainConversation(
}
/**
* Adds bots to the user's main conversation so they appear on its stage. Only active bots of
* the same user in the conversation's space qualify; others are skipped. Returns the ids that
* were not members before, so callers can announce the change.
* Adds bots to the user's main conversation in `spaceId` (default: the home space) so they
* appear on its stage. Only active bots of the same user in that space qualify; others are
* skipped. Returns the ids that were not members before, so callers can announce the change.
*/
export async function addMainConversationMembers(
prisma: PrismaClient,
input: { userId: string; botIds: string[] },
input: { userId: string; spaceId?: string; botIds: string[] },
): Promise<string[]> {
const requested = [...new Set(input.botIds)];
if (requested.length === 0) return [];
const conversation = await ensureMainConversation(prisma, input.userId);
const conversation = await ensureMainConversation(prisma, input.userId, input.spaceId);
const bots = await prisma.bot.findMany({
where: {
id: { in: requested },
@ -234,12 +287,12 @@ export async function addMainConversationMembers(
*/
export async function joinMainConversation(
prisma: PrismaClient,
input: { userId: string; botIds: string[] },
input: { userId: string; spaceId?: string; botIds: string[] },
realtime?: RealtimeFanout,
): Promise<string[]> {
const added = await addMainConversationMembers(prisma, input);
if (added.length === 0) return added;
const conversation = await ensureMainConversation(prisma, input.userId);
const conversation = await ensureMainConversation(prisma, input.userId, input.spaceId);
await appendEvent(
prisma,
{

View File

@ -288,6 +288,8 @@ type SpaceDeleteDb = Pick<PrismaClient, "spaceMember" | "bot" | "chatGroup" | "c
async function assertEmptySpaceDeletable(
db: SpaceDeleteDb,
input: EmptySpaceDeleteInput,
/** Leave the deleting user's own main bot and main conversation out of the emptiness check. */
options: { exceptOwnMain?: boolean } = {},
): Promise<{
organizationId: string;
memberships: Array<{
@ -338,9 +340,12 @@ async function assertEmptySpaceDeletable(
orderBy: { createdAt: "asc" },
});
if (memberships.length <= 1) throw new CannotDeleteLastSpaceError();
const exceptOwnMain = options.exceptOwnMain
? { NOT: { userId: input.userId, kind: "main" } }
: {};
const [botCount, groupCount, computers] = await Promise.all([
db.bot.count({ where: { spaceId: input.spaceId } }),
db.chatGroup.count({ where: { spaceId: input.spaceId } }),
db.bot.count({ where: { spaceId: input.spaceId, ...exceptOwnMain } }),
db.chatGroup.count({ where: { spaceId: input.spaceId, ...exceptOwnMain } }),
db.computer.findMany({
where: { spaceId: input.spaceId, providerRef: { not: null } },
select: { homeKey: true, kind: true, providerRef: true },
@ -358,6 +363,25 @@ async function assertEmptySpaceDeletable(
};
}
/**
* The deleting user's own main bots and main conversations in a Space that holds nothing else
* and passes every other deletion check. A project's main agent is part of the project, so the
* caller removes these before claiming the Space; nothing is returned while other content or
* another rule would still block the deletion.
*/
export async function ownMainAgentsBlockingSpaceDeletion(
prisma: PrismaClient,
input: EmptySpaceDeleteInput,
): Promise<{ botIds: string[]; groupIds: string[] }> {
await assertEmptySpaceDeletable(prisma, input, { exceptOwnMain: true });
const own = { spaceId: input.spaceId, userId: input.userId, kind: "main" };
const [bots, groups] = await Promise.all([
prisma.bot.findMany({ where: own, select: { id: true } }),
prisma.chatGroup.findMany({ where: own, select: { id: true } }),
]);
return { botIds: bots.map((bot) => bot.id), groupIds: groups.map((group) => group.id) };
}
/** Claim an empty Space before external teardown.
*
* Content creation takes the same advisory lock and rejects a claimed Space,

View File

@ -1177,6 +1177,9 @@ describeWithDatabase("API authorization and resource isolation", () => {
const support = await rpc<Space>(app, cookie, "spaces/create", {
name: "Customer support",
});
// Every project comes with its own main agent.
const supportLeadId = support.bots.find((bot) => bot.kind === "main")?.id;
expect(supportLeadId).toBeDefined();
const storedSpaces = await handles.prisma.space.findMany({
where: { id: { in: [original.spaceId, support.id] } },
select: { id: true, organizationId: true },
@ -1229,9 +1232,11 @@ describeWithDatabase("API authorization and resource isolation", () => {
expect(await rpc<Array<{ id: string }>>(app, cookie, "bots/list")).toEqual([
expect.objectContaining({ id: originalBot.id }),
]);
expect(await rpc<Array<{ id: string }>>(app, cookie, "bots/list", {}, support.id)).toEqual([
expect.objectContaining({ id: supportBot.id }),
]);
expect(
(await rpc<Array<{ id: string }>>(app, cookie, "bots/list", {}, support.id))
.map((bot) => bot.id)
.sort(),
).toEqual([supportLeadId, supportBot.id].sort());
await expectDenied(app, cookie, "bots/get", { botId: supportBot.id });
await expectDenied(app, cookie, "bots/get", { botId: originalBot.id }, support.id);
@ -1251,7 +1256,10 @@ describeWithDatabase("API authorization and resource isolation", () => {
expect.objectContaining({
id: support.id,
name: "Customer support",
bots: [expect.objectContaining({ id: supportBot.id, notifyOnFinish: false })],
bots: expect.arrayContaining([
expect.objectContaining({ id: supportLeadId, kind: "main" }),
expect.objectContaining({ id: supportBot.id, notifyOnFinish: false }),
]),
}),
]),
);
@ -1268,9 +1276,10 @@ describeWithDatabase("API authorization and resource isolation", () => {
expect(supportNavigation.current).toEqual(
expect.objectContaining({
id: support.id,
bots: [expect.objectContaining({ id: supportBot.id })],
bots: expect.arrayContaining([expect.objectContaining({ id: supportBot.id })]),
}),
);
expect(supportNavigation.current.bots).toHaveLength(2);
const otherOrganizationNavigation = await rpc<SpaceNavigation>(
app,
cookie,
@ -1678,11 +1687,21 @@ describeWithDatabase("API authorization and resource isolation", () => {
raw(app, cookie, "spaces/remove", { spaceId: "missing-space" }),
).resolves.toMatchObject({ status: 404 });
// A new project holds only its own main agent, which goes with it.
const emptyMain = empty.groups.find((group) => group.kind === "main");
expect(emptyMain).toBeDefined();
expect(empty).toMatchObject({ hasContent: false, canDelete: true });
expect(
(await rpc<SpaceNavigation>(app, cookie, "spaces/list")).spaces.find(
(space) => space.id === empty.id,
),
).toMatchObject({ hasContent: false, canDelete: true });
const removed = await rpc<{ ok: true; activeSpaceId: string }>(app, cookie, "spaces/remove", {
spaceId: empty.id,
});
expect(removed).toEqual({ ok: true, activeSpaceId: actor.spaceId });
await expect(handles.prisma.space.findUnique({ where: { id: empty.id } })).resolves.toBeNull();
expect(await handles.prisma.bot.count({ where: { spaceId: empty.id } })).toBe(0);
const navigation = await rpc<SpaceNavigation>(app, cookie, "spaces/list");
expect(navigation.spaces.map((space) => space.id)).not.toContain(empty.id);
@ -1780,6 +1799,9 @@ describeWithDatabase("API authorization and resource isolation", () => {
const cookie = await signup(app, `space-race-${stamp}@rakazo.test`, "Space Race");
const actor = await rpc<Actor>(app, cookie, "me");
const space = await rpc<Space>(app, cookie, "spaces/create", { name: "Concurrent" });
// Claiming needs a truly empty space; spaces/remove clears the project's main agent first.
await handles.prisma.chatGroup.deleteMany({ where: { spaceId: space.id } });
await handles.prisma.bot.deleteMany({ where: { spaceId: space.id } });
await handles.prisma.computer.create({
data: {
spaceId: space.id,
@ -1812,7 +1834,7 @@ describeWithDatabase("API authorization and resource isolation", () => {
expect(await handles.prisma.bot.count({ where: { spaceId: space.id } })).toBe(0);
expect(
await handles.prisma.computer.findFirst({
where: { spaceId: space.id },
where: { spaceId: space.id, scopeKey: `space-race-scope-${stamp}` },
select: { providerRef: true },
}),
).toEqual({ providerRef: `space-race-provider-${stamp}` });
@ -1873,7 +1895,7 @@ describeWithDatabase("API authorization and resource isolation", () => {
expect(lifecycle?.deletionClaimId).toBeTruthy();
expect(
await handles.prisma.computer.findFirst({
where: { spaceId: space.id },
where: { spaceId: space.id, scopeKey: `space-teardown-scope-${stamp}` },
select: { providerRef: true },
}),
).toEqual({ providerRef: `space-teardown-provider-${stamp}` });

View File

@ -251,6 +251,58 @@ describeWithDatabase("delegated tasks (PostgreSQL)", () => {
expect(joined?.payload).toMatchObject({ addedBotIds: [writer.id] });
});
it("lets a project's own main agent delegate to the project's specialists in its conversation", async () => {
const { cookie, me, conversation: home } = await userWithMainBot("delegate-project");
const project = await rpc<{
id: string;
groups: Array<{ id: string; kind: string; members: Array<{ botId: string }> }>;
}>(app, cookie, "spaces/create", { name: "Launch" });
const main = project.groups.find((group) => group.kind === "main");
expect(main).toBeDefined();
expect(
await rpc(app, cookie, "spaces/ensureMain", { spaceId: project.id }, project.id),
).toMatchObject({ groupId: main!.id, spaceId: project.id });
const lead = main!.members[0]!.botId;
expect(lead).not.toBe(home.leadBotId);
const designer = await rpc<{ id: string }>(
app,
cookie,
"bots/create",
botInput("Designer"),
project.id,
);
const thread = await handles.prisma.thread.findUniqueOrThrow({ where: { groupId: main!.id } });
const startedAt = new Date();
await rpc(
app,
cookie,
"threads/send",
{ groupId: main!.id, text: "delegate to the bot named Designer: sketch a launch logo" },
project.id,
);
const task = await waitForTask(me.userId, "sketch a launch logo", ["done"]);
expect(task).toMatchObject({
requesterBotId: lead,
assigneeBotId: designer.id,
spaceId: project.id,
assigneeSpaceId: project.id,
threadId: thread.id,
});
// The specialist joins the project's conversation, never the home one.
expect(
await handles.prisma.chatGroupMember.findFirst({
where: { groupId: main!.id, botId: designer.id },
}),
).not.toBeNull();
expect(
await handles.prisma.chatGroupMember.findFirst({
where: { groupId: home.groupId, botId: designer.id },
}),
).toBeNull();
await waitForWakeRun(thread.id, lead, startedAt);
});
it("runs an other-space task in the specialist's own chat and reports the result back", async () => {
const { cookie, me, conversation } = await userWithMainBot("delegate-cross");
const lab = await rpc<{ id: string }>(app, cookie, "spaces/create", { name: "Lab" });

View File

@ -186,6 +186,12 @@ describeWithDatabase("onboarding discovery through the API", () => {
select: { organizationId: true, workspacePath: true },
}),
).resolves.toEqual({ organizationId: ownerOrg.organizationId, workspacePath: atlasFolder });
// Like any new project, it starts with its own main agent and main conversation.
await expect(
handles.prisma.chatGroup.count({
where: { spaceId: created, userId: owner.actor.userId, kind: "main" },
}),
).resolves.toBe(1);
await expect(
handles.prisma.spaceMember.findMany({
where: { spaceId: { in: [created, foreign.id] } },