After onboarding the user ended up in a 1:1 chat with "Chief", and the
"Hauptthread" was a group the web client created and found by name. The
main bot had no conversation it actually led, and addressing another bot
there did nothing unless the user had added it as a member first.
Now the main conversation is a real place:
- bots.ensureMain creates or returns the main bot. Bootstrap and space
navigation return `main` ({ botId, groupId, spaceId }) once the main
bot exists; reading navigation never creates a bot.
- The main bot leads the main conversation and answers when nobody is
addressed: message routing, follow-ups, reactions and clearing the
thread fall back to the group's lead instead of its first member.
- Addressing a same-space bot that is not a member yet, by @Name or a
mention chip, makes it join before the message is routed, so it
answers in the conversation. Specialists from other spaces do not join.
- The main bot cannot be archived or deleted, the main conversation
cannot be archived or deleted or have its members edited, and child
bots never dissolve it. Renaming the main bot renames the conversation.
- Web: onboarding uses bots.ensureMain and finishes in the main
conversation. The main conversation is the first sidebar entry in every
space, shown with the main bot's avatar and name; from another space it
switches back to the home space. The main bot is not listed again among
the bots, its 1:1 thread stays reachable by URL. The client no longer
creates or detects the "Hauptthread" by name, refetches a group when it
changes, and shows the speaker of any of the user's bots in a group.
- Onboarding no longer resets the step when its bootstrap resolves after
the user has already moved on, and new spaces open in the app instead
of walking onboarding again.
Tests: group mention routing with a default responder, lead follow-ups,
child-bot guards, a journey for the main conversation (lead answers,
addressed bot joins, guards, rename), authorization for bots.ensureMain,
and an E2E spec that lands in the main conversation, gets an answer from
the lead, sees an addressed bot join, and reaches the conversation from
another space. Existing E2E specs start in the main bot's thread through
a helper or use the main conversation where that is what they test.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
95 lines
4.4 KiB
TypeScript
95 lines
4.4 KiB
TypeScript
import { expect, test } from "@playwright/test";
|
|
import { captureScreenshot, completeOnboarding, rpc, signup, waitForChatComposer } from "./helpers";
|
|
|
|
type Snapshot = {
|
|
leadBotId?: string;
|
|
members?: Array<{ botId: string }>;
|
|
messages: Array<{ role: string; botId?: string }>;
|
|
};
|
|
|
|
test("onboarding lands in the main conversation, its lead answers, and addressed bots join", async ({
|
|
page,
|
|
}, testInfo) => {
|
|
const stamp = Date.now();
|
|
await signup(page, `main-conversation-${stamp}@rakazo.test`, "password12", "Main Conversation");
|
|
await completeOnboarding(page, testInfo, { mainConversation: true });
|
|
|
|
const mainUrl = page.url();
|
|
const groupId = new URL(mainUrl).pathname.split("/").at(-1)!;
|
|
const homeSpaceId = await page.evaluate(() => window.localStorage.getItem("rakazo:space-id"));
|
|
const chief = await rpc<{ id: string }>(page, "bots/ensureMain", {});
|
|
const sidebar = page.locator("aside").first();
|
|
const mainEntry = sidebar.getByRole("button", { name: "Chief, main conversation" });
|
|
await expect(mainEntry).toBeVisible();
|
|
// The main bot is reached through the main conversation, not listed again.
|
|
await expect(sidebar.locator(`[data-roster-bot-id="${chief.id}"]`)).toHaveCount(0);
|
|
await expect(page.getByTestId("oo-group-stage")).toBeVisible();
|
|
await captureScreenshot(page, testInfo, "main-conversation-landing");
|
|
|
|
const snapshot = () => rpc<Snapshot>(page, "threads/get", { groupId });
|
|
const botMessages = async (botId: string) =>
|
|
(await snapshot()).messages.filter(
|
|
(message) => message.role === "bot" && message.botId === botId,
|
|
).length;
|
|
expect((await snapshot()).leadBotId).toBe(chief.id);
|
|
|
|
// Nobody addressed: the main bot answers.
|
|
const chiefBefore = await botMessages(chief.id);
|
|
const composer = page.getByRole("combobox", { name: "Message" });
|
|
await composer.fill("What should we look at first?");
|
|
await composer.press("Enter");
|
|
await expect.poll(() => botMessages(chief.id), { timeout: 60_000 }).toBeGreaterThan(chiefBefore);
|
|
const transcript = page.getByTestId("transcript");
|
|
await expect(transcript).toContainText(/on it|handled/i);
|
|
|
|
// A same-space bot that is not a member yet joins when addressed and answers here.
|
|
const researcher = await rpc<{ id: string }>(page, "bots/create", {
|
|
name: "Researcher",
|
|
title: "",
|
|
description: "",
|
|
notifyOnFinish: true,
|
|
computerMode: "team",
|
|
});
|
|
await page.reload({ waitUntil: "domcontentloaded" });
|
|
await waitForChatComposer(page);
|
|
await expect(sidebar.locator(`[data-roster-bot-id="${researcher.id}"]`)).toBeVisible();
|
|
await composer.fill("@Res");
|
|
await page.getByRole("option", { name: "@Researcher", exact: true }).click();
|
|
await expect(page.getByTestId("mention-chip").filter({ hasText: "Researcher" })).toBeVisible();
|
|
await composer.fill(`${await composer.inputValue()}find three sources`);
|
|
await composer.press("Enter");
|
|
await expect
|
|
.poll(async () => (await snapshot()).members?.map((member) => member.botId).sort(), {
|
|
timeout: 30_000,
|
|
})
|
|
.toEqual([chief.id, researcher.id].sort());
|
|
await expect.poll(() => botMessages(researcher.id), { timeout: 60_000 }).toBeGreaterThan(0);
|
|
const researcherReply = transcript
|
|
.locator("[data-message-id]")
|
|
.filter({ hasText: /on it|handled/i })
|
|
.filter({ has: page.getByText("Researcher", { exact: true }) })
|
|
.first();
|
|
await expect(researcherReply).toBeVisible();
|
|
await captureScreenshot(page, testInfo, "main-conversation-specialist-joined");
|
|
|
|
// From another space the entry is still first and leads back to the home space.
|
|
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
|
|
.poll(() => page.evaluate(() => window.localStorage.getItem("rakazo:space-id")))
|
|
.not.toBe(homeSpaceId);
|
|
await expect(mainEntry).toBeVisible({ timeout: 30_000 });
|
|
await captureScreenshot(page, testInfo, "main-conversation-from-another-space");
|
|
await mainEntry.click();
|
|
await page.waitForURL(mainUrl);
|
|
await expect
|
|
.poll(() => page.evaluate(() => window.localStorage.getItem("rakazo:space-id")))
|
|
.toBe(homeSpaceId);
|
|
await waitForChatComposer(page);
|
|
await expect(researcherReply).toBeVisible();
|
|
});
|