Merge main into the Phase 2 integration branch

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -89,7 +89,15 @@ import {
verifyMcpInstall, verifyMcpInstall,
} from "@rakazo/adapters"; } from "@rakazo/adapters";
import type { Auth } from "@rakazo/auth"; 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 { import {
appContract, appContract,
CoachRoutineKeySchema, CoachRoutineKeySchema,
@ -134,6 +142,7 @@ import {
listSpaceUserBotIds, listSpaceUserBotIds,
newestModelCredentialOrder, newestModelCredentialOrder,
newestVoiceCredentialOrder, newestVoiceCredentialOrder,
ownMainAgentsBlockingSpaceDeletion,
Prisma, Prisma,
parseComputerMode, parseComputerMode,
projectWorkspaceDto, projectWorkspaceDto,
@ -512,6 +521,26 @@ export interface RouterDeps {
* keep the deletion claim renewed forever and block stale-claim recovery. */ * keep the deletion claim renewed forever and block stale-claim recovery. */
const SPACE_TEARDOWN_TIMEOUT_MS = 120_000; 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 { function spaceTeardownTimeoutMs(): number {
const override = Number(process.env.SPACE_TEARDOWN_TIMEOUT_MS ?? ""); const override = Number(process.env.SPACE_TEARDOWN_TIMEOUT_MS ?? "");
return Number.isFinite(override) && override > 0 ? override : SPACE_TEARDOWN_TIMEOUT_MS; return Number.isFinite(override) && override > 0 ? override : SPACE_TEARDOWN_TIMEOUT_MS;
@ -678,6 +707,13 @@ export function createRouter(deps: RouterDeps) {
const workspace = workspacePath const workspace = workspacePath
? await wireProjectWorkspace(deps.prisma, { spaceId: space.id, workspacePath }) ? await wireProjectWorkspace(deps.prisma, { spaceId: space.id, workspacePath })
: null; : 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 { return {
id: space.id, id: space.id,
name: space.name, name: space.name,
@ -685,12 +721,26 @@ export function createRouter(deps: RouterDeps) {
hasContent: false, hasContent: false,
canDelete: true, canDelete: true,
workspace, workspace,
bots: [], bots: bots.map(spaceBotDto),
groups: [], groups: groups.map(spaceGroupDto),
externalConversations: [], externalConversations: [],
botSections: [], 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 }) => { remove: authed.spaces.remove.handler(async ({ context, input }) => {
let claimId: string | null = null; let claimId: string | null = null;
let claimActive = false; let claimActive = false;
@ -703,6 +753,41 @@ export function createRouter(deps: RouterDeps) {
spaceId: input.spaceId, spaceId: input.spaceId,
}; };
try { 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 // Claim emptiness before external teardown. Bot and group creation
// take the same lifecycle lock and reject the Space until deletion // take the same lifecycle lock and reject the Space until deletion
// finishes or this claim is released. // finishes or this claim is released.
@ -1301,7 +1386,11 @@ export function createRouter(deps: RouterDeps) {
}, },
}); });
if (existing.kind === "main" && input.name !== undefined && input.name !== existing.name) { 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 bots = await repos.listBots(context.actor);
const bot = bots.find((b) => b.id === input.botId); const bot = bots.find((b) => b.id === input.botId);
@ -1589,19 +1678,7 @@ export function createRouter(deps: RouterDeps) {
input.groupId, input.groupId,
"The main conversation can't be deleted.", "The main conversation can't be deleted.",
); );
const removed = await groupRepos.removeGroup(context.actor, input.groupId); await removeGroupWithArtifacts(deps, groupRepos, 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);
}
return { ok: true as const }; return { ok: true as const };
}), }),
}, },
@ -5081,6 +5158,11 @@ async function spaceNavigationDto(
orderBy: { createdAt: "asc" }, orderBy: { createdAt: "asc" },
}); });
const spaceIds = memberships.map((membership) => membership.spaceId); 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 inactiveSpaceIds = spaceIds.filter((spaceId) => spaceId !== actor.spaceId);
const [ const [
currentBots, currentBots,
@ -5101,13 +5183,14 @@ async function spaceNavigationDto(
// Active-only navigation lists miss archived content in other spaces; count any // 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 // bot/group in the actor's spaces (including another member's) so a shared
// non-empty space cannot look empty for onboarding redirects. // 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({ deps.prisma.bot.findMany({
where: { spaceId: { in: spaceIds } }, where: { spaceId: { in: spaceIds }, NOT: ownProjectMain },
select: { spaceId: true }, select: { spaceId: true },
distinct: ["spaceId"], distinct: ["spaceId"],
}), }),
deps.prisma.chatGroup.findMany({ deps.prisma.chatGroup.findMany({
where: { spaceId: { in: spaceIds } }, where: { spaceId: { in: spaceIds }, NOT: ownProjectMain },
select: { spaceId: true }, select: { spaceId: true },
distinct: ["spaceId"], distinct: ["spaceId"],
}), }),
@ -5153,33 +5236,8 @@ async function spaceNavigationDto(
(membership.space.deletingAt === null || membership.space.deletingAt < staleClaimBefore), (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. // A linked folder is on the owner's host; other members see neither its path nor remote.
workspace: actor.isDeploymentOwner ? projectWorkspaceDto(membership.space) : null, workspace: actor.isDeploymentOwner ? projectWorkspaceDto(membership.space) : null,
bots: spaceBots.map((bot) => ({ bots: spaceBots.map(spaceBotDto),
id: bot.id, groups: spaceGroups.map(spaceGroupDto),
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,
})),
externalConversations: externalConversations.filter( externalConversations: externalConversations.filter(
(conversation) => conversation.spaceId === membership.spaceId, (conversation) => conversation.spaceId === membership.spaceId,
), ),
@ -5190,6 +5248,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[]> { function partitionBySpace<T extends { spaceId: string }>(rows: T[]): Map<string, T[]> {
const partitioned = new Map<string, T[]>(); const partitioned = new Map<string, T[]>();
for (const row of rows) { for (const row of rows) {

View File

@ -28,7 +28,11 @@ export default function NewSpace() {
return; return;
} }
router.dismissAll(); 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) { } catch (reason) {
setError(reason instanceof Error ? reason.message : t("Could not create space")); setError(reason instanceof Error ? reason.message : t("Could not create space"));
setPending(false); setPending(false);

View File

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

View File

@ -111,6 +111,29 @@ describe("spaceInboxItems", () => {
expect(items.map((item) => item.type)).toEqual(["bot", "group"]); 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", () => { it("keeps empty spaces beside a populated space", () => {
const personal = space({ id: "personal", isDefault: true, bots: [bot] }); const personal = space({ id: "personal", isDefault: true, bots: [bot] });
expect(spaceInboxItems([personal, space()]).map((item) => item.type)).toEqual([ expect(spaceInboxItems([personal, space()]).map((item) => item.type)).toEqual([

View File

@ -32,7 +32,10 @@ export function canDeleteInboxSpace(
export function spaceInboxItems(spaces: InboxSpace[]): InboxSpaceItem[] { export function spaceInboxItems(spaces: InboxSpace[]): InboxSpaceItem[] {
return spaces.flatMap((space): InboxSpaceItem[] => { return spaces.flatMap((space): InboxSpaceItem[] => {
const chats = [ 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 })), ...space.groups.map((chat) => ({ type: "group" as const, group: chat, ...chat })),
]; ];
const items: InboxSpaceItem[] = []; const items: InboxSpaceItem[] = [];

View File

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

View File

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

View File

@ -19,7 +19,10 @@ test("onboarding lands in the main conversation, its lead answers, and addressed
const homeSpaceId = await page.evaluate(() => window.localStorage.getItem("rakazo:space-id")); const homeSpaceId = await page.evaluate(() => window.localStorage.getItem("rakazo:space-id"));
const chief = await rpc<{ id: string }>(page, "bots/ensureMain", {}); const chief = await rpc<{ id: string }>(page, "bots/ensureMain", {});
const sidebar = page.locator("aside").first(); 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(); await expect(mainEntry).toBeVisible();
// The main bot is reached through the main conversation, not listed again. // The main bot is reached through the main conversation, not listed again.
await expect(sidebar.locator(`[data-roster-bot-id="${chief.id}"]`)).toHaveCount(0); 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" }); const dialog = page.getByRole("dialog", { name: "New project" });
await dialog.getByLabel("Name").fill("Launch"); await dialog.getByLabel("Name").fill("Launch");
await dialog.getByRole("button", { name: "Create project", exact: true }).click(); 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 await expect
.poll(() => page.evaluate(() => window.localStorage.getItem("rakazo:space-id"))) .poll(() => page.evaluate(() => window.localStorage.getItem("rakazo:space-id")))
.not.toBe(homeSpaceId); .not.toBe(homeSpaceId);

View File

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

View File

@ -16,7 +16,7 @@ function projectSpace(projekte: Locator, name: string) {
}); });
} }
test("Projects + opens new project dialog instead of group form", async ({ page }) => { test("Projects + opens new project dialog instead of group form", async ({ page }, testInfo) => {
const stamp = Date.now(); const stamp = Date.now();
await signup(page, `proj-create-${stamp}@rakazo.test`, "password12", "Project Create"); await signup(page, `proj-create-${stamp}@rakazo.test`, "password12", "Project Create");
await completeOnboarding(page); await completeOnboarding(page);
@ -34,16 +34,21 @@ test("Projects + opens new project dialog instead of group form", async ({ page
await dialog.getByLabel("Name").fill("Customer project"); await dialog.getByLabel("Name").fill("Customer project");
await dialog.getByRole("button", { name: "Create project", exact: true }).click(); await dialog.getByRole("button", { name: "Create project", exact: true }).click();
await expect(page).not.toHaveURL(/\/onboarding/); await expect(page).not.toHaveURL(/\/onboarding/);
await page.waitForURL(/\/app/); // The new project opens its conversation with the project's main agent.
await page.waitForLoadState("networkidle"); await page.waitForURL(/\/app\/g\/[^/]+$/, { timeout: 30_000 });
await expect(projekte.getByText("Customer project", { exact: true })).toBeVisible({ await expect(projekte.getByText("Customer project", { exact: true })).toBeVisible({
timeout: 30_000, timeout: 30_000,
}); });
await expect(unterhaltungenOnly(page).getByText("Customer project", { exact: true })).toHaveCount( await expect(unterhaltungenOnly(page).getByText("Customer project", { exact: true })).toHaveCount(
0, 0,
); );
await expect(page.getByTestId("project-start-main")).toBeVisible(); await expect(
await expect(page.getByTestId("project-start-main")).toHaveText(/Start/); 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]) { 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 supportSpaceGroup = await supportSpace.getAttribute("data-sidebar-group");
const supportSpaceId = supportSpaceGroup?.match(/^project-space:([^:]+)/)?.[1]; const supportSpaceId = supportSpaceGroup?.match(/^project-space:([^:]+)/)?.[1];
expect(supportSpaceId).toBeTruthy(); expect(supportSpaceId).toBeTruthy();
await supportSpace.getByRole("button", { name: "Select Customer support" }).click(); // A project created from chat comes with its own main agent, reached through its conversation.
await page.waitForURL(/\/app(?:\/)?$/); await supportSpace.getByRole("button", { name: "Chief, main conversation" }).click();
await page.waitForURL(/\/app\/g\/[^/]+$/);
await expect await expect
.poll(() => page.evaluate(() => window.localStorage.getItem("rakazo:space-id"))) .poll(() => page.evaluate(() => window.localStorage.getItem("rakazo:space-id")))
.toBe(supportSpaceId); .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(projekte.getByText("Customer support", { exact: true })).toBeVisible();
await expect(unterhaltungenOnly(page).getByRole("button", { name: /^Chief/ })).toHaveCount(1); 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: /^Chief/ })).toHaveCount(1);
await expect(projekte.getByRole("button", { name: "Start", exact: true })).toBeVisible(); await expect(projekte.getByRole("button", { name: "Start", exact: true })).toHaveCount(0);
await captureScreenshot(page, testInfo, "spaces-sidebar"); await captureScreenshot(page, testInfo, "spaces-sidebar");
await page.evaluate((id) => { 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(); 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(); const stamp = Date.now();
await signup(page, `spaces-delete-${stamp}@rakazo.test`, "password12", "Space Owner"); await signup(page, `spaces-delete-${stamp}@rakazo.test`, "password12", "Space Owner");
await completeOnboarding(page); 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.getByLabel("Name").fill("Temporary");
await dialog.getByRole("button", { name: "Create space", exact: true }).click(); 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. // Onboarding runs once per user: a new space opens directly in the conversation with its
await page.waitForURL(/\/app(?:\/)?$/); // own main agent, its only bot.
await page.waitForURL(/\/app\/g\/[^/]+$/, { timeout: 30_000 });
const projekte = page.locator(".sidebar-projekte"); const projekte = page.locator(".sidebar-projekte");
await expect(projekte.getByText("Temporary", { exact: true })).toBeVisible(); await expect(projekte.getByText("Temporary", { exact: true })).toBeVisible();
await expect(unterhaltungenOnly(page).getByRole("button", { name: /^Chief/ })).toHaveCount(1); await expect(unterhaltungenOnly(page).getByRole("button", { name: /^Chief/ })).toHaveCount(1);
await expect(projekte.getByRole("button", { name: /^Chief/ })).toHaveCount(0); await expect(
await expect(projekte.getByRole("button", { name: "Start", exact: true })).toBeVisible(); projectSpace(projekte, "Temporary").getByRole("button", { name: "Chief, main conversation" }),
expect(await rpc<unknown[]>(page, "bots/list", {})).toEqual([]); ).toBeVisible();
await captureScreenshot(page, testInfo, "new-space-empty"); await expect(projekte.getByRole("button", { name: "Start", exact: true })).toHaveCount(0);
expect(await rpc<Array<{ kind: string }>>(page, "bots/list", {})).toEqual([
// The empty space stays selectable instead of trapping the user. expect.objectContaining({ kind: "main" }),
await expect(projekte.getByRole("button", { name: "Select Temporary" }).first()).toBeVisible(); ]);
await captureScreenshot(page, testInfo, "new-space-main-conversation");
// Deletion is discoverable through the visible Space actions menu. // Deletion is discoverable through the visible Space actions menu.
await projectSpace(projekte, "Temporary") 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 deleteSpaceDialog.getByRole("button", { name: "Delete", exact: true }).click();
await expect(sidebar.getByText("Temporary", { exact: true })).toHaveCount(0); await expect(sidebar.getByText("Temporary", { exact: true })).toHaveCount(0);
await expect(page).not.toHaveURL(/\/onboarding/); await expect(page).not.toHaveURL(/\/onboarding/);
// The project's main agent went with it.
await expect(sidebar.getByRole("button", { name: /^Chief/ })).toHaveCount(1); await expect(sidebar.getByRole("button", { name: /^Chief/ })).toHaveCount(1);
// A stale onboarding URL recovers without duplicating the bot. // 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" }); const dialog = page.getByRole("dialog", { name: "New space" });
await dialog.getByLabel("Name").fill("Side"); await dialog.getByLabel("Name").fill("Side");
await dialog.getByRole("button", { name: "Create space", exact: true }).click(); 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"); const projekte = page.locator(".sidebar-projekte");
await expect(projekte.getByText("Side", { exact: true })).toBeVisible(); 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 expect(page).not.toHaveURL(/\/onboarding/);
await page.goto("/app"); await page.goto("/app");
await expect(page).not.toHaveURL(/\/onboarding/); await expect(page).not.toHaveURL(/\/onboarding/);
await expect(projekte.getByRole("button", { name: "Select Side" }).first()).toBeVisible(); // The project's main conversation stays, so the project opens there.
await expect(page.getByTestId("project-start-main")).toBeVisible(); 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 projectSpace(projekte, "Side").getByRole("button", { name: "Actions for Side" }).click();
await expect(page.getByRole("menuitem", { name: "Delete space" })).toBeVisible(); await expect(page.getByRole("menuitem", { name: "Delete space" })).toBeVisible();

View File

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

View File

@ -1,14 +1,15 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { isEmptyProjectSpace, projectGroupChatsInSpace } from "./project-empty-state.js"; import { isEmptyProjectSpace, projectMainGroup } from "./project-empty-state.js";
describe("projectGroupChatsInSpace", () => { describe("projectMainGroup", () => {
it("returns project groups in a space without the main conversation", () => { it("finds the main conversation of a space", () => {
const groups = [ const groups = [
{ id: "g1", kind: "group" as const, spaceId: "space-a" }, { id: "g1", kind: "group" as const, spaceId: "space-a" },
{ id: "g2", kind: "main" 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); 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); expect(isEmptyProjectSpace("project", spaces, [], "home")).toBe(true);
}); });
@ -31,8 +32,8 @@ describe("isEmptyProjectSpace", () => {
expect(isEmptyProjectSpace("project", spaces, groups, "home")).toBe(false); 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" }]; const groups = [{ id: "g1", kind: "main" as const, spaceId: "project" }];
expect(isEmptyProjectSpace("project", spaces, groups, "home")).toBe(true); expect(isEmptyProjectSpace("project", spaces, groups, "home")).toBe(false);
}); });
}); });

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -32,10 +32,22 @@ export function formatBotIdentity(bot: BotIdentity): string {
* The main bot's role. Tools are named only when this run has them, so the paragraph never * 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. * 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 [ return [
"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 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.", ? `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") toolNames.has("delegate_task")
? "Give a specialist tracked work with delegate_task; its result comes back to you." ? "Give a specialist tracked work with delegate_task; its result comes back to you."
: undefined, : undefined,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -313,8 +313,10 @@ export async function consolidateProfile(
// The scripted runtime answers with canned text keyed off the prompt; facts "learned" from it // The scripted runtime answers with canned text keyed off the prompt; facts "learned" from it
// would be nonsense. Skip without touching the cursor. // would be nonsense. Skip without touching the cursor.
if (!deps.runtime.describe().capabilities.compaction) return; 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({ const main = await deps.prisma.bot.findFirst({
where: { userId, kind: "main", archivedAt: null }, where: { userId, kind: "main", archivedAt: null },
orderBy: [{ space: { isDefault: "desc" } }, { createdAt: "asc" }],
select: { id: true, spaceId: true, thread: { select: { id: true } } }, select: { id: true, spaceId: true, thread: { select: { id: true } } },
}); });
if (!main?.thread) return; if (!main?.thread) return;

View File

@ -40,7 +40,7 @@ export const AgentSecretInputSchema = z.object({
}); });
export type AgentSecretInput = z.infer<typeof AgentSecretInputSchema>; 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 BOT_KINDS = ["main", "specialist"] as const;
export const BotKindSchema = z.enum(BOT_KINDS); export const BotKindSchema = z.enum(BOT_KINDS);
export type BotKind = z.infer<typeof BotKindSchema>; export type BotKind = z.infer<typeof BotKindSchema>;
@ -113,7 +113,7 @@ export const REPLY_QUOTE_MAX_LENGTH = 2_000;
export const GROUP_MEMBER_MIN = 2; export const GROUP_MEMBER_MIN = 2;
export const GROUP_MEMBER_MAX = 6; 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 GROUP_KINDS = ["group", "main"] as const;
export const GroupKindSchema = z.enum(GROUP_KINDS); export const GroupKindSchema = z.enum(GROUP_KINDS);
export type GroupKind = z.infer<typeof GroupKindSchema>; export type GroupKind = z.infer<typeof GroupKindSchema>;
@ -304,7 +304,7 @@ export const SpaceSchema = z.object({
}); });
export type Space = z.infer<typeof SpaceSchema>; 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({ export const MainConversationRefSchema = z.object({
botId: Id, botId: Id,
groupId: Id, groupId: Id,

View File

@ -51,6 +51,7 @@ import {
GroupDetailSchema, GroupDetailSchema,
GroupSchema, GroupSchema,
IntegrationCatalogResultSchema, IntegrationCatalogResultSchema,
MainConversationRefSchema,
McpServerConfigInput, McpServerConfigInput,
McpServerSchema, McpServerSchema,
MemoryDocumentSchema, MemoryDocumentSchema,
@ -249,6 +250,8 @@ export const appContract = {
remove: oc remove: oc
.input(z.object({ spaceId: Id })) .input(z.object({ spaceId: Id }))
.output(z.object({ ok: z.literal(true), activeSpaceId: 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), bootstrap: oc.input(z.object({ botId: Id.optional() })).output(AppBootstrapSchema),
deployment: { deployment: {

View File

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

View File

@ -410,7 +410,7 @@ model Bot {
webhookSecretId String? webhookSecretId String?
teamChatAmbientEnabled Boolean @default(false) teamChatAmbientEnabled Boolean @default(false)
teamChatRules String @default("") 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") kind String @default("specialist")
tags String[] @default([]) tags String[] @default([])
restrictBuiltinTools Boolean @default(false) restrictBuiltinTools Boolean @default(false)
@ -435,7 +435,7 @@ model Bot {
externalConversations ExternalConversation[] externalConversations ExternalConversation[]
@@unique([spaceId, spawnKey]) @@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([spaceId, userId, archivedAt, pinned, updatedAt])
@@index([sectionId]) @@index([sectionId])
@@index([computerId]) @@index([computerId])
@ -481,7 +481,7 @@ model ChatGroup {
space Space @relation(fields: [spaceId], references: [id], onDelete: Cascade) space Space @relation(fields: [spaceId], references: [id], onDelete: Cascade)
userId String userId String
name 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") kind String @default("group")
leadBotId String? leadBotId String?
leadBot Bot? @relation("ChatGroupLead", fields: [leadBotId], references: [id], onDelete: SetNull) leadBot Bot? @relation("ChatGroupLead", fields: [leadBotId], references: [id], onDelete: SetNull)
@ -499,7 +499,7 @@ model ChatGroup {
@@index([spaceId, userId, archivedAt, pinned, updatedAt]) @@index([spaceId, userId, archivedAt, pinned, updatedAt])
@@index([sectionId]) @@index([sectionId])
@@index([leadBotId]) @@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") @@map("chat_groups")
} }

View File

@ -11,6 +11,7 @@ import {
joinMainConversation, joinMainConversation,
loadUserContext, loadUserContext,
MAIN_BOT_SPAWN_KEY, MAIN_BOT_SPAWN_KEY,
PROJECT_MAIN_BOT_DEFAULTS,
} from "./main-agent.js"; } from "./main-agent.js";
import { createRepos } from "./repos.js"; import { createRepos } from "./repos.js";
import { requireMembership } from "./scope.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); 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 () => { it("creates the main bot without the spawn key when another user holds it in a shared space", async () => {
const owner = await newUser(); const owner = await newUser();
const ownerMain = await ensureMainBot(prisma, owner.userId); const ownerMain = await ensureMainBot(prisma, owner.userId);

View File

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

View File

@ -288,6 +288,8 @@ type SpaceDeleteDb = Pick<PrismaClient, "spaceMember" | "bot" | "chatGroup" | "c
async function assertEmptySpaceDeletable( async function assertEmptySpaceDeletable(
db: SpaceDeleteDb, db: SpaceDeleteDb,
input: EmptySpaceDeleteInput, input: EmptySpaceDeleteInput,
/** Leave the deleting user's own main bot and main conversation out of the emptiness check. */
options: { exceptOwnMain?: boolean } = {},
): Promise<{ ): Promise<{
organizationId: string; organizationId: string;
memberships: Array<{ memberships: Array<{
@ -338,9 +340,12 @@ async function assertEmptySpaceDeletable(
orderBy: { createdAt: "asc" }, orderBy: { createdAt: "asc" },
}); });
if (memberships.length <= 1) throw new CannotDeleteLastSpaceError(); if (memberships.length <= 1) throw new CannotDeleteLastSpaceError();
const exceptOwnMain = options.exceptOwnMain
? { NOT: { userId: input.userId, kind: "main" } }
: {};
const [botCount, groupCount, computers] = await Promise.all([ const [botCount, groupCount, computers] = await Promise.all([
db.bot.count({ where: { spaceId: input.spaceId } }), db.bot.count({ where: { spaceId: input.spaceId, ...exceptOwnMain } }),
db.chatGroup.count({ where: { spaceId: input.spaceId } }), db.chatGroup.count({ where: { spaceId: input.spaceId, ...exceptOwnMain } }),
db.computer.findMany({ db.computer.findMany({
where: { spaceId: input.spaceId, providerRef: { not: null } }, where: { spaceId: input.spaceId, providerRef: { not: null } },
select: { homeKey: true, kind: true, providerRef: true }, 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. /** Claim an empty Space before external teardown.
* *
* Content creation takes the same advisory lock and rejects a claimed Space, * Content creation takes the same advisory lock and rejects a claimed Space,

View File

@ -1177,6 +1177,9 @@ describeWithDatabase("API authorization and resource isolation", () => {
const support = await rpc<Space>(app, cookie, "spaces/create", { const support = await rpc<Space>(app, cookie, "spaces/create", {
name: "Customer support", 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({ const storedSpaces = await handles.prisma.space.findMany({
where: { id: { in: [original.spaceId, support.id] } }, where: { id: { in: [original.spaceId, support.id] } },
select: { id: true, organizationId: true }, select: { id: true, organizationId: true },
@ -1229,9 +1232,11 @@ describeWithDatabase("API authorization and resource isolation", () => {
expect(await rpc<Array<{ id: string }>>(app, cookie, "bots/list")).toEqual([ expect(await rpc<Array<{ id: string }>>(app, cookie, "bots/list")).toEqual([
expect.objectContaining({ id: originalBot.id }), expect.objectContaining({ id: originalBot.id }),
]); ]);
expect(await rpc<Array<{ id: string }>>(app, cookie, "bots/list", {}, support.id)).toEqual([ expect(
expect.objectContaining({ id: supportBot.id }), (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: supportBot.id });
await expectDenied(app, cookie, "bots/get", { botId: originalBot.id }, support.id); await expectDenied(app, cookie, "bots/get", { botId: originalBot.id }, support.id);
@ -1251,7 +1256,10 @@ describeWithDatabase("API authorization and resource isolation", () => {
expect.objectContaining({ expect.objectContaining({
id: support.id, id: support.id,
name: "Customer support", name: "Customer support",
bots: [expect.objectContaining({ id: supportBot.id, notifyOnFinish: false })], bots: expect.arrayContaining([
expect.objectContaining({ id: supportLeadId, kind: "main" }),
expect.objectContaining({ id: supportBot.id, notifyOnFinish: false }),
]),
}), }),
]), ]),
); );
@ -1268,9 +1276,10 @@ describeWithDatabase("API authorization and resource isolation", () => {
expect(supportNavigation.current).toEqual( expect(supportNavigation.current).toEqual(
expect.objectContaining({ expect.objectContaining({
id: support.id, 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>( const otherOrganizationNavigation = await rpc<SpaceNavigation>(
app, app,
cookie, cookie,
@ -1678,11 +1687,21 @@ describeWithDatabase("API authorization and resource isolation", () => {
raw(app, cookie, "spaces/remove", { spaceId: "missing-space" }), raw(app, cookie, "spaces/remove", { spaceId: "missing-space" }),
).resolves.toMatchObject({ status: 404 }); ).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", { const removed = await rpc<{ ok: true; activeSpaceId: string }>(app, cookie, "spaces/remove", {
spaceId: empty.id, spaceId: empty.id,
}); });
expect(removed).toEqual({ ok: true, activeSpaceId: actor.spaceId }); expect(removed).toEqual({ ok: true, activeSpaceId: actor.spaceId });
await expect(handles.prisma.space.findUnique({ where: { id: empty.id } })).resolves.toBeNull(); 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"); const navigation = await rpc<SpaceNavigation>(app, cookie, "spaces/list");
expect(navigation.spaces.map((space) => space.id)).not.toContain(empty.id); expect(navigation.spaces.map((space) => space.id)).not.toContain(empty.id);
@ -1780,6 +1799,9 @@ describeWithDatabase("API authorization and resource isolation", () => {
const cookie = await signup(app, `space-race-${stamp}@rakazo.test`, "Space Race"); const cookie = await signup(app, `space-race-${stamp}@rakazo.test`, "Space Race");
const actor = await rpc<Actor>(app, cookie, "me"); const actor = await rpc<Actor>(app, cookie, "me");
const space = await rpc<Space>(app, cookie, "spaces/create", { name: "Concurrent" }); 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({ await handles.prisma.computer.create({
data: { data: {
spaceId: space.id, spaceId: space.id,
@ -1812,7 +1834,7 @@ describeWithDatabase("API authorization and resource isolation", () => {
expect(await handles.prisma.bot.count({ where: { spaceId: space.id } })).toBe(0); expect(await handles.prisma.bot.count({ where: { spaceId: space.id } })).toBe(0);
expect( expect(
await handles.prisma.computer.findFirst({ await handles.prisma.computer.findFirst({
where: { spaceId: space.id }, where: { spaceId: space.id, scopeKey: `space-race-scope-${stamp}` },
select: { providerRef: true }, select: { providerRef: true },
}), }),
).toEqual({ providerRef: `space-race-provider-${stamp}` }); ).toEqual({ providerRef: `space-race-provider-${stamp}` });
@ -1873,7 +1895,7 @@ describeWithDatabase("API authorization and resource isolation", () => {
expect(lifecycle?.deletionClaimId).toBeTruthy(); expect(lifecycle?.deletionClaimId).toBeTruthy();
expect( expect(
await handles.prisma.computer.findFirst({ await handles.prisma.computer.findFirst({
where: { spaceId: space.id }, where: { spaceId: space.id, scopeKey: `space-teardown-scope-${stamp}` },
select: { providerRef: true }, select: { providerRef: true },
}), }),
).toEqual({ providerRef: `space-teardown-provider-${stamp}` }); ).toEqual({ providerRef: `space-teardown-provider-${stamp}` });

View File

@ -251,6 +251,58 @@ describeWithDatabase("delegated tasks (PostgreSQL)", () => {
expect(joined?.payload).toMatchObject({ addedBotIds: [writer.id] }); 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 () => { 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 { cookie, me, conversation } = await userWithMainBot("delegate-cross");
const lab = await rpc<{ id: string }>(app, cookie, "spaces/create", { name: "Lab" }); const lab = await rpc<{ id: string }>(app, cookie, "spaces/create", { name: "Lab" });

View File

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