Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions apps/web/src/instant-agent-create.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,4 +205,53 @@ describe("createWorkbenchFromTemplate (CL-6387)", () => {
// owner reported it from.
expect(queryClient.getQueryState(staleQueryKey)?.isInvalidated).toBe(true);
});

// CL-6979: a blank / new-room create must mint a chat with no setup
// agent as host — Myra is not auto-invited and her definitionId is
// not sent on the create body. Routing is mention/reply/explicit host;
// new rooms stay empty until someone invites.
test("new room create does not auto-invite Myra (CL-6979)", async () => {
const navigated: string[] = [];
const myraDefinitionId = "def-assistant";
const calls = stubFetch((path) => {
if (path.includes("/workflows/definitions")) {
return json({ data: [assistantDefinitionWire], nextCursor: null });
}
if (path.endsWith("/chat/workbenches")) {
return json({
id: "chan-blank",
title: NEW_WORKBENCH_TITLE,
kind: "chat",
pinned: false,
participants: [],
});
}
if (path.endsWith("/chat/workbenches/chan-blank/invite")) {
return json({ address: "agent:myra", definitionId: myraDefinitionId });
}
throw new Error(`unexpected fetch: ${path}`);
});

await createWorkbenchFromTemplate(
"tnt_1",
"blank",
(to) => navigated.push(to),
newQueryClient(),
);

const createCall = calls.find((call) =>
call.path.endsWith("/chat/workbenches"),
);
expect(createCall).toBeDefined();
const body = JSON.parse(String(createCall?.init?.body));
expect(body.definitionId).toBeUndefined();
expect(body.kind).toBe("workbench");
expect(body.name).toBe(NEW_WORKBENCH_TITLE);

const inviteCalls = calls.filter((call) =>
call.path.endsWith("/chat/workbenches/chan-blank/invite"),
);
expect(inviteCalls).toHaveLength(0);
expect(navigated).toEqual(["/w/chan-blank"]);
});
});
92 changes: 52 additions & 40 deletions apps/web/src/instant-agent-create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,15 @@
// palette's "New workbench", and the zero-workbench land-hop on `/`
// (CL-6486, superseding CL-6138's silent auto-mint) — opens the template
// picker (`pages/new-workbench-picker.tsx`, CL-6342) and calls
// `createWorkbenchFromTemplate` below once a row is chosen. It mints a
// fresh workbench against the account's default setup template (the same
// seeded `assistant` definition backing the home Myra workbench, which
// already opens with the setup greeting: "what do you want me around
// for?"). The conversation itself is what specializes the agent into
// whatever the person wants; the drafting and capability machinery already
// listens for that in-chat, so no definition is drafted or created up
// front here. Explicitly defining a brand-new agent template, with its own
// name/purpose/model/skills chosen up front, stays `CreateAgentPanel`'s job
// (Settings → Agents), unchanged.
// `createWorkbenchFromTemplate` below once a row is chosen. A blank
// ("Just start talking") pick mints a plain `kind: "workbench"` room with
// no setup agent as host (CL-6979): Myra is not auto-invited, and her
// definitionId is not sent. The home Myra conversation stays
// `ensureMyraWorkbench`. Named templates still mint against the seeded
// `assistant` definition so the roster the greeting promises is actually
// in the room. Explicitly defining a brand-new agent template, with its
// own name/purpose/model/skills chosen up front, stays
// `CreateAgentPanel`'s job (Settings → Agents), unchanged.

import { getLogger } from "@corbits/client-log";
import type { QueryClient } from "@tanstack/react-query";
Expand Down Expand Up @@ -84,6 +83,18 @@ export class WorkbenchPreconditionError extends Error {
const SETUP_AGENT_MISSING_MESSAGE =
"Your workbench is still finishing setup. Try again in a moment.";

async function resolveSetupTemplateId(tenantId: string): Promise<string> {
const definitions = await listAgentDefinitions(tenantId);
const setupTemplate = findMyraDefinition(definitions);
if (setupTemplate === undefined) {
throw new WorkbenchPreconditionError(
SETUP_AGENT_MISSING_MESSAGE,
"setup-agent-missing",
);
}
return setupTemplate.id;
}

/**
* Presents the connected org's repo list for the person to pick from once
* the workbench exists — the create flow's own "select" half of CL-6386
Expand All @@ -99,19 +110,20 @@ export type PickGithubRepos = (args: {

/**
* The template picker's "Create workbench" action (CL-6344): mints a
* fresh chat, named after the picked template, against the account's
* default setup template (the seeded `assistant`/Myra definition),
* passing the picked row's id through as `templateId` so the room opens
* with that template's own intro (`packages/chat/src/routes.ts`'s
* `POST /workbenches` resolves it into the canned greeting). When the id
* names a real manifest (`workbenchTemplate`), this also creates its
* participant agent definitions, invites each into the room so the
* roster the greeting promises is the roster actually there (see
* fresh workbench named after the picked template. A blank pick
* ("Just start talking", CL-6979) is a `kind: "workbench"` room with no
* host agent — routing is mention, reply, or an explicit later invite,
* never an auto-hosted Myra. A named template still mints a chat against
* the account's default setup template (the seeded `assistant`/Myra
* definition), passing the picked row's id through as `templateId` so
* the room opens with that template's own intro
* (`packages/chat/src/routes.ts`'s `POST /workbenches` resolves it into
* the canned greeting). When the id names a real manifest
* (`workbenchTemplate`), this also creates its participant agent
* definitions, invites each into the room so the roster the greeting
* promises is the roster actually there (see
* `instantiateWorkbenchTemplate`'s own doc), and records its required
* connections as pending. A template id with no manifest yet (`blank`,
* "Just start talking") mints a plain untagged chat under the generic
* `NEW_WORKBENCH_TITLE`, exactly like before templates existed — there
* is no better name to give it. When `pickGithubRepos` is supplied and
* connections as pending. When `pickGithubRepos` is supplied and
* GitHub is already connected for this tenant, this also drives
* CL-6386's "select on new-workbench" step — see `PickGithubRepos`'s
* own doc.
Expand Down Expand Up @@ -140,14 +152,6 @@ export async function createWorkbenchFromTemplate(
pickGithubRepos?: PickGithubRepos,
firstMessage?: string,
): Promise<void> {
const definitions = await listAgentDefinitions(tenantId);
const setupTemplate = findMyraDefinition(definitions);
if (setupTemplate === undefined) {
throw new WorkbenchPreconditionError(
SETUP_AGENT_MISSING_MESSAGE,
"setup-agent-missing",
);
}
// The manifest comes from the bench library (CL-6344), never from a
// hardcoded catalog import; reading it is what seeds the shelf
// (CL-6458). `blank` is the one id with no manifest by design; any
Expand Down Expand Up @@ -182,16 +186,24 @@ export async function createWorkbenchFromTemplate(
)
: false;

const workbench = await createWorkbench(tenantId, {
kind: "chat",
definitionId: setupTemplate.id,
name: manifest?.title ?? NEW_WORKBENCH_TITLE,
...(manifest !== undefined ? { templatePromise: manifest.promise } : {}),
...(requiresGithub && !githubAlreadyConnected
? { connectGithubRequiredFor: manifest?.title ?? "" }
: {}),
});

// CL-6979: a blank / new-room create is an empty workbench, not a
// Myra-hosted chat. Named templates still launch against the setup
// agent so their greeting and connect card have a sender.
const workbench =
manifest === undefined
? await createWorkbench(tenantId, {
kind: "workbench",
name: NEW_WORKBENCH_TITLE,
})
: await createWorkbench(tenantId, {
kind: "chat",
definitionId: await resolveSetupTemplateId(tenantId),
name: manifest.title,
templatePromise: manifest.promise,
...(requiresGithub && !githubAlreadyConnected
? { connectGithubRequiredFor: manifest.title }
: {}),
});
if (githubAlreadyConnected && pickGithubRepos !== undefined) {
const state = await getConnectGithubState(tenantId, workbench.id);
if (state.kind === "connected" && state.repos.length > 0) {
Expand Down
38 changes: 15 additions & 23 deletions apps/web/test/new-workbench-picker.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -169,27 +169,11 @@ function stubBlankCreate(
onSendMessage?: (body: { parts: readonly { kind: string }[] }) => void,
): RecordedCall[] {
return stubFetch((path, init) => {
if (path.includes("/workflows/definitions")) {
return json({
data: [
{
id: "wfd_assistant",
tenantId: "tnt_1",
name: "assistant",
currentVersion: "1",
status: "deployed",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
},
],
nextCursor: null,
});
}
if (path.endsWith("/chat/workbenches") && init?.method === "POST") {
return json({
id: "chan_new",
title: "New Workbench",
kind: "chat",
kind: "workbench",
pinned: false,
participants: [],
});
Expand Down Expand Up @@ -346,9 +330,12 @@ describe("NewWorkbenchPickerRoute", () => {
call.path.endsWith("/chat/workbenches") && call.init?.method === "POST",
);
expect(JSON.parse(String(createWorkbenchCall?.init?.body))).toMatchObject({
kind: "chat",
definitionId: "wfd_assistant",
kind: "workbench",
name: "New Workbench",
});
expect(
JSON.parse(String(createWorkbenchCall?.init?.body)).definitionId,
).toBeUndefined();

const sendMessageCall = calls.find((call) =>
call.path.endsWith("/chat/workbenches/chan_new/messages"),
Expand Down Expand Up @@ -400,7 +387,9 @@ describe("NewWorkbenchPickerRoute", () => {
// (CL-6457's background drain still running, or never started without a
// credential) must never dead-end the person on the raw internal
// precondition message — the picker checks readiness first and shows an
// honest, retryable "still setting up" state instead.
// honest, retryable "still setting up" state instead. Blank create no
// longer needs Myra (CL-6979); named templates still do, so this
// exercises Code review.
test("when the setup agent isn't deployed yet, creating shows an honest still-setting-up state, not the raw precondition error", async () => {
stubFetch((path) => {
if (path.includes("/workflows/definitions")) {
Expand All @@ -416,15 +405,18 @@ describe("NewWorkbenchPickerRoute", () => {
pending: ["assistant"],
});
}
if (path.includes("/credentials/resolve/")) {
return json({ error: "not_found" }, 404);
}
return undefined;
});
await renderPicker();

const justTalk = prefabCards().find((card) =>
card.textContent?.includes("Just start talking"),
const codeReview = prefabCards().find((card) =>
card.textContent?.includes("Code review"),
);
await act(async () => {
justTalk?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
codeReview?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
for (let i = 0; i < 20; i++) {
await settle();
Expand Down
46 changes: 41 additions & 5 deletions apps/web/test/toast-single-system.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@
// the house styling, and clears itself.

import { toast, Toaster } from "@corbits/react-ui";
import {
CODE_REVIEW_TEMPLATE,
serializeWorkbenchTemplateManifest,
} from "@corbits/workflow-catalog";
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
Expand Down Expand Up @@ -149,6 +153,14 @@ function justStartTalkingCard(): HTMLButtonElement | undefined {
).find((card) => card.textContent?.includes("Just start talking"));
}

function codeReviewCard(): HTMLButtonElement | undefined {
return Array.from(
container?.querySelectorAll<HTMLButtonElement>(
"button.new-workbench-prefab-card",
) ?? [],
).find((card) => card.textContent?.includes("Code review"));
}

describe("the one toast system (CL-6372)", () => {
// The store outlives this file too: a sibling suite that raised a toast
// before bun loaded this one leaves it queued, and it would render into
Expand Down Expand Up @@ -183,16 +195,37 @@ describe("the one toast system (CL-6372)", () => {
await waitForClear();
});

// CL-6510: the new contract this file's own change introduced — a
// missing setup agent no longer fires a toast at all, since the
// picker now shows a retryable "still setting up" panel instead of
// treating that precondition as a dead end.
// CL-6510: a missing setup agent no longer fires a toast at all, since
// the picker now shows a retryable "still setting up" panel instead of
// treating that precondition as a dead end. Blank create no longer needs
// Myra (CL-6979); named templates still do, so this exercises Code review.
test("a missing setup agent shows the retry panel and fires no toast", async () => {
globalThis.fetch = ((input: RequestInfo | URL) => {
const path = typeof input === "string" ? input : String(input);
if (path.includes("/api/me/principals")) {
return Promise.resolve(json(MEMBERSHIP));
}
if (path.endsWith("/library/templates")) {
return Promise.resolve(
json({
data: [
{
id: "code-review",
content:
serializeWorkbenchTemplateManifest(CODE_REVIEW_TEMPLATE),
},
],
}),
);
}
if (path.endsWith("/library/templates/code-review")) {
return Promise.resolve(
json({
id: "code-review",
content: serializeWorkbenchTemplateManifest(CODE_REVIEW_TEMPLATE),
}),
);
}
if (path.includes("/workflows/definitions")) {
return Promise.resolve(json({ data: [], nextCursor: null }));
}
Expand All @@ -208,12 +241,15 @@ describe("the one toast system (CL-6372)", () => {
}),
);
}
if (path.includes("/credentials/resolve/")) {
return Promise.resolve(json({ error: "not_found" }, 404));
}
return Promise.resolve(json({ error: "boom" }, 500));
}) as typeof fetch;
await renderPickerWithToaster();

await act(async () => {
justStartTalkingCard()?.dispatchEvent(
codeReviewCard()?.dispatchEvent(
new MouseEvent("click", { bubbles: true }),
);
});
Expand Down
Loading