diff --git a/apps/web/src/instant-agent-create.test.ts b/apps/web/src/instant-agent-create.test.ts index a6616da04..3c18a2649 100644 --- a/apps/web/src/instant-agent-create.test.ts +++ b/apps/web/src/instant-agent-create.test.ts @@ -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"]); + }); }); diff --git a/apps/web/src/instant-agent-create.ts b/apps/web/src/instant-agent-create.ts index 538880f8f..27db9f58c 100644 --- a/apps/web/src/instant-agent-create.ts +++ b/apps/web/src/instant-agent-create.ts @@ -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"; @@ -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 { + 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 @@ -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. @@ -140,14 +152,6 @@ export async function createWorkbenchFromTemplate( pickGithubRepos?: PickGithubRepos, firstMessage?: string, ): Promise { - 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 @@ -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) { diff --git a/apps/web/test/new-workbench-picker.test.tsx b/apps/web/test/new-workbench-picker.test.tsx index 7d9c00910..04764802f 100644 --- a/apps/web/test/new-workbench-picker.test.tsx +++ b/apps/web/test/new-workbench-picker.test.tsx @@ -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: [], }); @@ -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"), @@ -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")) { @@ -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(); diff --git a/apps/web/test/toast-single-system.test.tsx b/apps/web/test/toast-single-system.test.tsx index 018ea84b7..64d29baf8 100644 --- a/apps/web/test/toast-single-system.test.tsx +++ b/apps/web/test/toast-single-system.test.tsx @@ -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"; @@ -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( + "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 @@ -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 })); } @@ -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 }), ); });