From 8449dec6c21a36c2062532be3baa859adac91bcc Mon Sep 17 00:00:00 2001 From: cynarAI <155274210+cynarAI@users.noreply.github.com> Date: Sun, 4 Oct 2026 00:38:39 +0200 Subject: [PATCH] feat: give every project its own main agent (#125) Each project (space) now has a main agent and a main conversation of its own. The user talks to it, and it delegates to the project's specialists. Creating a project sets both up and opens the conversation, and deleting an otherwise empty project removes them with it. Claude-Session: https://claude.ai/code/session_01R8hSsKTB3W3e61wD6kvzur Co-authored-by: Claude --- apps/api/src/main-conversation.ts | 21 +- apps/api/src/onboarding-scan.test.ts | 5 + apps/api/src/onboarding-scan.ts | 2 + apps/api/src/onboarding.ts | 2 +- apps/api/src/router.ts | 183 +++++++++++++----- apps/mobile/app/new-space.tsx | 6 +- apps/mobile/lib/api.ts | 2 +- apps/mobile/lib/inbox-spaces.test.ts | 23 +++ apps/mobile/lib/inbox-spaces.ts | 5 +- apps/web/e2e/empty-project-starten.spec.ts | 98 ---------- apps/web/e2e/main-conversation.spec.ts | 8 +- apps/web/e2e/project-main-agent.spec.ts | 87 +++++++++ apps/web/e2e/spaces.spec.ts | 60 +++--- apps/web/src/lib/project-empty-state.test.ts | 17 +- apps/web/src/lib/project-empty-state.ts | 14 +- apps/web/src/pages/Shell.tsx | 97 +++++----- packages/adapters/src/delegated-tasks.ts | 21 +- .../src/executor-capabilities.test.ts | 1 + .../src/executor-instructions.test.ts | 19 ++ packages/adapters/src/executor-prompt.ts | 18 +- packages/adapters/src/executor.ts | 20 +- .../adapters/src/profile-consolidation.ts | 2 + packages/contracts/src/domain.ts | 6 +- packages/contracts/src/rpc.ts | 3 + .../migration.sql | 7 + packages/db/prisma/schema.prisma | 8 +- packages/db/src/main-agent.postgres.test.ts | 32 +++ packages/db/src/main-agent.ts | 123 ++++++++---- packages/db/src/spaces.ts | 28 ++- packages/testkit/src/authorization.test.ts | 36 +++- .../testkit/src/delegation.postgres.test.ts | 52 +++++ 31 files changed, 693 insertions(+), 313 deletions(-) delete mode 100644 apps/web/e2e/empty-project-starten.spec.ts create mode 100644 apps/web/e2e/project-main-agent.spec.ts create mode 100644 packages/db/prisma/migrations/20261003210000_main_agent_per_space/migration.sql diff --git a/apps/api/src/main-conversation.ts b/apps/api/src/main-conversation.ts index b3ec5da..18d7b1f 100644 --- a/apps/api/src/main-conversation.ts +++ b/apps/api/src/main-conversation.ts @@ -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, + }); } diff --git a/apps/api/src/onboarding-scan.test.ts b/apps/api/src/onboarding-scan.test.ts index c919310..549a45b 100644 --- a/apps/api/src/onboarding-scan.test.ts +++ b/apps/api/src/onboarding-scan.test.ts @@ -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()), 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); diff --git a/apps/api/src/onboarding-scan.ts b/apps/api/src/onboarding-scan.ts index bad1aa8..b804fe7 100644 --- a/apps/api/src/onboarding-scan.ts +++ b/apps/api/src/onboarding-scan.ts @@ -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 }); } diff --git a/apps/api/src/onboarding.ts b/apps/api/src/onboarding.ts index b0bf565..71a4759 100644 --- a/apps/api/src/onboarding.ts +++ b/apps/api/src/onboarding.ts @@ -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 }; diff --git a/apps/api/src/router.ts b/apps/api/src/router.ts index 0f515aa..75a3882 100644 --- a/apps/api/src/router.ts +++ b/apps/api/src/router.ts @@ -88,7 +88,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, @@ -131,6 +139,7 @@ import { listSpaceUserBotIds, newestModelCredentialOrder, newestVoiceCredentialOrder, + ownMainAgentsBlockingSpaceDeletion, Prisma, parseComputerMode, projectWorkspaceDto, @@ -491,6 +500,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, + 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; @@ -602,6 +631,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, @@ -609,12 +645,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; @@ -627,6 +677,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. @@ -1225,7 +1310,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); @@ -1513,19 +1602,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 }; }), }, @@ -4942,6 +5019,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, @@ -4962,13 +5044,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"], }), @@ -5014,33 +5097,8 @@ 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) => ({ - id: bot.id, - spaceId: bot.spaceId, - kind: bot.kind, - name: bot.name, - title: bot.title, - color: bot.color, - notifyOnFinish: bot.notifyOnFinish, - pinned: bot.pinned, - sectionId: bot.sectionId, - unread: bot.unread, - preview: bot.preview, - status: bot.status, - updatedAt: bot.updatedAt, - })), - groups: spaceGroups.map((group) => ({ - id: group.id, - spaceId: group.spaceId, - kind: group.kind, - name: group.name, - pinned: group.pinned, - sectionId: group.sectionId, - members: group.members, - preview: group.preview, - unread: group.unread, - updatedAt: group.updatedAt, - })), + bots: spaceBots.map(spaceBotDto), + groups: spaceGroups.map(spaceGroupDto), externalConversations: externalConversations.filter( (conversation) => conversation.spaceId === membership.spaceId, ), @@ -5051,6 +5109,39 @@ async function spaceNavigationDto( }; } +function spaceBotDto(bot: SpaceBot): SpaceBot { + return { + id: bot.id, + spaceId: bot.spaceId, + kind: bot.kind, + name: bot.name, + title: bot.title, + color: bot.color, + notifyOnFinish: bot.notifyOnFinish, + pinned: bot.pinned, + sectionId: bot.sectionId, + unread: bot.unread, + preview: bot.preview, + status: bot.status, + updatedAt: bot.updatedAt, + }; +} + +function spaceGroupDto(group: SpaceGroup): SpaceGroup { + return { + id: group.id, + spaceId: group.spaceId, + kind: group.kind, + name: group.name, + pinned: group.pinned, + sectionId: group.sectionId, + members: group.members, + preview: group.preview, + unread: group.unread, + updatedAt: group.updatedAt, + }; +} + function partitionBySpace(rows: T[]): Map { const partitioned = new Map(); for (const row of rows) { diff --git a/apps/mobile/app/new-space.tsx b/apps/mobile/app/new-space.tsx index 1cd0e51..30efadb 100644 --- a/apps/mobile/app/new-space.tsx +++ b/apps/mobile/app/new-space.tsx @@ -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); diff --git a/apps/mobile/lib/api.ts b/apps/mobile/lib/api.ts index 183ddf7..e34b7e4 100644 --- a/apps/mobile/lib/api.ts +++ b/apps/mobile/lib/api.ts @@ -728,7 +728,7 @@ export type MobileBot = Pick< | "modelId" | "thinkingLevel" > & - Partial>; + Partial>; export type MobileBotSection = BotSection; diff --git a/apps/mobile/lib/inbox-spaces.test.ts b/apps/mobile/lib/inbox-spaces.test.ts index 492930c..42e9472 100644 --- a/apps/mobile/lib/inbox-spaces.test.ts +++ b/apps/mobile/lib/inbox-spaces.test.ts @@ -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([ diff --git a/apps/mobile/lib/inbox-spaces.ts b/apps/mobile/lib/inbox-spaces.ts index 2618c3a..3ee0bc3 100644 --- a/apps/mobile/lib/inbox-spaces.ts +++ b/apps/mobile/lib/inbox-spaces.ts @@ -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[] = []; diff --git a/apps/web/e2e/empty-project-starten.spec.ts b/apps/web/e2e/empty-project-starten.spec.ts deleted file mode 100644 index dcf80dc..0000000 --- a/apps/web/e2e/empty-project-starten.spec.ts +++ /dev/null @@ -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 Starten 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: "Starten", 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("Starten 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 Starten 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: "Starten", exact: true })).toBeVisible(); - await expect(page.getByTestId("project-start-main")).toBeVisible(); -}); diff --git a/apps/web/e2e/main-conversation.spec.ts b/apps/web/e2e/main-conversation.spec.ts index f87dcf1..1f68767 100644 --- a/apps/web/e2e/main-conversation.spec.ts +++ b/apps/web/e2e/main-conversation.spec.ts @@ -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); diff --git a/apps/web/e2e/project-main-agent.spec.ts b/apps/web/e2e/project-main-agent.spec.ts new file mode 100644 index 0000000..52488a2 --- /dev/null +++ b/apps/web/e2e/project-main-agent.spec.ts @@ -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"); +}); diff --git a/apps/web/e2e/spaces.spec.ts b/apps/web/e2e/spaces.spec.ts index 7492809..aa0da90 100644 --- a/apps/web/e2e/spaces.spec.ts +++ b/apps/web/e2e/spaces.spec.ts @@ -16,7 +16,7 @@ function projectSpace(projekte: Locator, name: string) { }); } -test("Projekte + opens new project dialog instead of group form", async ({ page }) => { +test("Projekte + 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("Projekte + 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(/Starten/); + 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: "Starten", exact: true })).toBeVisible(); + await expect(projekte.getByRole("button", { name: /^Chief/ })).toHaveCount(1); + await expect(projekte.getByRole("button", { name: "Starten", 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: "Starten", exact: true })).toBeVisible(); - expect(await rpc(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: "Starten", exact: true })).toHaveCount(0); + expect(await rpc>(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(); diff --git a/apps/web/src/lib/project-empty-state.test.ts b/apps/web/src/lib/project-empty-state.test.ts index a31562d..a7fcb4e 100644 --- a/apps/web/src/lib/project-empty-state.test.ts +++ b/apps/web/src/lib/project-empty-state.test.ts @@ -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); }); }); diff --git a/apps/web/src/lib/project-empty-state.ts b/apps/web/src/lib/project-empty-state.ts index a8afad7..574e6eb 100644 --- a/apps/web/src/lib/project-empty-state.ts +++ b/apps/web/src/lib/project-empty-state.ts @@ -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[], +/** The project's own main conversation, where the user talks to its main agent. */ +export function projectMainGroup>( + groups: readonly T[], spaceId: string, -): readonly Pick[] { - 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[], @@ -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); } diff --git a/apps/web/src/pages/Shell.tsx b/apps/web/src/pages/Shell.tsx index 5437789..753362a 100644 --- a/apps/web/src/pages/Shell.tsx +++ b/apps/web/src/pages/Shell.tsx @@ -1034,14 +1034,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; @@ -1278,12 +1279,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 }); @@ -1660,6 +1666,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, @@ -1676,6 +1684,7 @@ export function ShellPage() { sectionId: null, showLock: true, emptySpaceId: space.id, + startSpaceId: undefined, spaceId: space.id, spaceName: space.name, canDeleteSpace: space.canDelete === true, @@ -1739,11 +1748,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`, @@ -1752,6 +1763,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, @@ -1836,12 +1848,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(() => { @@ -2085,36 +2100,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 @@ -4007,7 +3992,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); @@ -4090,14 +4075,14 @@ export function ShellPage() { ) : null} ) : null} - {!collapsed && group.emptySpaceId ? ( + {!collapsed && group.startSpaceId ? (