Merge main into the media integration branch

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:25:48 +00:00
commit 681dec2f3f
No known key found for this signature in database
36 changed files with 798 additions and 334 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

@ -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

@ -7,9 +7,11 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { getScanStatus, startOnboardingScan } from "./onboarding-scan.js";
const createSpaceForMember = vi.hoisted(() => vi.fn());
const ensureMainConversation = vi.hoisted(() => vi.fn());
vi.mock("@rakazo/db", async (original) => ({
...(await original<typeof db>()),
createSpaceForMember,
ensureMainConversation,
}));
const owner = { userId: "user-owner", spaceId: "space-owner", isDeploymentOwner: true };
@ -72,6 +74,7 @@ describe("onboarding scan", () => {
afterEach(() => {
vi.unstubAllEnvs();
createSpaceForMember.mockReset();
ensureMainConversation.mockReset();
});
it("hides a scan from every account except the one that started it", async () => {
@ -127,6 +130,8 @@ describe("onboarding scan", () => {
name: "app",
});
expect(spaces).toEqual([{ id: "space-1", name: "app", workspacePath: app }]);
// Each new project starts with its own main agent.
expect(ensureMainConversation).toHaveBeenCalledWith(prisma, owner.userId, "space-1");
// Scanning again finds the linked project instead of creating a second one.
const again = await startOnboardingScan({ actor: owner, scanHome }, prisma);

View File

@ -7,6 +7,7 @@ import type { Actor } from "@rakazo/contracts";
import type { PrismaClient } from "@rakazo/db";
import {
createSpaceForMember,
ensureMainConversation,
InvalidSpaceNameError,
resolveProjectWorkspaceDirectory,
SpaceLimitError,
@ -676,6 +677,7 @@ async function createProjects(
spaceId: space.id,
workspacePath: folder,
});
await ensureMainConversation(prisma, actor.userId, space.id);
created.push({ spaceId: space.id, name: space.name, path: workspace.path });
}

View File

@ -79,7 +79,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 };

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,
IntegrationProviderIdSchema,
@ -132,6 +140,7 @@ import {
listSpaceUserBotIds,
newestModelCredentialOrder,
newestVoiceCredentialOrder,
ownMainAgentsBlockingSpaceDeletion,
Prisma,
parseComputerMode,
projectWorkspaceDto,
@ -502,6 +511,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;
@ -613,6 +642,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,
@ -620,12 +656,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;
@ -638,6 +688,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.
@ -1236,7 +1321,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);
@ -1524,19 +1613,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 };
}),
},
@ -4984,6 +5061,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,
@ -5004,13 +5086,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"],
}),
@ -5056,7 +5139,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,
@ -5070,8 +5166,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,
@ -5082,14 +5181,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({
@ -4568,7 +4560,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`}
@ -4945,7 +4937,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);
}}
/>
@ -7264,8 +7259,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

@ -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

@ -93,6 +93,7 @@ async function runBot(
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

@ -28,6 +28,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. */
@ -91,6 +93,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 ?? [] })),
@ -338,6 +343,19 @@ describe("run instructions", () => {
expect(request.instructions).not.toContain("save lasting facts they share with remember");
});
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 () => {
const { request, peersFindMany } = await runOnce({
bot: { kind: "main", name: "Chief", title: "Chief of Staff", instructions: "" },
@ -366,6 +384,7 @@ describe("run instructions", () => {
expect.objectContaining({
where: expect.objectContaining({
userId: "user-1",
kind: "specialist",
space: { memberships: { some: { userId: "user-1" } } },
}),
}),

View File

@ -29,10 +29,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

@ -91,6 +91,7 @@ import {
createSpaceForMember,
createThreadMessageInTransaction,
effectiveMemoryScope,
ensureMainConversation,
findDefaultModelCredential,
findModelCredential,
findSpaceModelTier,
@ -3248,6 +3249,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) {
@ -3699,8 +3702,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 } } },
},
@ -3758,6 +3762,16 @@ export function createRunExecutor(deps: ExecutorDeps) {
)
: undefined;
const timeInstruction = formatCurrentTimeInstruction(userContext.timeZone);
// A main bot outside the home space leads that project.
const mainAgentSpace =
bot.kind === "main"
? await deps.prisma.space.findUnique({
where: { id: run.spaceId },
select: { name: true, isDefault: true },
})
: null;
const mainAgentProject =
mainAgentSpace && !mainAgentSpace.isDefault ? mainAgentSpace.name : undefined;
// Replies to another owner's agent, like a shared channel, only get the basics.
const userInstruction = formatUserContext(userContext, {
basicsOnly: messagingChannelRun || foreignAgentRun,
@ -3790,7 +3804,9 @@ 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,
groupContext?.instructions,

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

@ -39,7 +39,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>;
@ -112,7 +112,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>;
@ -303,7 +303,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

@ -35,6 +35,7 @@ import {
GroupDetailSchema,
GroupSchema,
IntegrationCatalogResultSchema,
MainConversationRefSchema,
McpServerConfigInput,
McpServerSchema,
MemoryDocumentSchema,
@ -202,6 +203,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

@ -403,7 +403,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)
@ -428,7 +428,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])
@ -476,7 +476,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)
@ -494,7 +494,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;
@ -56,51 +63,76 @@ 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.
* 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): Promise<MainBotRef> {
let main = await findMainBot(prisma, userId);
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",
});
}
@ -108,7 +140,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) {
@ -118,31 +150,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) => {
@ -165,7 +202,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;
@ -190,17 +227,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 },
@ -232,12 +269,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

@ -1149,6 +1149,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 },
@ -1201,9 +1204,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);
@ -1223,7 +1228,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 }),
]),
}),
]),
);
@ -1240,9 +1248,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,
@ -1650,11 +1659,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);
@ -1752,6 +1771,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,
@ -1784,7 +1806,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}` });
@ -1845,7 +1867,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" });