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
360 lines
14 KiB
TypeScript
360 lines
14 KiB
TypeScript
import { expect, type Page, test } from "@playwright/test";
|
||
import {
|
||
activeBotId,
|
||
captureScreenshot,
|
||
completeOnboarding,
|
||
createNamedBot,
|
||
openMainBotThread,
|
||
realSandboxTimeout,
|
||
rpc,
|
||
signup,
|
||
} from "./helpers";
|
||
|
||
test("Team Computer gives bots a home folder plus shared space while Private stays isolated", async ({
|
||
page,
|
||
}, testInfo) => {
|
||
const stamp = Date.now();
|
||
const personalMarker = `personal-${stamp}`;
|
||
const sharedMarker = `shared-${stamp}`;
|
||
const privateMarker = `private-${stamp}`;
|
||
|
||
await signup(page, `team-computer-${stamp}@rakazo.test`, "password12", "Team Computer");
|
||
await completeOnboarding(page);
|
||
const chiefId = activeBotId(page);
|
||
|
||
await openComputerPanel(page);
|
||
await expect(page.getByText("Team Computer", { exact: true }).last()).toBeVisible();
|
||
await captureScreenshot(page, testInfo, "41-team-computer");
|
||
|
||
const writerId = await createBot(page, "Writer", "team");
|
||
await sendAndWait(
|
||
page,
|
||
writerId,
|
||
`write a file in your home called notes/result.txt that says ${personalMarker}`,
|
||
);
|
||
|
||
await expect(readFile(page, writerId, "notes/result.txt")).resolves.toContain(personalMarker);
|
||
await expect(readFileResponse(page, chiefId, "notes/result.txt")).resolves.toMatchObject({
|
||
ok: false,
|
||
});
|
||
await expect(readFile(page, chiefId, `bots/${writerId}/notes/result.txt`)).resolves.toContain(
|
||
personalMarker,
|
||
);
|
||
await captureScreenshot(page, testInfo, "42-team-bot-home-folder");
|
||
|
||
await sendAndWait(
|
||
page,
|
||
writerId,
|
||
`write a file called shared/notes/result.txt that says ${sharedMarker}`,
|
||
);
|
||
await expect(readFile(page, chiefId, "shared/notes/result.txt")).resolves.toContain(sharedMarker);
|
||
|
||
const privateId = await createBot(page, "Private Writer", "dedicated");
|
||
await openComputerPanel(page);
|
||
await expect(page.getByText(/Private Writer['’]s screen/).last()).toBeVisible();
|
||
await captureScreenshot(page, testInfo, "43-private-computer");
|
||
await expect(readFileResponse(page, privateId, "notes/result.txt")).resolves.toMatchObject({
|
||
ok: false,
|
||
});
|
||
|
||
await sendAndWait(
|
||
page,
|
||
privateId,
|
||
`write a file in your home called notes/result.txt that says ${privateMarker}`,
|
||
);
|
||
await expect(readFile(page, privateId, "notes/result.txt")).resolves.toContain(privateMarker);
|
||
await expect(readFileResponse(page, chiefId, "notes/result.txt")).resolves.toMatchObject({
|
||
ok: false,
|
||
});
|
||
|
||
await setComputerMode(page, "Private Writer", privateId, "team");
|
||
await expect(readFileResponse(page, privateId, "notes/result.txt")).resolves.toMatchObject({
|
||
ok: false,
|
||
});
|
||
await expect(readFile(page, privateId, "shared/notes/result.txt")).resolves.toContain(
|
||
sharedMarker,
|
||
);
|
||
await captureScreenshot(page, testInfo, "44-private-bot-joined-team-computer");
|
||
|
||
await setComputerMode(page, "Private Writer", privateId, "dedicated");
|
||
await expect(readFile(page, privateId, "notes/result.txt")).resolves.toContain(privateMarker);
|
||
await expect(readFile(page, writerId, "notes/result.txt")).resolves.toContain(personalMarker);
|
||
await captureScreenshot(page, testInfo, "45-private-computer-restored");
|
||
});
|
||
|
||
test("user control leaves another Team bot's screen available", async ({ page }, testInfo) => {
|
||
const stamp = Date.now();
|
||
const marker = `after-release-${stamp}`;
|
||
|
||
await signup(page, `team-control-${stamp}@rakazo.test`, "password12", "Team Control");
|
||
await completeOnboarding(page);
|
||
const chiefId = activeBotId(page);
|
||
const workerId = await createBot(page, "Worker", "team");
|
||
|
||
// The sidebar entry for the main bot opens the main conversation; its computer is in the 1:1.
|
||
await openMainBotThread(page);
|
||
await page.getByTitle("Agent computer").click();
|
||
await page.getByTestId("computer-preview").hover();
|
||
await page.getByTestId("computer-preview-open").click();
|
||
await expect(page.getByRole("button", { name: "Close computer" })).toBeVisible();
|
||
await page.getByRole("button", { name: "Close computer" }).click();
|
||
await expect(page.getByRole("button", { name: "Close computer" })).toHaveCount(0);
|
||
|
||
await openBot(page, "Worker");
|
||
const workerRunId = await sendMessage(
|
||
page,
|
||
`write a file in your home called notes/result.txt that says ${marker}`,
|
||
);
|
||
|
||
await waitForRun(page, workerId, workerRunId);
|
||
await expect(
|
||
rpc<{ controlHolder: string; controlBotId: string | null }>(page, "computer/status", {
|
||
botId: workerId,
|
||
}),
|
||
).resolves.toMatchObject({ controlHolder: "user", controlBotId: chiefId });
|
||
await expect(readFile(page, workerId, "notes/result.txt")).resolves.toContain(marker);
|
||
await captureScreenshot(page, testInfo, "46-team-computer-user-control-allows-parallel-bot");
|
||
|
||
await rpc(page, "computer/release", { botId: chiefId });
|
||
await expect(
|
||
rpc<{ controlHolder: string; controlBotId: string | null }>(page, "computer/status", {
|
||
botId: chiefId,
|
||
}),
|
||
).resolves.toMatchObject({ controlHolder: "bot", controlBotId: null });
|
||
|
||
await expect(readFileResponse(page, chiefId, "notes/result.txt")).resolves.toMatchObject({
|
||
ok: false,
|
||
});
|
||
await captureScreenshot(page, testInfo, "47-team-computer-control-released");
|
||
});
|
||
|
||
test("a failed control release keeps the computer open for retry", async ({ page }, testInfo) => {
|
||
await signup(page, `team-release-${Date.now()}@rakazo.test`, "password12", "Team Release");
|
||
await completeOnboarding(page);
|
||
await page.getByTitle("Agent computer").click();
|
||
await page.getByTestId("computer-preview").hover();
|
||
await page.getByTestId("computer-preview-open").click();
|
||
const chrome = page.getByTestId("computer-chrome");
|
||
const release = chrome.getByRole("button", { name: "Release", exact: true });
|
||
await expect(release).toBeVisible();
|
||
await page.route("**/rpc/computer/release", (route) =>
|
||
route.fulfill({ status: 500, body: "release unavailable" }),
|
||
);
|
||
await release.click();
|
||
await expect(page.getByText("Could not continue", { exact: true }).last()).toBeVisible();
|
||
await expect(page.getByRole("button", { name: "Close computer" })).toBeVisible();
|
||
await captureScreenshot(page, testInfo, "48-team-computer-release-retry");
|
||
await page.unroute("**/rpc/computer/release");
|
||
await release.click();
|
||
await expect(page.getByRole("button", { name: "Close computer" })).toBeHidden();
|
||
});
|
||
|
||
test("an active Team bot must be stopped before user takeover", async ({ page }, testInfo) => {
|
||
const stamp = Date.now();
|
||
|
||
await signup(
|
||
page,
|
||
`active-team-control-${stamp}@rakazo.test`,
|
||
"password12",
|
||
"Active Team Control",
|
||
);
|
||
await completeOnboarding(page);
|
||
const chiefId = activeBotId(page);
|
||
|
||
await sendMessage(page, "keep working until I stop you");
|
||
await expect
|
||
.poll(async () => (await threadSnapshot(page, chiefId)).run?.status ?? "idle")
|
||
.toBe("running");
|
||
await captureScreenshot(page, testInfo, "48-active-team-bot-blocks-takeover");
|
||
await expect
|
||
.poll(
|
||
async () => (await rpc<{ state: string }>(page, "computer/status", { botId: chiefId })).state,
|
||
)
|
||
.toBe("running");
|
||
await expect
|
||
.poll(
|
||
async () =>
|
||
(
|
||
await rpc<{ busyBotName: string | null }>(page, "computer/status", {
|
||
botId: chiefId,
|
||
})
|
||
).busyBotName,
|
||
)
|
||
.not.toBeNull();
|
||
|
||
const takeover = await rpcResponse(page, "computer/takeover", { botId: chiefId });
|
||
expect(takeover.ok).toBe(false);
|
||
expect(takeover.status).toBe(409);
|
||
await expect
|
||
.poll(async () => (await threadSnapshot(page, chiefId)).run?.status ?? "idle")
|
||
.toBe("running");
|
||
|
||
await page.getByTitle("Agent computer").click();
|
||
const sidePanel = page.getByTestId("side-panel");
|
||
await expect(sidePanel.getByRole("button", { name: /Take control/i })).toHaveCount(0);
|
||
await page.getByTestId("computer-preview").hover();
|
||
const openBusy = sidePanel.getByTestId("computer-preview-open");
|
||
await expect(openBusy).toBeVisible();
|
||
await expect(openBusy.getByText("Open", { exact: true })).toBeVisible();
|
||
await openBusy.click();
|
||
const chrome = page.getByTestId("computer-chrome");
|
||
await expect(page.getByRole("button", { name: "Close computer" })).toBeVisible();
|
||
// Open while the bot is busy must not grant control (takeover stays blocked).
|
||
await expect(chrome.getByText("You have control", { exact: true })).toHaveCount(0);
|
||
await expect(chrome.getByRole("button", { name: /Take control/i })).toHaveCount(0);
|
||
await captureScreenshot(page, testInfo, "48b-open-while-busy-no-control");
|
||
await page.getByRole("button", { name: "Close computer" }).click();
|
||
|
||
// Stop through the shell so the client refreshes computer status (API stop alone
|
||
// does not emit a terminal thread event).
|
||
await page.getByRole("button", { name: "Stop", exact: true }).click();
|
||
await waitForIdle(page, chiefId);
|
||
await expect
|
||
.poll(
|
||
async () =>
|
||
(
|
||
await rpc<{ busyBotName: string | null }>(page, "computer/status", {
|
||
botId: chiefId,
|
||
})
|
||
).busyBotName,
|
||
)
|
||
.toBeNull();
|
||
|
||
// After stop, Open via hover is the takeover path (no Take control button).
|
||
await page.getByTestId("computer-preview").hover();
|
||
await page.getByTestId("computer-preview-open").click();
|
||
await expect(page.getByRole("button", { name: "Close computer" })).toBeVisible();
|
||
await expect(chrome.getByText("You have control", { exact: true })).toBeVisible();
|
||
await expect(chrome.getByRole("button", { name: /Take control/i })).toHaveCount(0);
|
||
await expect(chrome.getByRole("button", { name: "Release", exact: true })).toBeVisible();
|
||
await captureScreenshot(page, testInfo, "49-team-computer-open-after-stop");
|
||
await chrome.getByRole("button", { name: "Release", exact: true }).click();
|
||
// Release closes the overlay and clears control without a DB edit.
|
||
await expect(page.getByRole("button", { name: "Close computer" })).toHaveCount(0);
|
||
});
|
||
|
||
async function createBot(page: Page, name: string, mode: "team" | "dedicated") {
|
||
return createNamedBot(page, name, { computerMode: mode });
|
||
}
|
||
|
||
async function setComputerMode(
|
||
page: Page,
|
||
botName: string,
|
||
botId: string,
|
||
mode: "team" | "dedicated",
|
||
) {
|
||
await page.getByRole("button", { name: botName, exact: true }).last().click();
|
||
const settings = page.getByTestId("bot-settings");
|
||
await expect(settings.locator("label:has-text('Name') input")).toHaveValue(botName);
|
||
const advanced = settings.getByTestId("bot-settings-advanced");
|
||
await advanced.evaluate((element) => {
|
||
(element as HTMLDetailsElement).open = true;
|
||
});
|
||
await settings
|
||
.getByRole("button", { name: mode === "team" ? "Team" : "Private", exact: true })
|
||
.click();
|
||
await settings.getByRole("button", { name: "Save", exact: true }).click();
|
||
await expect
|
||
.poll(async () => {
|
||
const bots = await rpc<Array<{ id: string; computerMode: string }>>(page, "bots/list", {});
|
||
return bots.find((bot) => bot.id === botId)?.computerMode;
|
||
})
|
||
.toBe(mode);
|
||
}
|
||
|
||
async function openBot(page: Page, name: string) {
|
||
await page
|
||
.getByRole("complementary")
|
||
.getByRole("button", { name: new RegExp(`^${name}`) })
|
||
.first()
|
||
.click();
|
||
await expect(page.getByRole("combobox", { name: "Message" })).toBeVisible();
|
||
await expectShellReady(page);
|
||
}
|
||
|
||
async function expectShellReady(page: Page) {
|
||
await expect(page.getByTestId("shell-root")).toHaveAttribute("data-ready", "true", {
|
||
timeout: 30_000,
|
||
});
|
||
}
|
||
|
||
async function expectComposerReady(page: Page) {
|
||
await expectShellReady(page);
|
||
const composer = page.getByPlaceholder(/Message/);
|
||
await expect(composer).toBeVisible();
|
||
await expect(composer).toBeEnabled();
|
||
}
|
||
|
||
async function openComputerPanel(page: Page) {
|
||
await page.getByTitle("Agent computer").click();
|
||
await expect(page.getByTestId("computer-preview")).toBeVisible();
|
||
await expect(page.getByTestId("computer-preview-open")).toHaveCount(1);
|
||
}
|
||
|
||
async function sendAndWait(page: Page, botId: string, text: string) {
|
||
const runId = await sendMessage(page, text);
|
||
await waitForRun(page, botId, runId);
|
||
}
|
||
|
||
async function sendMessage(page: Page, text: string) {
|
||
await expectComposerReady(page);
|
||
const composer = page.getByPlaceholder(/Message/);
|
||
await composer.click();
|
||
await composer.fill(text);
|
||
const sent = page.waitForResponse(
|
||
(response) =>
|
||
response.url().includes("/rpc/threads/send") && response.request().method() === "POST",
|
||
);
|
||
await page.getByRole("button", { name: "Send", exact: true }).click();
|
||
const response = await sent;
|
||
expect(response.ok()).toBe(true);
|
||
const result = (await response.json()) as { json?: { runId?: string } };
|
||
if (!result.json?.runId) throw new Error("threads/send did not return a run id");
|
||
return result.json.runId;
|
||
}
|
||
|
||
async function waitForRun(page: Page, botId: string, runId: string) {
|
||
await expect
|
||
.poll(
|
||
async () => {
|
||
const snapshot = await threadSnapshot(page, botId);
|
||
if (snapshot.run?.id === runId) return false;
|
||
return snapshot.messages.some((message) => message.runId === runId);
|
||
},
|
||
{
|
||
timeout: realSandboxTimeout(90_000, 20_000),
|
||
message: `run ${runId} must finish before its result is inspected`,
|
||
},
|
||
)
|
||
.toBe(true);
|
||
}
|
||
|
||
async function waitForIdle(page: Page, botId: string) {
|
||
await expect
|
||
.poll(async () => (await threadSnapshot(page, botId)).run?.status ?? "idle", {
|
||
timeout: realSandboxTimeout(90_000, 20_000),
|
||
})
|
||
.toBe("idle");
|
||
}
|
||
|
||
function threadSnapshot(page: Page, botId: string) {
|
||
return rpc<{
|
||
run: { id: string; status: string } | null;
|
||
messages: Array<{ runId?: string | null }>;
|
||
}>(page, "threads/get", { botId });
|
||
}
|
||
|
||
async function readFile(page: Page, botId: string, path: string) {
|
||
const result = await rpc<{ content: string }>(page, "computer/readFile", { botId, path });
|
||
return result.content;
|
||
}
|
||
|
||
async function readFileResponse(page: Page, botId: string, path: string) {
|
||
return rpcResponse(page, "computer/readFile", { botId, path });
|
||
}
|
||
|
||
async function rpcResponse(page: Page, procedure: string, body: unknown) {
|
||
const response = await page.request.post(`/rpc/${procedure}`, { data: { json: body } });
|
||
return { ok: response.ok(), status: response.status() };
|
||
}
|