Give every project its own main agent
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. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R8hSsKTB3W3e61wD6kvzur
This commit is contained in:
parent
d912712668
commit
df0886d9aa
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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<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;
|
||||
@ -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<T extends { spaceId: string }>(rows: T[]): Map<string, T[]> {
|
||||
const partitioned = new Map<string, T[]>();
|
||||
for (const row of rows) {
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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([
|
||||
|
||||
@ -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[] = [];
|
||||
|
||||
@ -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();
|
||||
});
|
||||
@ -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);
|
||||
|
||||
87
apps/web/e2e/project-main-agent.spec.ts
Normal file
87
apps/web/e2e/project-main-agent.spec.ts
Normal 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");
|
||||
});
|
||||
@ -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<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: "Starten", 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();
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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}
|
||||
</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`Starten`}
|
||||
@ -4112,7 +4097,9 @@ export function ShellPage() {
|
||||
aria-label={
|
||||
item.kind === "bot"
|
||||
? t`${item.chat.name}, 1:1 conversation`
|
||||
: t`${item.chat.name}, group conversation`
|
||||
: item.chat.kind === "main"
|
||||
? t`${item.chat.name}, main conversation`
|
||||
: t`${item.chat.name}, group conversation`
|
||||
}
|
||||
onClick={() => {
|
||||
setActiveNavPillar("projekte");
|
||||
@ -4124,7 +4111,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({
|
||||
@ -4574,7 +4566,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`Starten`}
|
||||
@ -4958,7 +4950,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);
|
||||
}}
|
||||
/>
|
||||
@ -7424,8 +7419,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";
|
||||
|
||||
@ -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}`;
|
||||
|
||||
@ -88,6 +88,7 @@ async function runBot(policy: BotPolicy, calls: Call[] = [], models: Record<stri
|
||||
updateMany: vi.fn(async () => ({ count: 1 })),
|
||||
},
|
||||
thread: { findUniqueOrThrow: vi.fn(async () => ({ id: run.threadId, groupId: null })) },
|
||||
space: { findUnique: vi.fn(async () => ({ name: "Home", isDefault: true })) },
|
||||
message: { findMany: vi.fn(async () => []) },
|
||||
task: { findUniqueOrThrow: vi.fn(async () => ({ id: run.taskId, prompt: "Work" })) },
|
||||
connection: { findMany: vi.fn(async () => []) },
|
||||
|
||||
@ -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" } } },
|
||||
}),
|
||||
}),
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -91,6 +91,7 @@ import {
|
||||
createSpaceForMember,
|
||||
createThreadMessageInTransaction,
|
||||
effectiveMemoryScope,
|
||||
ensureMainConversation,
|
||||
findDefaultModelCredential,
|
||||
findModelCredential,
|
||||
findSpaceModelTier,
|
||||
@ -3218,6 +3219,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) {
|
||||
@ -3667,8 +3670,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 } } },
|
||||
},
|
||||
@ -3726,6 +3730,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,
|
||||
@ -3758,7 +3772,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,
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -35,6 +35,7 @@ import {
|
||||
GroupDetailSchema,
|
||||
GroupSchema,
|
||||
IntegrationCatalogResultSchema,
|
||||
MainConversationRefSchema,
|
||||
McpServerConfigInput,
|
||||
McpServerSchema,
|
||||
MemoryDocumentSchema,
|
||||
@ -195,6 +196,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: {
|
||||
|
||||
@ -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';
|
||||
@ -402,7 +402,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)
|
||||
@ -427,7 +427,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])
|
||||
@ -473,7 +473,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)
|
||||
@ -491,7 +491,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")
|
||||
}
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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({
|
||||
where: {
|
||||
userId,
|
||||
spaceId: home.spaceId,
|
||||
spawnKey: MAIN_BOT_SPAWN_KEY,
|
||||
archivedAt: null,
|
||||
},
|
||||
select: { id: true },
|
||||
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: 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 } },
|
||||
select: { userId: true },
|
||||
});
|
||||
await createRepos(prisma).createBot(home, {
|
||||
...MAIN_BOT_DEFAULTS,
|
||||
// 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 },
|
||||
})
|
||||
: 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,
|
||||
{
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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}` });
|
||||
|
||||
@ -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" });
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user