diff --git a/apps/web/src/app.css b/apps/web/src/app.css index d4e62ce9..75907a40 100644 --- a/apps/web/src/app.css +++ b/apps/web/src/app.css @@ -350,26 +350,6 @@ select:disabled, color: var(--card-foreground); } -/* The post-landing setup line (CL-6462): quieter than the health banner - above it — no icon, muted text — because nothing is wrong and nothing - is being asked of the reader. */ -.setup-progress-note { - display: flex; - align-items: center; - gap: 0.625rem; - padding: 0.5rem 1rem; - border-bottom: 1px solid var(--border); - background: var(--background); - flex-shrink: 0; -} - -.setup-progress-note-text { - flex: 1; - min-width: 0; - font-size: 0.8125rem; - color: var(--muted-foreground); -} - /* The Plugins gallery's own "couldn't find that connection" notice (CL-6092): a deep link from the shell banner that names a provider the gallery has no card for. */ diff --git a/apps/web/src/pages/new-workbench-picker.tsx b/apps/web/src/pages/new-workbench-picker.tsx index 6c088528..6d86fcbb 100644 --- a/apps/web/src/pages/new-workbench-picker.tsx +++ b/apps/web/src/pages/new-workbench-picker.tsx @@ -9,7 +9,12 @@ // next slice). import { Button, toast } from "@corbits/react-ui"; -import { ChatCircle, GitPullRequest, Plus } from "@corbits/icons"; +import { + ChatCircle, + GitPullRequest, + MagnifyingGlass, + Plus, +} from "@corbits/icons"; import { ChatApiError, describeChatError, @@ -74,6 +79,7 @@ type RepoPickerState = { const ROW_ICON: Record = { "code-review": GitPullRequest, + "due-diligence": MagnifyingGlass, blank: ChatCircle, }; diff --git a/apps/web/src/pages/onboarding-page.tsx b/apps/web/src/pages/onboarding-page.tsx index fda1c7b7..b261bd14 100644 --- a/apps/web/src/pages/onboarding-page.tsx +++ b/apps/web/src/pages/onboarding-page.tsx @@ -50,7 +50,6 @@ import { } from "../onboarding"; import type { CredentialProvider, CredentialProviderCard } from "../onboarding"; import { OnboardingLayout } from "../onboarding/onboarding-layout"; -import { markSetupInProgress } from "../shell/setup-progress-note"; import type { SessionUser } from "../session"; /** No naming step means provisioning always needs a name to send — this @@ -297,7 +296,6 @@ export function OnboardingPage({ user }: { readonly user: SessionUser }) { if (state.phase === "finishing-setup") { void completeSetup().then((outcome) => { if (outcome.kind === "connected") { - if (outcome.agentsPending) markSetupInProgress(); navigate("/"); } else if (outcome.kind === "unseeded") { setResumingUnseeded(true); @@ -362,7 +360,6 @@ export function OnboardingPage({ user }: { readonly user: SessionUser }) { // moves on now, and the warm loading state on the other side // covers whatever is still coming online. if (outcome.kind === "connected") { - if (outcome.agentsPending) markSetupInProgress(); navigate("/"); } else { setState( diff --git a/apps/web/src/shell/app-shell.tsx b/apps/web/src/shell/app-shell.tsx index 2a18eb73..d6400a90 100644 --- a/apps/web/src/shell/app-shell.tsx +++ b/apps/web/src/shell/app-shell.tsx @@ -43,7 +43,6 @@ import { useToggleCanvasFocus, } from "./canvas-availability"; import { ProviderHealthBanner } from "./provider-health-banner"; -import { SetupProgressNote } from "./setup-progress-note"; import { Sidebar } from "./sidebar"; import { ShellContextMenu } from "./context-menu/shell-context-menu"; @@ -173,7 +172,6 @@ export function AppShell({
- {routeHasNoStageTopBar(path) ? ( { - if (!readFlag()) return; - let cancelled = false; - let timer: ReturnType | undefined; - - const poll = () => { - void fetchAgentReadiness().then((readiness) => { - if (cancelled) return; - if (readiness.kind === "ready") { - clearFlag(); - setVisible(false); - return; - } - setVisible(readiness.kind === "chat-ready"); - timer = setTimeout(poll, SETUP_POLL_MS); - }); - }; - poll(); - - return () => { - cancelled = true; - if (timer !== undefined) clearTimeout(timer); - }; - }, []); - - if (!visible || dismissed) return null; - - return ( -
-

- Your workbench is still setting up in the background. Nothing to wait - for — keep going. -

- -
- ); -} diff --git a/apps/web/src/workbench-templates.ts b/apps/web/src/workbench-templates.ts index 35b2ae54..1ec38c5a 100644 --- a/apps/web/src/workbench-templates.ts +++ b/apps/web/src/workbench-templates.ts @@ -1,10 +1,15 @@ -// The picker's row catalog (CL-6342): one entry per selectable kind, plus -// the disabled "more kinds soon" row. Copy is pinned to the approved mock -// verbatim — see `pages/new-workbench-picker.tsx` for the row rendering -// and `instant-agent-create.ts`'s `createWorkbenchFromTemplate` for what +// The picker's row catalog: one entry per selectable kind, plus the +// disabled "more kinds soon" row. `code-review` and `blank` are pinned to +// the approved mock (CL-6342) verbatim; `due-diligence` mirrors the +// backend's `DUE_DILIGENCE_TEMPLATE` (`@corbits/workflow-catalog`, CL-6499) +// — it and every other id here are still gated by what this bench's +// library actually serves before either is offered as a live row (see +// `NewWorkbenchPickerRoute`'s `servedTemplateIds`). See +// `pages/new-workbench-picker.tsx` for the row rendering and +// `instant-agent-create.ts`'s `createWorkbenchFromTemplate` for what // picking one actually does today. -export type WorkbenchTemplateId = "code-review" | "blank"; +export type WorkbenchTemplateId = "code-review" | "due-diligence" | "blank"; export type WorkbenchTemplate = { readonly id: WorkbenchTemplateId; @@ -19,6 +24,12 @@ export const WORKBENCH_TEMPLATES: readonly WorkbenchTemplate[] = [ promise: "Three reviewers read every pull request and post what they'd change.", }, + { + id: "due-diligence", + title: "Research & due diligence", + promise: + "Scout researches the web and what your team already knows, and saves what it finds so you can pick it up later.", + }, { id: "blank", title: "Just start talking", diff --git a/apps/web/test/app-shell-setup-note-removed.test.tsx b/apps/web/test/app-shell-setup-note-removed.test.tsx new file mode 100644 index 00000000..020a11b9 --- /dev/null +++ b/apps/web/test/app-shell-setup-note-removed.test.tsx @@ -0,0 +1,104 @@ +// Regression test: the owner's first-run feedback was explicit — the +// "still setting up in the background" note is noise at the exact moment +// someone is forming a first impression, and it must never appear. This +// mounts the real shell with the legacy session flag set (as a stale +// browser tab from before the removal would have it) and asserts the +// note cannot render, proving removal rather than just a hidden default. + +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; + +import { AppShell } from "../src/shell/app-shell"; +import { BenchProvider } from "../src/bench-context"; +import { NavigationProvider } from "../src/navigation"; +import { ProviderHealthProvider } from "../src/shell/provider-health-context"; +import { ShellChromeProvider } from "../src/shell/shell-chrome-provider"; +import { TestQueryProvider } from "./test-query-provider"; + +const noop = () => undefined; +const realFetch = globalThis.fetch; +const realMatchMedia = window.matchMedia; + +const user = { id: "user_1", name: "Ada Lovelace", email: "ada@example.com" }; + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); +const settle = () => act(() => sleep(10)); + +function stubMatchMedia(matching: Record): void { + window.matchMedia = ((media: string) => + ({ + media, + matches: matching[media] ?? false, + addEventListener: noop, + removeEventListener: noop, + }) as unknown as MediaQueryList) as typeof window.matchMedia; +} + +const emptyMemberships = () => + new Response(JSON.stringify({ data: [], nextCursor: null }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + +// A pending provisioning status is exactly the shape that used to flip the +// note visible once the legacy session flag was set. +const pendingProvisioningStatus = () => + new Response( + JSON.stringify({ kind: "provisioning", setupAgentReady: true }), + { status: 200, headers: { "content-type": "application/json" } }, + ); + +describe("app shell no longer shows the background setup note", () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + stubMatchMedia({}); + sessionStorage.setItem("workbench.setup-in-progress", "1"); + globalThis.fetch = ((input: RequestInfo | URL) => { + const url = typeof input === "string" ? input : String(input); + return Promise.resolve( + url.includes("provisioning-status") + ? pendingProvisioningStatus() + : emptyMemberships(), + ); + }) as typeof fetch; + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + globalThis.fetch = realFetch; + window.matchMedia = realMatchMedia; + sessionStorage.clear(); + }); + + test("renders no setup note text or markup, even with the legacy flag set", async () => { + await act(async () => { + root.render( + + + + + + + {"Inbox"} + + + + + + , + ); + }); + await settle(); + await settle(); + + expect(container.textContent).not.toContain("still setting up"); + expect(container.querySelector(".setup-progress-note")).toBeNull(); + }); +}); diff --git a/apps/web/test/new-workbench-picker.test.tsx b/apps/web/test/new-workbench-picker.test.tsx index 06a5d254..029f008e 100644 --- a/apps/web/test/new-workbench-picker.test.tsx +++ b/apps/web/test/new-workbench-picker.test.tsx @@ -8,6 +8,7 @@ import { afterEach, describe, expect, test } from "bun:test"; import { CODE_REVIEW_TEMPLATE, + DUE_DILIGENCE_TEMPLATE, serializeWorkbenchTemplateManifest, } from "@corbits/workflow-catalog"; import { act } from "react"; @@ -144,6 +145,51 @@ describe("NewWorkbenchPickerRoute", () => { expect(container?.textContent).toContain("More kinds soon"); }); + // The library seeds every shipped template (`createTemplateLibrarySeeder`), + // so a bench whose library serves due-diligence too offers it as a real + // row, not just an entry in the static row catalog with nothing to back it. + test("due-diligence is offered as a selectable row once the library serves it", 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), + }, + { + id: "due-diligence", + content: serializeWorkbenchTemplateManifest( + DUE_DILIGENCE_TEMPLATE, + ), + }, + ], + }), + ); + } + throw new Error(`unexpected fetch: ${path}`); + }) as typeof fetch; + await renderPicker(); + + const radios = Array.from( + container?.querySelectorAll('[role="radio"]') ?? [], + ); + expect(radios.length).toBe(3); + const dueDiligence = radios.find((row) => + row.textContent?.includes("Research & due diligence"), + ); + expect(dueDiligence).not.toBeUndefined(); + expect(dueDiligence?.textContent).toContain( + "Scout researches the web and what your team already knows", + ); + }); + // CL-6458: the picker offers what the bench's library can actually // serve. A row the library has no manifest for is shown as not set up // — never offered and then dead-ended on a 404 at create time. diff --git a/apps/web/test/setup-progress-note.test.tsx b/apps/web/test/setup-progress-note.test.tsx deleted file mode 100644 index 67c8cecc..00000000 --- a/apps/web/test/setup-progress-note.test.tsx +++ /dev/null @@ -1,95 +0,0 @@ -// CL-6462's quiet half: once Myra is up the person is already in a -// conversation, so whatever is still deploying gets one dismissible line -// and nothing more. It must also stay out of the way entirely for -// everyone who did not just connect a provider — no line, no request. - -import { afterEach, describe, expect, test } from "bun:test"; -import { act } from "react"; -import { createRoot } from "react-dom/client"; -import type { Root } from "react-dom/client"; - -import { - markSetupInProgress, - SetupProgressNote, -} from "../src/shell/setup-progress-note"; - -const realFetch = globalThis.fetch; - -let container: HTMLDivElement | null = null; -let root: Root | null = null; - -afterEach(() => { - globalThis.fetch = realFetch; - sessionStorage.clear(); - if (root !== null) { - act(() => root?.unmount()); - root = null; - } - container?.remove(); - container = null; -}); - -const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); -const settle = () => act(() => sleep(10)); - -function stubStatus(body: unknown) { - const calls: string[] = []; - globalThis.fetch = ((input: RequestInfo | URL) => { - calls.push(typeof input === "string" ? input : String(input)); - return Promise.resolve( - new Response(JSON.stringify(body), { - status: 200, - headers: { "content-type": "application/json" }, - }), - ); - }) as typeof fetch; - return calls; -} - -async function render() { - container = document.createElement("div"); - document.body.appendChild(container); - root = createRoot(container); - await act(async () => { - root?.render(); - }); - await settle(); - await settle(); -} - -describe("SetupProgressNote", () => { - test("says nothing, and asks nothing, for someone who did not just connect", async () => { - const calls = stubStatus({ kind: "provisioning", setupAgentReady: true }); - - await render(); - - expect(container?.textContent).toBe(""); - expect(calls).toEqual([]); - }); - - test("shows one quiet dismissible line while the rest is still coming online", async () => { - stubStatus({ kind: "provisioning", setupAgentReady: true }); - markSetupInProgress(); - - await render(); - - expect(container?.textContent).toContain("still setting up"); - const dismiss = container?.querySelector('[aria-label="Dismiss"]'); - expect(dismiss).not.toBeNull(); - - await act(async () => { - (dismiss as HTMLButtonElement).click(); - }); - expect(container?.textContent).toBe(""); - }); - - test("shows nothing once the bench reports everything live, and stops watching", async () => { - stubStatus({ kind: "ready", setupAgentReady: true }); - markSetupInProgress(); - - await render(); - - expect(container?.textContent).toBe(""); - expect(sessionStorage.getItem("workbench.setup-in-progress")).toBeNull(); - }); -}); diff --git a/bun.lock b/bun.lock index f0522267..09912b93 100644 --- a/bun.lock +++ b/bun.lock @@ -434,6 +434,7 @@ "@corbits/memory": "github:corbitsdev/corbits-memory#9e6f213fa2c002b531d3f6af1aa0abd737b8afe3", "@corbits/turn-artifacts": "workspace:*", "@corbits/url-path": "workspace:*", + "@corbits/workflow-catalog": "workspace:*", "@corbits/workflow-source": "workspace:*", "@intx/agent": "0.3.0", "@intx/authz": "0.3.0", @@ -898,7 +899,7 @@ }, "packages/jimmy-agent": { "name": "@corbits/jimmy-agent", - "version": "0.0.1", + "version": "0.0.2", "dependencies": { "@intx/agent": "0.3.0", "@intx/types": "0.3.0", @@ -1189,7 +1190,7 @@ }, "packages/scout-agent": { "name": "@corbits/scout-agent", - "version": "0.0.1", + "version": "0.0.2", "dependencies": { "@intx/agent": "0.3.0", "@intx/types": "0.3.0", @@ -3516,8 +3517,6 @@ "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.6", "", {}, "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw=="], - "@workbench/hub/@corbits/mailbox": ["@corbits/mailbox@github:corbitsdev/corbits-mailbox#caa5214", { "dependencies": { "@hono/standard-validator": "0.2.3", "@standard-community/standard-json": "0.3.5", "@standard-community/standard-openapi": "0.2.9", "arktype": "2.1.29", "hono-openapi": "1.3.1" }, "peerDependencies": { "@intx/log": "^0.2.2", "@intx/mime": "^0.2.2", "@intx/types": "^0.2.2", "drizzle-orm": "^0.45.2", "hono": "^4.12.0", "postgres": "^3.4.0" } }, "corbitsdev-corbits-mailbox-caa5214", "sha512-z8DRBFgA4ukM8p29COeaMjfKZYe5jAUF4OBMiaIQFuW592+DGD/y6Ws6SjGlXmR9azkHNWh8oTzjlWlRP24vsQ=="], - "@workbench/hub/@corbits/memory": ["@corbits/memory@github:corbitsdev/corbits-memory#9e6f213", { "dependencies": { "@intx/agent": "0.2.2", "@intx/authz": "0.2.2", "@intx/hub-api": "0.2.2", "@intx/log": "0.2.2", "@intx/workflow": "0.2.2", "arktype": "^2.1.29", "drizzle-orm": "^0.45.1", "hono": "^4.9.0", "hono-openapi": "^1.3.1", "postgres": "^3.4.7" } }, "corbitsdev-corbits-memory-9e6f213", "sha512-utnM4ZT2zmslcPXYWAAqxlDNLcpGsXFiTOtj8h7+OXnhCP0Eaw8yl25+yCTyHpvt3jcdeG4h5uFsSj7ou0BZCA=="], "ajv-formats/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], diff --git a/packages/chat/package.json b/packages/chat/package.json index d4f2068d..4b0059c9 100644 --- a/packages/chat/package.json +++ b/packages/chat/package.json @@ -31,6 +31,7 @@ "@corbits/folded-runs": "workspace:*", "@corbits/memory": "github:corbitsdev/corbits-memory#9e6f213fa2c002b531d3f6af1aa0abd737b8afe3", "@corbits/turn-artifacts": "workspace:*", + "@corbits/workflow-catalog": "workspace:*", "@corbits/workflow-source": "workspace:*", "@intx/agent": "0.3.0", "@intx/authz": "0.3.0", diff --git a/packages/chat/src/workbench-service.ts b/packages/chat/src/workbench-service.ts index f6863f27..8d8e6dc9 100644 --- a/packages/chat/src/workbench-service.ts +++ b/packages/chat/src/workbench-service.ts @@ -9,6 +9,7 @@ import { generateId } from "@intx/hub-common"; import { getLogger } from "@intx/log"; import { InferenceResolutionError } from "@corbits/folded-runs"; +import { workbenchTemplate } from "@corbits/workflow-catalog"; import { encodeParts } from "./codec"; import type { Part as PartType } from "./parts"; import { localPartOf } from "./agent-address"; @@ -385,34 +386,74 @@ export type PostCannedGreetingInput = CannedGreetingInput & { readonly agentAddress: string; }; +/** + * The two template ids a blank room's opener may name. Each one's + * participants fully resolve through `instantiateWorkbenchTemplate`'s + * agent-request ports today (the code reviewers and Scout both have a + * real `createParticipantAgent` request), so offering them is a promise + * the picker's "Create workbench" flow can actually keep. `gtm` stays + * out: its crm/collateral participants have no such resolver yet, so + * naming it would offer something that can't be created. + */ +const BLANK_ROOM_OFFER_TEMPLATE_IDS = ["code-review", "due-diligence"] as const; + +/** The titles to name in a blank room's opener, read live off + * `@corbits/workflow-catalog` every time — never a copy of the title + * text, so a rename in the catalog shows up here with no edit. */ +function blankRoomOfferTitles(): readonly string[] { + return BLANK_ROOM_OFFER_TEMPLATE_IDS.map( + (id) => workbenchTemplate(id)?.title, + ).filter((title): title is string => title !== undefined); +} + +/** One clause naming what's ready to go, or "" once neither offer id + * resolves (a catalog shipping neither template) — never a bare + * "there's a" with nothing after it. Exactly two ids are ever offered + * today (see `BLANK_ROOM_OFFER_TEMPLATE_IDS`), so this only has to + * handle naming one or both. */ +function templateOfferClause(): string { + const [first, second] = blankRoomOfferTitles(); + if (first === undefined) return ""; + if (second === undefined) { + return ` There's a ${first} setup ready to go, if that fits.`; + } + return ` There's a ${first} setup and a ${second} one ready to go, if either fits.`; +} + /** * The opener variations. Canned rather than model-written so the * greeting is on the timeline the moment the agent joins — a fresh * chat used to stay silent through a whole kickoff inference turn, and * a person who typed into that silence wrong-footed the conversation. - * Each takes the leading address (" Alice" or "") and the agent's - * display name; none may mention the workbench's title (a label the - * opener picked, never a request) or capabilities-as-a-menu. + * Each takes the leading address (" Alice" or ""), the agent's display + * name, and the template-offer clause (see `templateOfferClause`), + * inserted just before the closing question; none may mention the + * workbench's title (a label the opener picked, never a request). */ -const GREETING_VARIATIONS: readonly ((who: string, agent: string) => string)[] = - [ - (who, agent) => - `Hey${who} — good to have a space to work in together. I'm ${agent}, ` + - "your teammate here; I can write, plan, pull pieces together, and " + - "line up the specialists and automations when we need them. What " + - "are you working on?", - (who, agent) => - `Hi${who}, I'm ${agent} — your teammate here. Drafting, planning, ` + - "research, lining up automations: all fair game. What should we " + - "dig into first?", - (who, agent) => - `Welcome in${who === "" ? "" : `,${who}`}. I'm ${agent}; think of me ` + - "as the teammate who writes, plans, and pulls in the right " + - "specialists when a job calls for them. What's on your plate?", - (who, agent) => - `Hey${who} — ${agent} here. This space is ours to work in: I can ` + - "draft, plan, and wire things up as we go. What are you working on?", - ]; +const GREETING_VARIATIONS: readonly (( + who: string, + agent: string, + templateOffer: string, +) => string)[] = [ + (who, agent, templateOffer) => + `Hey${who} — good to have a space to work in together. I'm ${agent}, ` + + "your teammate here; I can write, plan, pull pieces together, and " + + `line up the specialists and automations when we need them.${templateOffer} ` + + "What are you working on?", + (who, agent, templateOffer) => + `Hi${who}, I'm ${agent} — your teammate here. Drafting, planning, ` + + `research, lining up automations: all fair game.${templateOffer} What ` + + "should we dig into first?", + (who, agent, templateOffer) => + `Welcome in${who === "" ? "" : `,${who}`}. I'm ${agent}; think of me ` + + "as the teammate who writes, plans, and pulls in the right " + + `specialists when a job calls for them.${templateOffer} What's on ` + + "your plate?", + (who, agent, templateOffer) => + `Hey${who} — ${agent} here. This space is ours to work in: I can ` + + `draft, plan, and wire things up as we go.${templateOffer} What are ` + + "you working on?", +]; function greetingVariationIndex(workbenchId: string): number { let sum = 0; @@ -449,7 +490,7 @@ export function cannedGreeting(input: CannedGreetingInput): string { const variation = GREETING_VARIATIONS[greetingVariationIndex(input.workbenchId)]; if (variation === undefined) throw new Error("no greeting variations"); - return variation(who, input.agentName); + return variation(who, input.agentName, templateOfferClause()); } /** diff --git a/packages/chat/test/workbench-service.test.ts b/packages/chat/test/workbench-service.test.ts index 95b5b31b..c80fdeb9 100644 --- a/packages/chat/test/workbench-service.test.ts +++ b/packages/chat/test/workbench-service.test.ts @@ -9,6 +9,7 @@ import { decodeParts } from "../src/codec"; import type { Part } from "../src/parts"; import { createInMemoryWorkbenchTenancyStore } from "../src/workbench-tenancy"; import { AgentUnreachableError } from "../src/platform-port"; +import { workbenchTemplate } from "@corbits/workflow-catalog"; import { cannedGreeting, postCannedGreeting } from "../src/workbench-service"; import { buildDeps, @@ -65,7 +66,7 @@ describe("postCannedGreeting (CL-6126)", () => { ]); }); - test("the greeting names the opener and the agent, and asks a question — never a menu or the workbench title", () => { + test("the greeting names the opener and the agent, asks a question, and never names the workbench title", () => { const greeting = cannedGreeting({ workbenchId: "chan_1", agentName: "Myra", @@ -77,6 +78,28 @@ describe("postCannedGreeting (CL-6126)", () => { expect(greeting).not.toContain("undefined"); }); + test("a blank room's greeting names the templates read live off the catalog, never a frozen string", () => { + const codeReviewTitle = workbenchTemplate("code-review")?.title; + const dueDiligenceTitle = workbenchTemplate("due-diligence")?.title; + if (codeReviewTitle === undefined || dueDiligenceTitle === undefined) { + throw new Error("expected both offer templates to carry a title"); + } + + for (const workbenchId of ["chan_0", "chan_1", "chan_2", "chan_3"]) { + const greeting = cannedGreeting({ workbenchId, agentName: "Myra" }); + expect(greeting).toContain(codeReviewTitle); + expect(greeting).toContain(dueDiligenceTitle); + } + }); + + test("a blank room's greeting points at the templates rather than claiming to build one", () => { + const greeting = cannedGreeting({ + workbenchId: "chan_1", + agentName: "Myra", + }); + expect(greeting).not.toMatch(/I('ll| will) (set up|spin up|create|build)/i); + }); + test("the same chat always gets the same variation", () => { const input = { workbenchId: "chan_1", agentName: "Myra" }; expect(cannedGreeting(input)).toBe(cannedGreeting(input));