diff --git a/apps/web/src/app.css b/apps/web/src/app.css index f74f2768c..797c46877 100644 --- a/apps/web/src/app.css +++ b/apps/web/src/app.css @@ -335,6 +335,26 @@ 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/onboarding.ts b/apps/web/src/onboarding.ts index 9b868bb9d..c0029a634 100644 --- a/apps/web/src/onboarding.ts +++ b/apps/web/src/onboarding.ts @@ -589,41 +589,45 @@ export async function completeSetup(): Promise { const ProvisioningStatus = type({ kind: "'ready' | 'provisioning'", - deployed: "string[]", - pending: "string[]", + setupAgentReady: "boolean", }); -export type ProvisioningProgress = { - readonly ready: boolean; - /** How many of this bench's agents are live, and how many there are in - * total — the numbers a waiting surface shows so the wait reads as - * progress rather than a frozen label. */ - readonly live: number; - readonly total: number; -}; +/** + * Whether this account can start a conversation yet, and whether + * anything is still coming online behind it (CL-6462). Deliberately not + * a count: how many workflows a bench seeds is an implementation detail, + * and a person watching "0 of 5" learns nothing they can act on. + * + * - `ready` — everything this bench seeds is live. + * - `chat-ready` — Myra is live, so the person can start now; the rest + * converge in the background. + * - `preparing` — Myra is not live yet; this is the only state worth + * holding someone on a loader for. + * - `unknown` — we could not tell (offline, a hiccup, an account with no + * personal bench). Never rendered as either progress or failure. + */ +export type AgentReadiness = + | { readonly kind: "ready" } + | { readonly kind: "chat-ready" } + | { readonly kind: "preparing" } + | { readonly kind: "unknown" }; /** * Where this account's agents stand right now. Cheap and read-only, so a - * surface that has to wait may poll it on a short interval. An - * unreachable or unparseable answer reports "not ready yet" rather than - * throwing: a hiccup in a progress check must never turn into an error - * screen over work that is, in fact, still progressing fine. + * surface that has to wait may poll it on a short interval. */ -export async function fetchProvisioningProgress(): Promise { +export async function fetchAgentReadiness(): Promise { try { const response = await fetch("/api/onboarding/provisioning-status"); + if (!response.ok) return { kind: "unknown" }; const body: unknown = await response.json().catch(() => null); - if (!response.ok) return { ready: false, live: 0, total: 0 }; const parsed = ProvisioningStatus(body); - if (parsed instanceof type.errors) { - return { ready: false, live: 0, total: 0 }; - } - return { - ready: parsed.kind === "ready", - live: parsed.deployed.length, - total: parsed.deployed.length + parsed.pending.length, - }; + if (parsed instanceof type.errors) return { kind: "unknown" }; + if (parsed.kind === "ready") return { kind: "ready" }; + return parsed.setupAgentReady + ? { kind: "chat-ready" } + : { kind: "preparing" }; } catch { - return { ready: false, live: 0, total: 0 }; + return { kind: "unknown" }; } } diff --git a/apps/web/src/pages/home-page.tsx b/apps/web/src/pages/home-page.tsx index 28baf4439..6287c1c23 100644 --- a/apps/web/src/pages/home-page.tsx +++ b/apps/web/src/pages/home-page.tsx @@ -7,6 +7,17 @@ // creates) the Myra workbench in the main stage, unchanged. Home as a // dashboard does not earn its keep — `/` only exists as this hop onto // `/w/:workbenchId`. Deep links to other pages are unchanged. +// +// Right after a provider connect this hop is also the wait (CL-6457's +// deploys run in the background, so landing here can beat them). CL-6462 +// settled what that wait looks like: one warm loader and nothing else. +// The land is simply attempted again every few seconds, because launching +// Myra IS the test of whether the person can start — she is deployed +// first (`SETUP_AGENT_ASSET_NAME` leads `DEFAULT_WORKFLOWS`), so the +// moment she answers we go, with every other seeded workflow still +// converging behind us. Readiness is read only to tell a wait from a +// genuine failure, never to draw a progress number: a seed count is an +// implementation detail, and "0 of 5" told a waiting person nothing. import { Button, EmptyState, PageShell } from "@corbits/react-ui"; import { WarningCircle } from "@corbits/icons"; @@ -15,7 +26,7 @@ import { useEffect, useState } from "react"; import { listAllWorkbenches, WorkbenchLoadingState } from "@corbits/chat-ui"; import { describeApiError } from "@corbits/api-query"; -import { fetchProvisioningProgress } from "../onboarding"; +import { fetchAgentReadiness } from "../onboarding"; import { useBench } from "../bench-context"; import { workbenchPath } from "../workbench-path"; import { createAgentAndLaunch } from "../instant-agent-create"; @@ -23,60 +34,73 @@ import { ensureMyraWorkbench } from "../myra-workbench"; import { useNavigate } from "../navigation"; type LandState = - | { readonly kind: "checking" } - /** The bench exists and its credential is connected, but its agents - * are still being deployed in the background (CL-6457). Landing here - * is expected right after someone connects a provider — connecting - * deliberately no longer waits for deploys — so this is a warm wait - * with live progress, never an error. */ - | { - readonly kind: "provisioning"; - readonly live: number; - readonly total: number; - } + /** Working on it: the warm loader, whether we are reading the bench's + * workbenches or waiting for Myra to finish coming online. Both are + * the same thing to the person waiting. */ + | { readonly kind: "opening" } + /** Myra has taken long enough that silence would read as a hang. Says + * so plainly and offers another go — never a frozen number. */ + | { readonly kind: "slow" } | { readonly kind: "error"; readonly message: string }; -const PROVISIONING_POLL_MS = 3_000; +const LAND_RETRY_MS = 3_000; +const LAND_STALL_MS = 45_000; -export function HomeRoute() { +export function HomeRoute({ + retryMs = LAND_RETRY_MS, + stallAfterMs = LAND_STALL_MS, +}: { + /** Timing seams. Production passes neither. */ + readonly retryMs?: number; + readonly stallAfterMs?: number; +} = {}) { const navigate = useNavigate(); const { selectedTenantId, memberships } = useBench(); - const [state, setState] = useState({ kind: "checking" }); - const [retryCount, setRetryCount] = useState(0); + const [state, setState] = useState({ kind: "opening" }); + const [attempt, setAttempt] = useState(0); + const [waitId, setWaitId] = useState(0); + + const startOver = () => { + setState({ kind: "opening" }); + setAttempt(0); + setWaitId((id) => id + 1); + }; useEffect(() => { if (selectedTenantId === null) return; let cancelled = false; - setState({ kind: "checking" }); + let retryTimer: ReturnType | undefined; + + const waitAndRetry = () => { + if (cancelled) return; + if ((attempt + 1) * retryMs >= stallAfterMs) { + setState({ kind: "slow" }); + return; + } + retryTimer = setTimeout(() => setAttempt((count) => count + 1), retryMs); + }; + + // A land that failed is either "she isn't up yet" or a real problem, + // and only the bench itself can say which. + const classify = (cause: unknown) => { + void fetchAgentReadiness().then((readiness) => { + if (cancelled) return; + if (readiness.kind === "ready" || readiness.kind === "chat-ready") { + setState({ + kind: "error", + message: describeApiError(cause, "opening Myra"), + }); + return; + } + waitAndRetry(); + }); + }; + void listAllWorkbenches(selectedTenantId).then( (workbenches) => { if (cancelled) return; if (workbenches.length === 0) { - createAgentAndLaunch(selectedTenantId, navigate).catch( - (cause: unknown) => { - if (cancelled) return; - // Myra cannot be launched if she has not finished - // deploying yet. Rather than reading that off an error - // message, ask the bench where its agents actually are: a - // bench still provisioning is someone waiting, not someone - // broken. - void fetchProvisioningProgress().then((progress) => { - if (cancelled) return; - setState( - progress.ready - ? { - kind: "error", - message: describeApiError(cause, "opening Myra"), - } - : { - kind: "provisioning", - live: progress.live, - total: progress.total, - }, - ); - }); - }, - ); + createAgentAndLaunch(selectedTenantId, navigate).catch(classify); return; } void ensureMyraWorkbench(selectedTenantId).then((result) => { @@ -85,7 +109,7 @@ export function HomeRoute() { navigate(workbenchPath(result.workbenchId)); return; } - setState({ kind: "error", message: result.message }); + classify(new Error(result.message)); }); }, (cause: unknown) => { @@ -96,36 +120,12 @@ export function HomeRoute() { }); }, ); - return () => { - cancelled = true; - }; - }, [selectedTenantId, navigate, retryCount]); - // While agents are still deploying, keep asking — and the moment the - // bench is ready, re-run the land so the person drops straight into - // Myra without touching anything. - useEffect(() => { - if (state.kind !== "provisioning") return; - let cancelled = false; - const timer = setInterval(() => { - void fetchProvisioningProgress().then((progress) => { - if (cancelled) return; - if (progress.ready) { - setRetryCount((count) => count + 1); - return; - } - setState({ - kind: "provisioning", - live: progress.live, - total: progress.total, - }); - }); - }, PROVISIONING_POLL_MS); return () => { cancelled = true; - clearInterval(timer); + if (retryTimer !== undefined) clearTimeout(retryTimer); }; - }, [state.kind]); + }, [selectedTenantId, navigate, attempt, waitId, retryMs, stallAfterMs]); if (memberships.kind === "loading") { return ( @@ -172,10 +172,7 @@ export function HomeRoute() { title="Couldn't open Myra" description={state.message} action={ - } @@ -184,22 +181,26 @@ export function HomeRoute() { ); } - if (state.kind === "provisioning") { + if (state.kind === "slow") { return ( -
- -

- {state.total > 0 - ? `${state.live} of ${state.total} ready` - : "This only takes a moment."} -

-
+ + } + title="Myra is taking longer than usual" + description="She's still getting set up. Give it another moment, or try again." + action={ + + } + /> + ); } return (
- +
); } diff --git a/apps/web/src/pages/onboarding-page.tsx b/apps/web/src/pages/onboarding-page.tsx index 62919b99c..5dfd06c41 100644 --- a/apps/web/src/pages/onboarding-page.tsx +++ b/apps/web/src/pages/onboarding-page.tsx @@ -50,6 +50,7 @@ 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 @@ -296,6 +297,7 @@ 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); @@ -348,6 +350,7 @@ 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 d6400a902..2a18eb73a 100644 --- a/apps/web/src/shell/app-shell.tsx +++ b/apps/web/src/shell/app-shell.tsx @@ -43,6 +43,7 @@ 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"; @@ -172,6 +173,7 @@ 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 the rest of its agents. Nothing to + wait for — keep going. +

+ +
+ ); +} diff --git a/apps/web/test/home-page.test.tsx b/apps/web/test/home-page.test.tsx index 64478d013..78885fc34 100644 --- a/apps/web/test/home-page.test.tsx +++ b/apps/web/test/home-page.test.tsx @@ -221,6 +221,141 @@ describe("HomeRoute (the `/` land hop every entry point funnels through)", () => }); }); +// CL-6462: what someone sees between "connect a provider" and "talking to +// Myra". The old answer was a bare centred "0 of 5 ready" on an empty +// page; these pin the replacement — one warm loader, no counts, a land +// that happens the moment Myra herself can answer, and an honest way out +// if she never does. +describe("the wait right after connecting a provider", () => { + /** A bench with no workbenches yet whose agent definitions arrive only + * after `readyAfter` reads — everything before that is the window the + * person spends waiting. */ + function benchWhereMyraArrivesAfter(readyAfter: number, provisioning = true) { + let definitionReads = 0; + const state = { statusCalls: 0 }; + stubFetch((path, method) => { + if (path === "/api/me/principals") return json(PRINCIPALS_RESPONSE); + if (path.endsWith("/chat/workbenches") && method === "GET") { + return json({ items: [] }); + } + if (path.includes("/workflows/definitions")) { + definitionReads += 1; + return json({ + data: + definitionReads > readyAfter + ? [ + { + 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 === "/api/onboarding/provisioning-status") { + state.statusCalls += 1; + return json({ + kind: provisioning ? "provisioning" : "ready", + setupAgentReady: !provisioning, + deployed: [], + pending: ["assistant"], + }); + } + if (path.endsWith("/chat/workbenches") && method === "POST") { + return json({ + id: "chan_new", + title: "New Workbench", + kind: "chat", + pinned: false, + participants: [], + }); + } + throw new Error(`unexpected fetch: ${method} ${path}`); + }); + return state; + } + + function renderHome(props: { + readonly retryMs: number; + readonly stallAfterMs: number; + readonly navigated: string[]; + }) { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + return act(async () => { + root?.render( + + props.navigated.push(to)}> + + + + + , + ); + }); + } + + test("shows the warm loader while Myra is still coming online — never a count", async () => { + benchWhereMyraArrivesAfter(99); + const navigated: string[] = []; + await renderHome({ retryMs: 10, stallAfterMs: 10_000, navigated }); + for (let i = 0; i < 40; i++) { + await settle(); + if ((container?.textContent ?? "") !== "") break; + } + + const text = container?.textContent ?? ""; + expect(text).toContain("Getting your workbench ready"); + expect(text).toContain("Tip:"); + expect(text).not.toMatch(/\d+ of \d+/); + expect(text).not.toMatch(/\d/); + expect(navigated).toEqual([]); + }); + + test("lands the moment Myra can answer, without waiting on the rest of the seeds", async () => { + // The status route still says "provisioning" — other workflows are + // mid-deploy — and the land happens anyway. + const state = benchWhereMyraArrivesAfter(1); + const navigated: string[] = []; + await renderHome({ retryMs: 10, stallAfterMs: 10_000, navigated }); + for (let i = 0; i < 40; i++) { + await settle(); + if (navigated.length > 0) break; + } + + expect(navigated).toEqual(["/w/chan_new"]); + expect(state.statusCalls).toBeGreaterThan(0); + }); + + test("says so honestly with a retry once the wait has gone on too long", async () => { + benchWhereMyraArrivesAfter(99); + const navigated: string[] = []; + await renderHome({ retryMs: 10, stallAfterMs: 40, navigated }); + for (let i = 0; i < 40; i++) { + await settle(); + if ((container?.textContent ?? "").includes("longer than usual")) break; + } + + const text = container?.textContent ?? ""; + expect(text).toContain("Myra is taking longer than usual"); + expect(text).not.toMatch(/\d+ of \d+/); + const retry = Array.from(container?.querySelectorAll("button") ?? []).find( + (button) => button.textContent === "Try again", + ); + expect(retry).not.toBeUndefined(); + }); +}); + describe('a failed memberships fetch never reads as "pick from the switcher"', () => { test("shows an error state with Retry, not the empty-selection copy", async () => { let principalsCalls = 0; diff --git a/apps/web/test/setup-progress-note.test.tsx b/apps/web/test/setup-progress-note.test.tsx new file mode 100644 index 000000000..67c8cecc1 --- /dev/null +++ b/apps/web/test/setup-progress-note.test.tsx @@ -0,0 +1,95 @@ +// 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/docs/bench-provisioning.md b/docs/bench-provisioning.md index 541535c4c..324a54285 100644 --- a/docs/bench-provisioning.md +++ b/docs/bench-provisioning.md @@ -69,13 +69,25 @@ a human sign-in, and are cached per user rather than minted per tick. ## What someone sees -Connecting hands the person forward immediately. If they land before -their agents exist, they get the warm loading state — "Getting your -agents ready…" with live progress (how many agents are ready out of how -many) — which resolves into their workbench on its own once the drain -finishes. - -Whether a bench is still provisioning is asked of the bench, never read -off an error message: a bench still deploying is someone waiting, not -someone broken. Any surface that still has to wait shows that progress -rather than a static label. +Connecting hands the person forward immediately, and the wait on the +other side is as short as it can honestly be: the setup agent (Myra) +leads `DEFAULT_WORKFLOWS`, so she is deployed before anything else, and +the land hop drops the person into her room the moment she can answer. +The digest, recurring-task and research seeds finish behind them — +nobody needs a scheduled digest in their first second. + +While she is still coming up, the shared warm loader is the whole +screen: one honest headline and a rotating tip, never an internal count. +How many workflows a bench seeds is an implementation detail, and "0 of +5 ready" told a waiting person nothing they could act on. A wait that +runs past a sensible interval says so plainly and offers another go, +rather than showing an unchanging number. + +Once the person has landed, anything still deploying is at most a single +dismissible line — never a screen that blocks them. + +Whether a bench can be started is asked of the bench, never read off an +error message: `GET /api/onboarding/provisioning-status` answers with +`setupAgentReady`, which is the only field a waiting surface should +branch on. `deployed`/`pending` stay in the response for operators and +logs. diff --git a/packages/chat-ui/src/loading-state.tsx b/packages/chat-ui/src/loading-state.tsx index 4f1e8791d..89f8a6959 100644 --- a/packages/chat-ui/src/loading-state.tsx +++ b/packages/chat-ui/src/loading-state.tsx @@ -61,7 +61,16 @@ export function WorkbenchLoadingState({ const [visible, setVisible] = useState(delayMs <= 0); useEffect(() => { - if (delayMs <= 0) return; + // A surface that swaps its own `delayMs` — a route that starts out + // "still reading" (delayed) and becomes "known to be waiting" + // (immediate) — reconciles onto this same element rather than + // remounting it, so dropping to 0 has to show the loader outright. + // Returning early here instead left the loader hidden for good and + // rendered the wait as a blank page (CL-6462). + if (delayMs <= 0) { + setVisible(true); + return; + } const id = setTimeout(() => setVisible(true), delayMs); return () => clearTimeout(id); }, [delayMs]); diff --git a/packages/chat-ui/test/loading-state.test.tsx b/packages/chat-ui/test/loading-state.test.tsx index c0a1fcc4d..fd544ccc7 100644 --- a/packages/chat-ui/test/loading-state.test.tsx +++ b/packages/chat-ui/test/loading-state.test.tsx @@ -56,6 +56,22 @@ describe("WorkbenchLoadingState", () => { expect(el.textContent).toContain("Loading routines…"); }); + test("a delayed loader that becomes immediate shows at once, never a blank page", () => { + // CL-6462: the land route mounts this while it is still reading (a + // delayed loader), then switches to an immediate one once it knows + // it is waiting on something. React reconciles rather than remounts, + // so the drop to 0 has to be honoured — a loader stuck invisible is + // what made the post-connect wait render as an empty screen. + const el = mount(); + expect(el.querySelector(".chat-workbench-loading")).toBeNull(); + + act(() => { + root?.render(); + }); + + expect(el.querySelector(".chat-workbench-loading")).not.toBeNull(); + }); + test("renders nothing until the delay elapses (flash prevention)", async () => { const el = mount(); diff --git a/packages/hub-client/src/index.ts b/packages/hub-client/src/index.ts index dfc5e513c..aeef4744a 100644 --- a/packages/hub-client/src/index.ts +++ b/packages/hub-client/src/index.ts @@ -42,6 +42,7 @@ export { seedCatalog, seedTenant, isLiveDeploymentStatus, + SETUP_AGENT_ASSET_NAME, } from "./seed"; export { CATALOG_SEEDS, diff --git a/packages/hub-client/src/seed.ts b/packages/hub-client/src/seed.ts index 6cd87256a..f91b1f931 100644 --- a/packages/hub-client/src/seed.ts +++ b/packages/hub-client/src/seed.ts @@ -211,8 +211,17 @@ function catalogAutomatable(assetName: string): boolean { } /** - * The workflow set every real tenant starts with: the echo - * walking-skeleton, the general-purpose assistant, and the workbench-digest + * The asset name of the agent a person actually talks to on a brand-new + * bench — Myra, the setup agent. Named here because deploy ORDER depends + * on it (see `DEFAULT_WORKFLOWS`) and because every surface that asks + * "can this person start yet?" answers by looking for this one asset, + * never by counting the whole set. + */ +export const SETUP_AGENT_ASSET_NAME = "assistant"; + +/** + * The workflow set every real tenant starts with: the general-purpose + * assistant, the echo walking-skeleton, and the workbench-digest * automation the Routines picker can honestly offer. This is what * `provisionPersonalTenantIfNeeded` (`@workbench/onboarding`) deploys * on first login for every real user — growing it is adding an entry @@ -220,38 +229,45 @@ function catalogAutomatable(assetName: string): boolean { * never the place for a workflow that exists only to exercise the * platform itself. See `CATALOG_TEST_WORKFLOWS` for those. * + * Order is a product decision, not a formality (CL-6462): `seedTenant` + * deploys this array in sequence at roughly 20s each, and the setup + * agent is the only entry a person needs before they can start talking. + * It goes first so a fresh signup lands in a working conversation in + * seconds while the rest converge behind them; a signup that waited on + * the whole set stared at a progress screen for minutes. + * * workbench-digest is the seed automation: schedulable, not a chat host, * friendly display name. It uses the tenant's real model so a scheduled * run can produce a real digest line. */ export const DEFAULT_WORKFLOWS: readonly DefaultWorkflow[] = [ { - assetName: "echo", - displayName: catalogDisplayName("echo"), - automatable: catalogAutomatable("echo"), + assetName: SETUP_AGENT_ASSET_NAME, + displayName: catalogDisplayName(SETUP_AGENT_ASSET_NAME), + automatable: catalogAutomatable(SETUP_AGENT_ASSET_NAME), buildJson: (tenantDomain, model) => - serializeEchoWorkflow( - buildEchoWorkflow({ - triggerAddress: `echo@${tenantDomain}`, + serializeAssistantWorkflow( + buildAssistantWorkflow({ + triggerAddress: `${SETUP_AGENT_ASSET_NAME}@${tenantDomain}`, inferencePreferences: [ { provider: model.provider, model: model.model }, ], - turnTimeoutMs: ECHO_TURN_TIMEOUT_MS, + turnTimeoutMs: ASSISTANT_TURN_TIMEOUT_MS, }), ), }, { - assetName: "assistant", - displayName: catalogDisplayName("assistant"), - automatable: catalogAutomatable("assistant"), + assetName: "echo", + displayName: catalogDisplayName("echo"), + automatable: catalogAutomatable("echo"), buildJson: (tenantDomain, model) => - serializeAssistantWorkflow( - buildAssistantWorkflow({ - triggerAddress: `assistant@${tenantDomain}`, + serializeEchoWorkflow( + buildEchoWorkflow({ + triggerAddress: `echo@${tenantDomain}`, inferencePreferences: [ { provider: model.provider, model: model.model }, ], - turnTimeoutMs: ASSISTANT_TURN_TIMEOUT_MS, + turnTimeoutMs: ECHO_TURN_TIMEOUT_MS, }), ), }, diff --git a/packages/hub-client/test/seed.test.ts b/packages/hub-client/test/seed.test.ts index e359a2b74..de7e6d665 100644 --- a/packages/hub-client/test/seed.test.ts +++ b/packages/hub-client/test/seed.test.ts @@ -6,6 +6,7 @@ import { NOOP_MODEL_SOURCE, seedCatalog, seedTenant, + SETUP_AGENT_ASSET_NAME, type SeedTenantArgs, type WorkflowPusher, } from "../src/seed"; @@ -549,13 +550,16 @@ describe("seedTenant", () => { ); }); - test("the default set is non-empty and starts with the echo workflow", () => { + test("the default set is non-empty and leads with the setup agent", () => { + // CL-6462: the one agent a person talks to deploys before anything + // else, so a fresh signup can start the moment she is live instead + // of waiting out the whole set. expect(DEFAULT_WORKFLOWS.length).toBeGreaterThan(0); - expect(DEFAULT_WORKFLOWS[0]?.assetName).toBe("echo"); + expect(DEFAULT_WORKFLOWS[0]?.assetName).toBe(SETUP_AGENT_ASSET_NAME); }); - test("the default set also includes the assistant workflow", () => { - expect(DEFAULT_WORKFLOWS.map((w) => w.assetName)).toContain("assistant"); + test("the default set also includes the echo walking skeleton", () => { + expect(DEFAULT_WORKFLOWS.map((w) => w.assetName)).toContain("echo"); }); test("the seeded assistant is productized under the Myra display name", () => { @@ -588,7 +592,7 @@ describe("seedTenant", () => { } }); - test("the default set consumed by real tenant provisioning is echo, assistant, workbench-digest, recurring-task, and last-30-days-research", () => { + test("the default set consumed by real tenant provisioning is assistant, echo, workbench-digest, recurring-task, and last-30-days-research", () => { // provisionPersonalTenantIfNeeded (@workbench/onboarding) deploys // DEFAULT_WORKFLOWS for every real signup. workbench-digest is the // seed automation the Routines picker can honestly offer; @@ -602,8 +606,8 @@ describe("seedTenant", () => { // reach a real user through this array — they are seeded only via // the explicit CATALOG_TEST_WORKFLOWS opt-in. expect(DEFAULT_WORKFLOWS.map((w) => w.assetName)).toEqual([ - "echo", "assistant", + "echo", "workbench-digest", "recurring-task", "last-30-days-research", @@ -909,6 +913,12 @@ describe("seedTenant", () => { ); expect(pushes).toHaveLength(DEFAULT_WORKFLOWS.length); + // CL-6462: deploy order is the product decision — the setup agent is + // pushed and deployed before any other seeded workflow, so someone + // who just connected can start talking while the rest converge. + expect(pushes[0]?.remoteUrl).toContain( + `/assets/workflow/${SETUP_AGENT_ASSET_NAME}.git`, + ); const output = lines.join("\n"); for (const workflow of DEFAULT_WORKFLOWS) { expect(output).not.toContain(`confirmed workflow ${workflow.assetName}`); diff --git a/packages/onboarding/src/routes.ts b/packages/onboarding/src/routes.ts index 6e6490fa6..075f737ae 100644 --- a/packages/onboarding/src/routes.ts +++ b/packages/onboarding/src/routes.ts @@ -13,6 +13,7 @@ import { createHubAPI, inferenceCredentialName, parseAs, + SETUP_AGENT_ASSET_NAME, supportedCredentialProviders, type ApiCall, type ModelSource, @@ -189,13 +190,19 @@ export type CreateOnboardingRoutesDeps = { * bench's agents actually are, read from the bench's own asset and * deployment state rather than from anything a caller remembers. `ready` * means every default workflow is live; `provisioning` means the drain - * still has work to do, and `deployed`/`pending` are what a waiting - * surface renders as live progress instead of a static label. + * still has work to do. + * + * `setupAgentReady` is the only field a waiting surface should ever + * branch on (CL-6462): it says whether the one agent a person talks to + * is live, which is the real "can they start?" question. `deployed` and + * `pending` stay for operators and logs — a count of seed workflows is + * an implementation detail no person should be made to watch. */ type ProvisioningStatusBody = { readonly kind: "ready" | "provisioning"; readonly tenantId: string; readonly tenantSlug: string; + readonly setupAgentReady: boolean; readonly deployed: string[]; readonly pending: string[]; }; @@ -298,6 +305,7 @@ export function createOnboardingRoutes( kind: pending.length === 0 ? "ready" : "provisioning", tenantId: tenant.tenantId, tenantSlug: tenant.tenantSlug, + setupAgentReady: deployed.includes(SETUP_AGENT_ASSET_NAME), deployed, pending, }; diff --git a/packages/onboarding/test/complete-credential.test.ts b/packages/onboarding/test/complete-credential.test.ts index 460c4a2a4..e264ab886 100644 --- a/packages/onboarding/test/complete-credential.test.ts +++ b/packages/onboarding/test/complete-credential.test.ts @@ -4,7 +4,10 @@ import type { ToolRegistryPublisher, WorkflowPusher, } from "@workbench/hub-client"; -import { SidecarUnavailableError } from "@workbench/hub-client"; +import { + SETUP_AGENT_ASSET_NAME, + SidecarUnavailableError, +} from "@workbench/hub-client"; import { completeCredentialSetup, ensureSeeded, @@ -377,8 +380,8 @@ describe("completeCredentialSetup", () => { tenantId: TENANT_ID, tenantSlug: TENANT_SLUG, workflows: [ - "echo", "assistant", + "echo", "workbench-digest", "recurring-task", "last-30-days-research", @@ -429,8 +432,8 @@ describe("completeCredentialSetup", () => { tenantId: TENANT_ID, tenantSlug: TENANT_SLUG, workflows: [ - "echo", "assistant", + "echo", "workbench-digest", "recurring-task", "last-30-days-research", @@ -481,8 +484,8 @@ describe("completeCredentialSetup", () => { tenantId: TENANT_ID, tenantSlug: TENANT_SLUG, workflows: [ - "echo", "assistant", + "echo", "workbench-digest", "recurring-task", "last-30-days-research", @@ -840,8 +843,8 @@ describe("completeCredentialSetup", () => { expect(result.kind).toBe("seeded"); if (result.kind === "seeded") { expect(result.workflows).toEqual([ - "echo", "assistant", + "echo", "workbench-digest", "recurring-task", "last-30-days-research", @@ -1393,8 +1396,8 @@ describe("completeCredentialSetup", () => { tenantDomain: "alice-user1.bench.local", deployed: [], pending: [ - "echo", "assistant", + "echo", "workbench-digest", "recurring-task", "last-30-days-research", @@ -1602,8 +1605,8 @@ describe("ensureSeeded (the slow half)", () => { expect(result).toEqual({ kind: "seeded", workflows: [ - "echo", "assistant", + "echo", "workbench-digest", "recurring-task", "last-30-days-research", @@ -1613,6 +1616,38 @@ describe("ensureSeeded (the slow half)", () => { expect(seedTenantCalls[0]?.model.provider).toBe("anthropic"); }); + test("deploys the setup agent before anything else, so a waiting person can start as soon as she is live", async () => { + // CL-6462. This is the deploy path the background drain runs + // (bench-provisioning's `runOnce` → `ensureSeeded` → `seedTenant`), + // and `seedTenant` works through the list in order at ~20s each — so + // the order handed in here is the order a fresh signup experiences. + const workflowOrder: string[] = []; + + await ensureSeeded({ + api: (async () => { + throw new Error( + "the real api must not be called — seedTenantFn is stubbed", + ); + }) as ApiCall, + cookies: ["session=abc"], + hubUrl: "http://localhost:3000", + pushWorkflow: noopPush, + publishToolRegistry: noopPublishToolRegistry, + log: collector().log, + tenant: TENANT, + provider: "anthropic", + apiKey: "sk-ant-good", + seedTenantFn: async (args) => { + for (const workflow of args.workflows ?? []) { + workflowOrder.push(workflow.assetName); + } + }, + }); + + expect(workflowOrder[0]).toBe(SETUP_AGENT_ASSET_NAME); + expect(workflowOrder.length).toBeGreaterThan(1); + }); + test("two overlapping calls for the same tenant never double-deploy — the same 409-then-list tolerance seedTenant already has", async () => { const TIMESTAMP = "2026-01-01T00:00:00.000Z"; type Row = { name: string; id: string }; @@ -1862,7 +1897,7 @@ describe("ensureSeeded (the slow half)", () => { expect(result).toEqual({ kind: "seeded-pending-agents", - deployed: ["echo", "assistant"], + deployed: ["assistant", "echo"], pending: ["workbench-digest", "recurring-task", "last-30-days-research"], message: "Your workbench is ready — agents will come online shortly.", }); diff --git a/packages/onboarding/test/complete-setup-routes.test.ts b/packages/onboarding/test/complete-setup-routes.test.ts index 1ab14f641..cebd4b129 100644 --- a/packages/onboarding/test/complete-setup-routes.test.ts +++ b/packages/onboarding/test/complete-setup-routes.test.ts @@ -301,6 +301,7 @@ describe("POST /complete-setup", () => { kind: string; tenantId: string; tenantSlug: string; + setupAgentReady: boolean; deployed: string[]; pending: string[]; }; @@ -308,6 +309,7 @@ describe("POST /complete-setup", () => { kind: "provisioning", tenantId: TENANT_ID, tenantSlug: TENANT_SLUG, + setupAgentReady: false, deployed: [], pending: DEFAULT_WORKFLOWS.map((w) => w.assetName), }); @@ -681,6 +683,7 @@ describe("POST /complete-setup", () => { kind: string; tenantId: string; tenantSlug: string; + setupAgentReady: boolean; deployed: string[]; pending: string[]; }; @@ -688,6 +691,9 @@ describe("POST /complete-setup", () => { kind: "provisioning", tenantId: TENANT_ID, tenantSlug: TENANT_SLUG, + // The one live workflow here IS the setup agent — she deploys + // first (CL-6462), so this bench is already chat-ready. + setupAgentReady: true, deployed: [liveWorkflow.assetName], pending: DEFAULT_WORKFLOWS.slice(1).map((w) => w.assetName), }); diff --git a/packages/onboarding/test/connect-deploys-nothing.test.ts b/packages/onboarding/test/connect-deploys-nothing.test.ts index 95186772f..4f98d3565 100644 --- a/packages/onboarding/test/connect-deploys-nothing.test.ts +++ b/packages/onboarding/test/connect-deploys-nothing.test.ts @@ -12,7 +12,10 @@ import type { MiddlewareHandler } from "hono"; import { Hono } from "hono"; import { createEnvKeyCredentialCipher } from "@intx/crypto"; import type { CredentialCipher } from "@intx/types"; -import { DEFAULT_WORKFLOWS } from "@workbench/hub-client"; +import { + DEFAULT_WORKFLOWS, + SETUP_AGENT_ASSET_NAME, +} from "@workbench/hub-client"; import { createOnboardingRoutes } from "../src/routes"; import { createInMemoryPendingSeedStore, @@ -309,6 +312,57 @@ describe("GET /provisioning-status", () => { } }); + test("says the setup agent is ready the moment she is live, while the rest are still pending", async () => { + // CL-6462: this one flag is what lets the land hop drop someone into + // a conversation without waiting out the whole seed set. + const { hub } = fakeHub({ seededWorkflows: [SETUP_AGENT_ASSET_NAME] }); + const server = Bun.serve({ port: 0, fetch: hub.fetch }); + const store = createInMemoryPendingSeedStore(testCipher()); + try { + const app = mountAuthenticated( + createOnboardingRoutes( + routeDeps({ hubUrl: `http://localhost:${server.port}`, store }), + ), + ); + + const response = await app.request("/api/onboarding/provisioning-status"); + const body = (await response.json()) as { + kind: string; + setupAgentReady: boolean; + pending: string[]; + }; + + expect(body.kind).toBe("provisioning"); + expect(body.setupAgentReady).toBe(true); + expect(body.pending.length).toBeGreaterThan(0); + } finally { + server.stop(true); + } + }); + + test("a bench whose other workflows landed first is not reported as chat-ready", async () => { + const others = ALL_WORKFLOWS.filter( + (name) => name !== SETUP_AGENT_ASSET_NAME, + ); + const { hub } = fakeHub({ seededWorkflows: others }); + const server = Bun.serve({ port: 0, fetch: hub.fetch }); + const store = createInMemoryPendingSeedStore(testCipher()); + try { + const app = mountAuthenticated( + createOnboardingRoutes( + routeDeps({ hubUrl: `http://localhost:${server.port}`, store }), + ), + ); + + const response = await app.request("/api/onboarding/provisioning-status"); + const body = (await response.json()) as { setupAgentReady: boolean }; + + expect(body.setupAgentReady).toBe(false); + } finally { + server.stop(true); + } + }); + test("reports ready once every agent is live", async () => { const { hub } = fakeHub({ seededWorkflows: ALL_WORKFLOWS }); const server = Bun.serve({ port: 0, fetch: hub.fetch });