diff --git a/apps/web/src/app.css b/apps/web/src/app.css
index 84fe4767f2..2fd9883ad7 100644
--- a/apps/web/src/app.css
+++ b/apps/web/src/app.css
@@ -3356,21 +3356,23 @@ tr.insights-row-clickable:hover {
selection rail (`box-shadow: inset 2px 0 0 0 var(--shell-accent)`)
matches the sidebar's own active-row treatment exactly. */
.new-workbench-picker {
- max-width: 40rem;
+ width: min(60rem, 100%);
margin: 0 auto;
- padding: 3.25rem 1.5rem 2.5rem;
+ padding: 3.5rem 1.5rem 3rem;
}
.new-workbench-picker h3 {
margin: 0;
- font-size: 1.375rem;
+ font-size: 2.5rem;
font-weight: 700;
- letter-spacing: -0.02em;
+ letter-spacing: -0.035em;
+ line-height: 1.15;
}
.new-workbench-picker-sub {
- margin: 0.3rem 0 1.5rem;
- font-size: 0.875rem;
+ margin: 0.5rem 0 1.75rem;
+ font-size: 1rem;
+ line-height: 1.5;
color: var(--muted-foreground);
}
@@ -3390,13 +3392,15 @@ tr.insights-row-clickable:hover {
send button inline, sized to read as "type here first". */
.new-workbench-prompt {
display: flex;
- align-items: flex-end;
- gap: 0.6rem;
+ flex-direction: column;
+ align-items: stretch;
border: 1px solid var(--border-strong, var(--border));
- border-radius: 0.6rem;
- padding: 0.7rem 0.7rem 0.7rem 1rem;
- margin-bottom: 1.5rem;
+ border-radius: 0.875rem;
+ margin-bottom: 2.5rem;
background: var(--background);
+ box-shadow:
+ 0 1px 2px rgb(43 38 39 / 5%),
+ 0 20px 40px -24px rgb(43 38 39 / 22%);
}
.new-workbench-prompt:has(.new-workbench-prompt-input:focus) {
@@ -3405,43 +3409,230 @@ tr.insights-row-clickable:hover {
}
.new-workbench-prompt-input {
- flex: 1;
+ width: 100%;
min-width: 0;
resize: none;
border: 0;
background: transparent;
+ caret-color: var(--shell-accent);
font: inherit;
- font-size: 0.9375rem;
- line-height: 1.4;
- padding: 0.2rem 0;
+ font-size: 1.25rem;
+ line-height: 1.45;
+ padding: 0;
}
.new-workbench-prompt-input:focus {
outline: none;
}
-.new-workbench-prefab-grid {
+.new-workbench-prompt-main {
+ display: flex;
+ flex-direction: column;
+ gap: 1.25rem;
+ min-height: 10.5rem;
+ padding: 1.375rem 1.5rem 1rem;
+}
+
+.new-workbench-prompt-examples,
+.new-workbench-agent-selection,
+.new-workbench-prompt-actions {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+}
+
+.new-workbench-prompt-examples {
+ gap: 0.5rem;
+ font-size: 0.8125rem;
+ color: var(--muted-foreground);
+}
+
+.new-workbench-prompt-examples button,
+.new-workbench-add-agent {
+ border: 1px solid var(--border);
+ border-radius: 999px;
+ background: var(--muted);
+ color: var(--foreground);
+ font: inherit;
+ font-size: 0.8125rem;
+ line-height: 1rem;
+ padding: 0.375rem 0.75rem;
+ cursor: pointer;
+}
+
+.new-workbench-prompt-examples button:hover {
+ border-color: var(--border-strong, var(--border));
+ background: color-mix(in srgb, var(--muted) 72%, var(--foreground));
+}
+
+.new-workbench-add-agent:hover,
+.new-workbench-add-agent[aria-expanded="true"] {
+ border-color: var(--border-strong, var(--border));
+ background: var(--background);
+}
+
+.new-workbench-prompt-examples button.is-selected,
+.new-workbench-prompt-examples button.is-selected:hover {
+ border-color: var(--foreground);
+ background: var(--foreground);
+ color: var(--background);
+}
+
+.new-workbench-prompt-actions {
+ justify-content: space-between;
+ gap: 1rem;
+ border-top: 1px solid var(--border);
+ padding: 0.75rem;
+}
+
+.new-workbench-agent-selection {
+ position: relative;
+ flex: 1 1 30rem;
+ gap: 0.5rem;
+ min-width: 0;
+ font-size: 0.8125rem;
+ font-weight: 600;
+ color: var(--muted-foreground);
+}
+
+.new-workbench-agent-chip {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.375rem;
+ border-radius: 999px;
+ background: var(--foreground);
+ color: var(--background);
+ padding: 0.3rem 0.45rem 0.3rem 0.625rem;
+}
+
+.new-workbench-agent-chip button {
+ width: 1rem;
+ height: 1rem;
+ border: 0;
+ border-radius: 50%;
+ background: transparent;
+ color: inherit;
+ font: inherit;
+ font-size: 1rem;
+ line-height: 1;
+ cursor: pointer;
+}
+
+.new-workbench-agent-status {
+ font-weight: 400;
+}
+
+.new-workbench-agent-picker {
+ position: relative;
+}
+
+.new-workbench-add-agent {
+ border-style: dashed;
+ background: var(--background);
+ font-weight: 600;
+}
+
+.new-workbench-agent-popover {
+ position: absolute;
+ z-index: 2;
+ top: calc(100% + 0.5rem);
+ left: 0;
+ width: min(21.25rem, calc(100vw - 3rem));
+ border: 1px solid var(--border);
+ border-radius: 0.75rem;
+ background: var(--background);
+ box-shadow: 0 20px 40px -16px rgb(43 38 39 / 28%);
+ padding: 0.5rem;
+}
+
+.new-workbench-agent-search {
+ width: 100%;
+ border: 0;
+ border-bottom: 1px solid var(--border);
+ background: transparent;
+ color: var(--foreground);
+ font: inherit;
+ font-size: 0.875rem;
+ padding: 0.5rem;
+}
+
+.new-workbench-agent-search:focus {
+ outline: none;
+}
+
+.new-workbench-agent-option {
display: grid;
- grid-template-columns: repeat(3, minmax(0, 1fr));
- gap: 0.75rem;
+ grid-template-columns: minmax(0, 1fr) auto auto;
+ align-items: center;
+ width: 100%;
+ gap: 0.5rem;
+ border: 0;
+ border-radius: 0.5rem;
+ background: transparent;
+ color: var(--foreground);
+ font: inherit;
+ text-align: left;
+ padding: 0.625rem;
+ cursor: pointer;
}
-@media (max-width: 640px) {
- .new-workbench-prefab-grid {
- grid-template-columns: 1fr;
- }
+.new-workbench-agent-option.is-active,
+.new-workbench-agent-option:hover {
+ background: var(--muted);
}
-.new-workbench-prefab-card {
+.new-workbench-agent-option-name {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-size: 0.875rem;
+ font-weight: 600;
+}
+
+.new-workbench-agent-option-handle,
+.new-workbench-agent-empty {
+ margin: 0;
+ color: var(--muted-foreground);
+ font-size: 0.75rem;
+ font-weight: 400;
+}
+
+.new-workbench-agent-empty {
+ padding: 0.75rem 0.5rem;
+}
+
+.new-workbench-prefab-grid {
display: flex;
flex-direction: column;
- align-items: flex-start;
- gap: 0.6rem;
+ gap: 0.5rem;
+}
+
+.new-workbench-template-heading {
+ display: flex;
+ justify-content: space-between;
+ gap: 1rem;
+ margin-bottom: 0.625rem;
+ padding: 0 0.25rem;
+ color: var(--muted-foreground);
+ font-size: 0.8125rem;
+}
+
+.new-workbench-template-heading span:first-child {
+ color: var(--foreground);
+ font-weight: 650;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+}
+
+.new-workbench-prefab-card {
+ display: flex;
+ align-items: center;
+ gap: 0.875rem;
text-align: left;
border: 1px solid var(--border);
- border-radius: 0.5rem;
+ border-radius: 0.625rem;
background: var(--background);
- padding: 0.9rem;
+ padding: 0.75rem 0.875rem;
font: inherit;
cursor: pointer;
}
@@ -3462,8 +3653,8 @@ tr.insights-row-clickable:hover {
}
.new-workbench-pick-glyph {
- width: 2.1rem;
- height: 2.1rem;
+ width: 2.125rem;
+ height: 2.125rem;
flex-shrink: 0;
display: grid;
place-items: center;
@@ -3472,6 +3663,7 @@ tr.insights-row-clickable:hover {
}
.new-workbench-pick-text {
+ flex: 1;
min-width: 0;
}
@@ -3484,11 +3676,56 @@ tr.insights-row-clickable:hover {
.new-workbench-pick-promise {
display: block;
- font-size: 0.8125rem;
+ font-size: 0.875rem;
color: var(--muted-foreground);
margin-top: 0.1rem;
}
+.new-workbench-empty-channel {
+ border: 0;
+ background: transparent;
+ color: var(--muted-foreground);
+ font: inherit;
+ font-size: 0.875rem;
+ margin-top: 1rem;
+ padding: 0.25rem;
+ text-align: left;
+ cursor: pointer;
+}
+
+.new-workbench-empty-channel span {
+ color: var(--foreground);
+ font-weight: 600;
+ text-decoration: underline;
+ text-underline-offset: 0.2rem;
+}
+
+.new-workbench-empty-channel:disabled,
+.new-workbench-add-agent:disabled {
+ cursor: not-allowed;
+ opacity: 0.6;
+}
+
+@media (max-width: 640px) {
+ .new-workbench-picker {
+ padding-top: 2rem;
+ }
+
+ .new-workbench-picker h3 {
+ font-size: 2rem;
+ }
+
+ .new-workbench-prompt-actions,
+ .new-workbench-template-heading {
+ align-items: flex-start;
+ flex-direction: column;
+ }
+
+ .new-workbench-agent-selection {
+ flex-basis: auto;
+ }
+}
+
/* Mission Control (CL-6488/CL-6489) — stat strip, then a main column
(Needs you / In flight) beside a 320px rail (Jump back in / This week),
the same "stack under 1100px" rule DESIGN.md gives every right rail. */
diff --git a/apps/web/src/instant-agent-create.test.ts b/apps/web/src/instant-agent-create.test.ts
index 8d2044478f..0b8918d65f 100644
--- a/apps/web/src/instant-agent-create.test.ts
+++ b/apps/web/src/instant-agent-create.test.ts
@@ -1,4 +1,4 @@
-import { afterEach, describe, expect, test } from "bun:test";
+import { afterEach, describe, expect, spyOn, test } from "bun:test";
import { QueryClient } from "@tanstack/react-query";
import {
CODE_REVIEW_TEMPLATE,
@@ -8,6 +8,7 @@ import {
import {
createWorkbenchFromTemplate,
NEW_WORKBENCH_TITLE,
+ WorkbenchPostCreateError,
} from "./instant-agent-create";
function newQueryClient(): QueryClient {
@@ -146,6 +147,172 @@ describe("createWorkbenchFromTemplate", () => {
expect(calls.some((call) => call.path.includes("/invite"))).toBe(false);
});
+ test("a blank intent carries selected agents in its pre-invite message", async () => {
+ const calls = stubFetch((path) => {
+ if (path.includes("/workflows/definitions")) {
+ return json({ data: [assistantDefinitionWire], nextCursor: null });
+ }
+ if (path.endsWith("/chat/workbenches")) {
+ return json({
+ id: "chan-1",
+ title: NEW_WORKBENCH_TITLE,
+ kind: "workbench",
+ pinned: false,
+ participants: [],
+ });
+ }
+ if (path.endsWith("/chat/workbenches/chan-1/messages")) {
+ return json({ id: "msg-1", createdAt: "2026-01-01T00:00:00.000Z" });
+ }
+ if (path.endsWith("/chat/workbenches/chan-1/settings")) {
+ return json({
+ id: "chan-1",
+ title: "Research our next partner",
+ kind: "workbench",
+ pinned: false,
+ participants: [],
+ settings: {},
+ contextWindow: { value: 0, source: "inherit" },
+ });
+ }
+ throw new Error(`unexpected fetch: ${path}`);
+ });
+
+ await createWorkbenchFromTemplate(
+ "tnt_1",
+ "blank",
+ () => undefined,
+ newQueryClient(),
+ "Research our next partner",
+ ["def-scout", "def-scout", "def-quill"],
+ );
+
+ const createCallIndex = calls.findIndex((call) =>
+ call.path.endsWith("/chat/workbenches"),
+ );
+ const messageCallIndex = calls.findIndex((call) =>
+ call.path.endsWith("/chat/workbenches/chan-1/messages"),
+ );
+ const messageCall = calls[messageCallIndex];
+ expect(createCallIndex).toBeLessThan(messageCallIndex);
+ expect(JSON.parse(String(messageCall?.init?.body))).toEqual({
+ parts: [{ kind: "text", text: "Research our next partner" }],
+ invite: [
+ { kind: "agent", definitionId: "def-scout" },
+ { kind: "agent", definitionId: "def-quill" },
+ ],
+ });
+ expect(calls.some((call) => call.path.endsWith("/invite"))).toBe(false);
+ });
+
+ test("opens the new room while its sidebar refresh is still in flight", async () => {
+ const navigated: string[] = [];
+ let finishRefresh: (() => void) | undefined;
+ const refresh = new Promise((resolve) => {
+ finishRefresh = resolve;
+ });
+ const queryClient = newQueryClient();
+ const invalidate = spyOn(
+ queryClient,
+ "invalidateQueries",
+ ).mockImplementation(() => refresh);
+ stubFetch((path) => {
+ if (path.includes("/workflows/definitions")) {
+ return json({ data: [assistantDefinitionWire], nextCursor: null });
+ }
+ if (path.endsWith("/chat/workbenches")) {
+ return json({
+ id: "chan-ready-now",
+ title: NEW_WORKBENCH_TITLE,
+ kind: "workbench",
+ pinned: false,
+ participants: [],
+ });
+ }
+ if (path.endsWith("/chat/workbenches/chan-ready-now/messages")) {
+ return json({ id: "msg-1", createdAt: "2026-01-01T00:00:00.000Z" });
+ }
+ if (path.endsWith("/chat/workbenches/chan-ready-now/settings")) {
+ return json({
+ id: "chan-ready-now",
+ title: "Ship the release",
+ kind: "workbench",
+ pinned: false,
+ participants: [],
+ settings: {},
+ contextWindow: { value: 0, source: "inherit" },
+ });
+ }
+ throw new Error(`unexpected fetch: ${path}`);
+ });
+
+ try {
+ const completed = await Promise.race([
+ createWorkbenchFromTemplate(
+ "tnt_1",
+ "blank",
+ (to) => navigated.push(to),
+ queryClient,
+ "Ship the release",
+ ["def-assistant"],
+ ).then(() => "completed"),
+ new Promise<"timed out">((resolve) => {
+ setTimeout(() => resolve("timed out"), 50);
+ }),
+ ]);
+
+ expect(completed).toBe("completed");
+ expect(navigated).toEqual(["/w/chan-ready-now"]);
+ } finally {
+ finishRefresh?.();
+ invalidate.mockRestore();
+ }
+ });
+
+ test("an opening-message failure leaves a minted blank workbench recoverable", async () => {
+ const navigated: string[] = [];
+ stubFetch((path) => {
+ if (path.includes("/workflows/definitions")) {
+ return json({ data: [assistantDefinitionWire], nextCursor: null });
+ }
+ if (path.endsWith("/chat/workbenches")) {
+ return json({
+ id: "chan-recoverable",
+ title: NEW_WORKBENCH_TITLE,
+ kind: "workbench",
+ pinned: false,
+ participants: [],
+ });
+ }
+ if (path.endsWith("/chat/workbenches/chan-recoverable/messages")) {
+ return json({ error: "agent launch failed" }, 409);
+ }
+ throw new Error(`unexpected fetch: ${path}`);
+ });
+
+ let cause: unknown;
+ try {
+ await createWorkbenchFromTemplate(
+ "tnt_1",
+ "blank",
+ (to) => navigated.push(to),
+ newQueryClient(),
+ "Research our next partner",
+ ["def-scout"],
+ );
+ } catch (error) {
+ cause = error;
+ }
+
+ expect(cause).toBeInstanceOf(WorkbenchPostCreateError);
+ if (!(cause instanceof WorkbenchPostCreateError)) {
+ throw new Error("expected a recoverable post-create error");
+ }
+ expect(cause.workbenchId).toBe("chan-recoverable");
+ expect(cause.stage).toBe("opening-message");
+ expect(navigated).toEqual([]);
+ });
+
test("picking the code-review definition names the bench after it and invites exactly its three reviewers", async () => {
const navigated: string[] = [];
let nextReviewerId = 0;
diff --git a/apps/web/src/instant-agent-create.ts b/apps/web/src/instant-agent-create.ts
index c6152d07fd..054bcc71a5 100644
--- a/apps/web/src/instant-agent-create.ts
+++ b/apps/web/src/instant-agent-create.ts
@@ -26,6 +26,7 @@ import {
type WorkbenchDefinition,
type WorkbenchOnboardingStep,
} from "@workbench/templates";
+import { reportError } from "@corbits/error-sink";
import {
deployWorkbenchTemplateBlock,
@@ -44,6 +45,24 @@ import type { WorkbenchTemplateId } from "./workbench-templates";
export { NEW_WORKBENCH_TITLE };
+export function refreshWorkbenchLists(
+ queryClient: QueryClient,
+ tenantId: string,
+ workbenchId: string,
+): void {
+ void queryClient
+ .invalidateQueries({
+ queryKey: workbenchesQueryKeyPrefix(tenantId),
+ })
+ .catch((cause) => {
+ reportError(cause, {
+ operation: "refresh_workbench_lists",
+ tenantId,
+ roomId: workbenchId,
+ });
+ });
+}
+
/**
* Marks the two precondition failures below as intentionally
* user-facing: their `message` is authored copy, never a raw request
@@ -73,6 +92,16 @@ export class WorkbenchPreconditionError extends Error {
}
}
+export class WorkbenchPostCreateError extends Error {
+ constructor(
+ readonly workbenchId: string,
+ readonly stage: "opening-message" | "rename",
+ cause: unknown,
+ ) {
+ super("The workbench was created, but setup did not finish.", { cause });
+ }
+}
+
/**
* Consumer-language stand-in for the system precondition this bench
* hit: "no deployed setup agent" describes an internal implementation
@@ -156,7 +185,11 @@ export async function createWorkbenchFromTemplate(
navigate: (to: string) => void,
queryClient: QueryClient,
firstMessage?: string,
+ selectedAgentDefinitionIds: readonly string[] = [],
): Promise {
+ if (templateId !== "blank" && selectedAgentDefinitionIds.length > 0) {
+ throw new Error("Only a new blank workbench can select agents directly.");
+ }
const definitions = await listAgentDefinitions(tenantId);
const setupTemplate = findMyraDefinition(definitions);
if (setupTemplate === undefined) {
@@ -223,27 +256,53 @@ export async function createWorkbenchFromTemplate(
);
},
});
- await queryClient.invalidateQueries({
- queryKey: workbenchesQueryKeyPrefix(tenantId),
- });
}
if (firstMessage !== undefined && firstMessage.trim() !== "") {
- await sendMessage(tenantId, workbench.id, partsForSend(firstMessage, []));
+ const selectedAgentInvites = [...new Set(selectedAgentDefinitionIds)].map(
+ (definitionId) => ({ kind: "agent" as const, definitionId }),
+ );
+ try {
+ await sendMessage(
+ tenantId,
+ workbench.id,
+ partsForSend(firstMessage, []),
+ {
+ ...(selectedAgentInvites.length > 0
+ ? { invite: selectedAgentInvites }
+ : {}),
+ },
+ );
+ } catch (cause) {
+ if (templateId === "blank") {
+ throw new WorkbenchPostCreateError(
+ workbench.id,
+ "opening-message",
+ cause,
+ );
+ }
+ throw cause;
+ }
// Blank / ad-hoc mints stay "New Workbench" until named. When the
// prompt box already supplied the opening message, rename via the same
// `chat/name` settings PATCH the sidebar rename uses — prefab titles
// (`definition?.title`) are left alone by `autoNameFromFirstMessage`.
const autoTitle = autoNameFromFirstMessage(workbench.title, firstMessage);
if (autoTitle !== undefined) {
- await patchWorkbenchSettings(tenantId, workbench.id, {
- "chat/name": autoTitle,
- });
- await queryClient.invalidateQueries({
- queryKey: workbenchesQueryKeyPrefix(tenantId),
- });
+ try {
+ await patchWorkbenchSettings(tenantId, workbench.id, {
+ "chat/name": autoTitle,
+ });
+ } catch (cause) {
+ if (templateId === "blank") {
+ throw new WorkbenchPostCreateError(workbench.id, "rename", cause);
+ }
+ throw cause;
+ }
+ refreshWorkbenchLists(queryClient, tenantId, workbench.id);
}
}
+ refreshWorkbenchLists(queryClient, tenantId, workbench.id);
navigate(workbenchPath(workbench.id));
}
diff --git a/apps/web/src/pages/new-workbench-picker.test.ts b/apps/web/src/pages/new-workbench-picker.test.ts
index bfae8323fb..5217c1374d 100644
--- a/apps/web/src/pages/new-workbench-picker.test.ts
+++ b/apps/web/src/pages/new-workbench-picker.test.ts
@@ -15,11 +15,29 @@ import { ApiQueryError } from "@corbits/api-query";
import { ChatApiError } from "@corbits/chat-ui";
import { describeWorkbenchCreateFailure } from "./new-workbench-picker";
-import { WorkbenchPreconditionError } from "../instant-agent-create";
+import {
+ WorkbenchPostCreateError,
+ WorkbenchPreconditionError,
+} from "../instant-agent-create";
const GENERIC = "Something went wrong creating this workbench. Try again.";
describe("describeWorkbenchCreateFailure", () => {
+ test("a recoverable post-create failure gives the person its support reference", () => {
+ expect(
+ describeWorkbenchCreateFailure(
+ new WorkbenchPostCreateError(
+ "chan_1",
+ "opening-message",
+ new Error("agent launch failed"),
+ ),
+ "ref_1",
+ ),
+ ).toBe(
+ "Workbench created, but we couldn't send the opening message or add the selected agent. Try again from the room. Reference: ref_1",
+ );
+ });
+
test("a WorkbenchPreconditionError (no setup agent, unavailable template) is shown verbatim", () => {
expect(
describeWorkbenchCreateFailure(
diff --git a/apps/web/src/pages/new-workbench-picker.tsx b/apps/web/src/pages/new-workbench-picker.tsx
index a0e62a5ed1..a22fc70133 100644
--- a/apps/web/src/pages/new-workbench-picker.tsx
+++ b/apps/web/src/pages/new-workbench-picker.tsx
@@ -9,6 +9,7 @@
// second step anywhere on this screen.
import { Button, toast } from "@corbits/react-ui";
+import { useDismissablePopover } from "@corbits/react-ui/hooks/use-dismissable-popover";
import {
ChatCircle,
GitPullRequest,
@@ -19,30 +20,33 @@ import {
ChatApiError,
CHAT_STRINGS,
describeChatError,
+ listTenantInvitableDefinitions,
WorkbenchLoadingState,
} from "@corbits/chat-ui";
+import { humanizeSlug } from "@corbits/chat/display-name";
import { useEffect, useRef, useState } from "react";
-import { useQueryClient } from "@tanstack/react-query";
-import { getLogger } from "@corbits/client-log";
+import { useQuery, useQueryClient } from "@tanstack/react-query";
import { ApiQueryError, describeApiError } from "@corbits/api-query";
+import { reportError } from "@corbits/error-sink";
import { useAPIQuery } from "../api";
import { TemplateLibraryPage } from "../workbench-templates-api";
import { useBench } from "../bench-context";
import {
createWorkbenchFromTemplate,
+ refreshWorkbenchLists,
+ WorkbenchPostCreateError,
WorkbenchPreconditionError,
} from "../instant-agent-create";
import { fetchAgentReadiness } from "../onboarding";
import { useNavigate } from "../navigation";
import { StageTopBar } from "../shell/stage-top-bar";
+import { workbenchPath } from "../workbench-path";
import {
WORKBENCH_TEMPLATES,
type WorkbenchTemplateId,
} from "../workbench-templates";
-const log = getLogger("web.new-workbench-picker");
-
const GENERIC_CREATE_FAILURE =
"Something went wrong creating this workbench. Try again.";
@@ -57,7 +61,17 @@ const GENERIC_CREATE_FAILURE =
* request paths and schema summaries in `.message` and must go through
* their own describer, never shown directly.
*/
-export function describeWorkbenchCreateFailure(cause: unknown): string {
+export function describeWorkbenchCreateFailure(
+ cause: unknown,
+ refId?: string,
+): string {
+ if (cause instanceof WorkbenchPostCreateError) {
+ const message =
+ cause.stage === "opening-message"
+ ? "Workbench created, but we couldn't send the opening message or add the selected agent. Try again from the room."
+ : "Workbench created, but we couldn't rename it.";
+ return refId === undefined ? message : `${message} Reference: ${refId}`;
+ }
if (cause instanceof WorkbenchPreconditionError) return cause.message;
if (cause instanceof ApiQueryError) {
return describeApiError(cause, "creating this workbench");
@@ -79,6 +93,17 @@ const CARD_ICON: Record = {
const BLANK_TEMPLATE_ID: WorkbenchTemplateId = "blank";
const PROMPT_PLACEHOLDER = "What do you want your Workbench to do?";
+const AGENT_LISTBOX_ID = "new-workbench-agent-listbox";
+
+function agentDisplayName({
+ name,
+ description,
+}: {
+ readonly name: string;
+ readonly description?: string;
+}): string {
+ return description ?? humanizeSlug(name);
+}
export function NewWorkbenchPickerRoute() {
const navigate = useNavigate();
@@ -91,6 +116,12 @@ export function NewWorkbenchPickerRoute() {
TemplateLibraryPage,
);
const [prompt, setPrompt] = useState("");
+ const [selectedAgentDefinitionIds, setSelectedAgentDefinitionIds] = useState<
+ readonly string[]
+ >([]);
+ const [agentPickerOpen, setAgentPickerOpen] = useState(false);
+ const [agentQuery, setAgentQuery] = useState("");
+ const [activeAgentIndex, setActiveAgentIndex] = useState(0);
const [creating, setCreating] = useState(false);
// Set only when `createWorkbenchFromTemplate` hit the missing-setup-agent
// precondition *and* a readiness check confirmed the bench genuinely
@@ -100,12 +131,32 @@ export function NewWorkbenchPickerRoute() {
// finishes, not a request in flight.
const [stillSettingUp, setStillSettingUp] = useState(false);
const promptRef = useRef(null);
+ const {
+ rootRef: agentPickerRootRef,
+ triggerRef: agentPickerTriggerRef,
+ close: dismissAgentPicker,
+ } = useDismissablePopover({
+ closeOnEscape: false,
+ open: agentPickerOpen,
+ onOpenChange: setAgentPickerOpen,
+ });
+ const invitableAgents = useQuery({
+ queryKey: ["tenant", selectedTenantId, "invitable-definitions"],
+ queryFn: () => {
+ if (selectedTenantId === null) {
+ throw new Error("An account is required to load agents.");
+ }
+ return listTenantInvitableDefinitions(selectedTenantId);
+ },
+ enabled: selectedTenantId !== null,
+ });
// The last attempted create, so "Try again" (both the still-setting-up
// dead end and a plain toast-and-retry) replays the exact same request
// rather than silently falling back to blank.
const lastAttemptRef = useRef<{
readonly templateId: WorkbenchTemplateId;
readonly firstMessage: string | undefined;
+ readonly selectedIds: readonly string[];
} | null>(null);
// The prompt box only exists in the DOM once the library read settles
@@ -119,6 +170,24 @@ export function NewWorkbenchPickerRoute() {
if (showingPrompt) promptRef.current?.focus();
}, [showingPrompt]);
+ useEffect(() => {
+ setAgentPickerOpen(false);
+ setAgentQuery("");
+ setActiveAgentIndex(0);
+ setSelectedAgentDefinitionIds([]);
+ }, [selectedTenantId]);
+
+ useEffect(() => {
+ if (invitableAgents.data === undefined) return;
+ const availableDefinitionIds = new Set(
+ invitableAgents.data.map((agent) => agent.id),
+ );
+ setSelectedAgentDefinitionIds((current) => {
+ const next = current.filter((id) => availableDefinitionIds.has(id));
+ return next.length === current.length ? current : next;
+ });
+ }, [invitableAgents.data]);
+
// What this bench's library can actually serve (CL-6458). A kind whose
// manifest the library doesn't hold is shown as not set up rather than
// offered and then dead-ended on a 404 at create time.
@@ -128,18 +197,31 @@ export function NewWorkbenchPickerRoute() {
: new Set();
const offeredTemplates = WORKBENCH_TEMPLATES.filter(
(template) =>
- template.id === BLANK_TEMPLATE_ID || servedTemplateIds.has(template.id),
+ template.id !== BLANK_TEMPLATE_ID && servedTemplateIds.has(template.id),
);
const unavailableTemplates = WORKBENCH_TEMPLATES.filter(
- (template) => !offeredTemplates.includes(template),
+ (template) =>
+ template.id !== BLANK_TEMPLATE_ID && !offeredTemplates.includes(template),
+ );
+ const filteredAgents = (invitableAgents.data ?? []).filter((agent) => {
+ const query = agentQuery.trim().toLocaleLowerCase();
+ return (
+ query === "" ||
+ agentDisplayName(agent).toLocaleLowerCase().includes(query) ||
+ agent.name.toLocaleLowerCase().includes(query)
+ );
+ });
+ const selectedAgents = (invitableAgents.data ?? []).filter((agent) =>
+ selectedAgentDefinitionIds.includes(agent.id),
);
async function handleCreate(
templateId: WorkbenchTemplateId,
firstMessage?: string,
+ selectedIds: readonly string[] = [],
) {
if (selectedTenantId === null || creating) return;
- lastAttemptRef.current = { templateId, firstMessage };
+ lastAttemptRef.current = { templateId, firstMessage, selectedIds };
setCreating(true);
setStillSettingUp(false);
try {
@@ -149,8 +231,21 @@ export function NewWorkbenchPickerRoute() {
navigate,
queryClient,
firstMessage,
+ selectedIds,
);
} catch (cause) {
+ if (cause instanceof WorkbenchPostCreateError) {
+ const refId = reportError(cause, {
+ operation: "workbench_post_create",
+ tenantId: selectedTenantId,
+ roomId: cause.workbenchId,
+ extra: { stage: cause.stage },
+ });
+ refreshWorkbenchLists(queryClient, selectedTenantId, cause.workbenchId);
+ navigate(workbenchPath(cause.workbenchId));
+ toast(describeWorkbenchCreateFailure(cause, refId));
+ return;
+ }
// The missing-setup-agent precondition reads identically whether
// this bench's default agents never finished deploying (CL-6457's
// background drain is still running, or never started without a
@@ -172,15 +267,13 @@ export function NewWorkbenchPickerRoute() {
return;
}
}
- log.error("Couldn't create the workbench", {
- message: cause instanceof Error ? cause.message : String(cause),
- status:
- cause instanceof ApiQueryError || cause instanceof ChatApiError
- ? cause.status
- : undefined,
- path: cause instanceof ApiQueryError ? cause.path : undefined,
+ // Every remaining cause is a genuinely failed create: report it with
+ // operation context and a quotable refId rather than a log line alone.
+ const refId = reportError(cause, {
+ operation: "workbench_create",
+ tenantId: selectedTenantId,
});
- toast(describeWorkbenchCreateFailure(cause));
+ toast(describeWorkbenchCreateFailure(cause, refId));
setCreating(false);
}
}
@@ -188,13 +281,37 @@ export function NewWorkbenchPickerRoute() {
function retryLastAttempt() {
const attempt = lastAttemptRef.current;
if (attempt === null) return;
- void handleCreate(attempt.templateId, attempt.firstMessage);
+ void handleCreate(
+ attempt.templateId,
+ attempt.firstMessage,
+ attempt.selectedIds,
+ );
}
function handlePromptSubmit() {
const trimmed = prompt.trim();
if (trimmed === "") return;
- void handleCreate(BLANK_TEMPLATE_ID, trimmed);
+ void handleCreate(BLANK_TEMPLATE_ID, trimmed, selectedAgentDefinitionIds);
+ }
+
+ function toggleAgent(definitionId: string) {
+ setSelectedAgentDefinitionIds((current) =>
+ current.includes(definitionId)
+ ? current.filter((id) => id !== definitionId)
+ : [...current, definitionId],
+ );
+ }
+
+ function closeAgentPicker() {
+ setAgentQuery("");
+ setActiveAgentIndex(0);
+ dismissAgentPicker();
+ }
+
+ function openAgentPicker() {
+ setAgentPickerOpen(true);
+ setAgentQuery("");
+ setActiveAgentIndex(0);
}
return (
@@ -251,28 +368,202 @@ export function NewWorkbenchPickerRoute() {
handlePromptSubmit();
}}
>
-
) : null}
+
+ Or start from a template
+ Agents are already in the room. One click opens it.
+
{offeredTemplates.map((template) => {
const Icon = CARD_ICON[template.id];
@@ -345,6 +640,15 @@ export function NewWorkbenchPickerRoute() {
);
})}
+
>
)}
diff --git a/apps/web/test/new-workbench-picker.test.tsx b/apps/web/test/new-workbench-picker.test.tsx
index 76a03a8022..70c3fe0fc8 100644
--- a/apps/web/test/new-workbench-picker.test.tsx
+++ b/apps/web/test/new-workbench-picker.test.tsx
@@ -7,7 +7,7 @@
// still instantiates its template exactly like the old "Create workbench"
// button did.
-import { afterEach, describe, expect, test } from "bun:test";
+import { afterEach, describe, expect, spyOn, test } from "bun:test";
import {
CODE_REVIEW_TEMPLATE,
DUE_DILIGENCE_TEMPLATE,
@@ -19,7 +19,10 @@ import { createRoot, type Root } from "react-dom/client";
import { BenchProvider } from "../src/bench-context";
import { NavigationProvider } from "../src/navigation";
import { NewWorkbenchPickerRoute } from "../src/pages/new-workbench-picker";
-import { TestQueryProvider } from "./test-query-provider";
+import {
+ createTestQueryClient,
+ TestQueryProvider,
+} from "./test-query-provider";
const realFetch = globalThis.fetch;
@@ -84,6 +87,16 @@ function stubFetch(
}),
);
}
+ if (path.endsWith("/chat/invitable-definitions")) {
+ return Promise.resolve(
+ json({
+ items: [
+ { id: "wfd_scout", name: "scout", description: "Scout" },
+ { id: "wfd_quill", name: "quill", description: "Quill" },
+ ],
+ }),
+ );
+ }
const response = extra(path, init);
if (response !== undefined) return Promise.resolve(response);
throw new Error(`unexpected fetch: ${path}`);
@@ -110,13 +123,14 @@ const settle = () => act(() => sleep(10));
async function renderPicker(
navigate: (to: string) => void = () => undefined,
+ queryClient = createTestQueryClient(),
): Promise {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root?.render(
-
+
@@ -166,7 +180,14 @@ function prefabCards(): HTMLButtonElement[] {
* manifest, no participants, no settings patch) — shared by every test
* exercising the prompt box's blank-plus-first-message path. */
function stubBlankCreate(
- onSendMessage?: (body: { parts: readonly { kind: string }[] }) => void,
+ onSendMessage?: (body: {
+ readonly parts: readonly { kind: string }[];
+ readonly invite?: readonly {
+ readonly kind: string;
+ readonly definitionId: string;
+ }[];
+ }) => void,
+ messageResponse?: Response,
): RecordedCall[] {
return stubFetch((path, init) => {
if (path.includes("/workflows/definitions")) {
@@ -200,9 +221,16 @@ function stubBlankCreate(
) {
const body = JSON.parse(String(init.body)) as {
parts: readonly { kind: string }[];
+ invite?: readonly {
+ readonly kind: string;
+ readonly definitionId: string;
+ }[];
};
onSendMessage?.(body);
- return json({ id: "msg_1", createdAt: "2026-01-01T00:00:00.000Z" });
+ return (
+ messageResponse ??
+ json({ id: "msg_1", createdAt: "2026-01-01T00:00:00.000Z" })
+ );
}
if (
path.endsWith("/chat/workbenches/chan_new/settings") &&
@@ -233,7 +261,7 @@ describe("NewWorkbenchPickerRoute", () => {
expect(document.activeElement).toBe(input);
});
- test("the prefab cards render below the prompt box, one click each — no radio group", async () => {
+ test("the template rows render below the prompt box, with an explicit empty channel", async () => {
stubFetch(() => undefined);
await renderPicker();
@@ -241,12 +269,9 @@ describe("NewWorkbenchPickerRoute", () => {
expect(container?.querySelector('[role="radio"]')).toBeNull();
const cards = prefabCards();
- expect(cards.length).toBe(2);
+ expect(cards.length).toBe(1);
expect(container?.textContent).toContain("Code review");
- expect(container?.textContent).toContain("Just start talking");
- expect(container?.textContent).toContain(
- "An empty channel. Nobody is hosted.",
- );
+ expect(container?.textContent).toContain("just open an empty channel");
});
// The library seeds every shipped template (`createTemplateLibrarySeeder`),
@@ -279,7 +304,7 @@ describe("NewWorkbenchPickerRoute", () => {
await renderPicker();
const cards = prefabCards();
- expect(cards.length).toBe(3);
+ expect(cards.length).toBe(2);
const dueDiligence = cards.find((card) =>
card.textContent?.includes("Due Diligence"),
);
@@ -306,8 +331,7 @@ describe("NewWorkbenchPickerRoute", () => {
await renderPicker();
const cards = prefabCards();
- expect(cards.length).toBe(1);
- expect(cards[0]?.textContent).toContain("Just start talking");
+ expect(cards.length).toBe(0);
expect(container?.textContent).toContain("Code review");
expect(container?.textContent).toContain("Not set up on this bench yet");
});
@@ -328,8 +352,7 @@ describe("NewWorkbenchPickerRoute", () => {
expect(container?.textContent).toContain(
"Couldn't load what this bench can set up",
);
- expect(prefabCards().length).toBe(1);
- expect(prefabCards()[0]?.textContent).toContain("Just start talking");
+ expect(prefabCards().length).toBe(0);
});
test("typing a goal and hitting Enter creates a blank workbench and delivers the goal as the first message", async () => {
@@ -375,6 +398,183 @@ describe("NewWorkbenchPickerRoute", () => {
]);
});
+ test("a failed opening message still opens the blank workbench", async () => {
+ stubBlankCreate(undefined, json({ error: "agent launch failed" }, 409));
+ const navigated: string[] = [];
+ await renderPicker((to) => navigated.push(to));
+
+ await act(async () => {
+ typeIntoPrompt("Research our next partner");
+ });
+ await act(async () => {
+ promptInput()?.dispatchEvent(
+ new KeyboardEvent("keydown", { key: "Enter", bubbles: true }),
+ );
+ });
+ for (let i = 0; i < 20; i++) {
+ await settle();
+ if (navigated.length > 0) break;
+ }
+
+ expect(navigated).toEqual(["/w/chan_new"]);
+ });
+
+ test("a failed sidebar refresh does not block opening a recoverable workbench", async () => {
+ stubBlankCreate(undefined, json({ error: "agent launch failed" }, 409));
+ const queryClient = createTestQueryClient();
+ const invalidate = spyOn(
+ queryClient,
+ "invalidateQueries",
+ ).mockRejectedValue(new Error("sidebar refresh failed"));
+ const navigated: string[] = [];
+ try {
+ await renderPicker((to) => navigated.push(to), queryClient);
+
+ await act(async () => {
+ typeIntoPrompt("Research our next partner");
+ });
+ await act(async () => {
+ promptInput()?.dispatchEvent(
+ new KeyboardEvent("keydown", { key: "Enter", bubbles: true }),
+ );
+ });
+ for (let i = 0; i < 20; i++) {
+ await settle();
+ if (navigated.length > 0) break;
+ }
+
+ expect(navigated).toEqual(["/w/chan_new"]);
+ } finally {
+ invalidate.mockRestore();
+ }
+ });
+
+ test("a selected agent is pre-invited with the opening message", async () => {
+ let sentInvite:
+ | readonly { readonly kind: string; readonly definitionId: string }[]
+ | undefined;
+ const calls = stubBlankCreate((body) => {
+ sentInvite = body.invite;
+ });
+ const navigated: string[] = [];
+ await renderPicker((to) => navigated.push(to));
+
+ for (let i = 0; i < 20; i++) {
+ await settle();
+ if (container?.textContent?.includes("+ Add agent")) break;
+ }
+ const addAgent = Array.from(
+ container?.querySelectorAll("button") ?? [],
+ ).find((button) => button.textContent === "+ Add agent");
+ await act(async () => {
+ addAgent?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
+ });
+ const scout = document.querySelector(
+ '[role="option"]#new-workbench-agent-wfd_scout',
+ );
+ await act(async () => {
+ scout?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
+ typeIntoPrompt("Research our next partner");
+ });
+ await act(async () => {
+ promptInput()?.dispatchEvent(
+ new KeyboardEvent("keydown", { key: "Enter", bubbles: true }),
+ );
+ });
+ for (let i = 0; i < 20; i++) {
+ await settle();
+ if (navigated.length > 0) break;
+ }
+
+ expect(sentInvite).toEqual([{ kind: "agent", definitionId: "wfd_scout" }]);
+ expect(calls.some((call) => call.path.endsWith("/invite"))).toBe(false);
+ expect(navigated).toEqual(["/w/chan_new"]);
+ });
+
+ test("Enter on a no-match agent search does not create a workbench", async () => {
+ const calls = stubBlankCreate(
+ undefined,
+ json({ error: "agent launch failed" }, 409),
+ );
+ const navigated: string[] = [];
+ await renderPicker((to) => navigated.push(to));
+ typeIntoPrompt("Research our next partner");
+
+ for (let i = 0; i < 20; i++) {
+ await settle();
+ if (container?.textContent?.includes("+ Add agent")) break;
+ }
+ const addAgent = Array.from(
+ container?.querySelectorAll("button") ?? [],
+ ).find((button) => button.textContent === "+ Add agent");
+ await act(async () => {
+ addAgent?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
+ });
+ const search = document.querySelector(
+ 'input[role="combobox"]',
+ );
+ const setter = Object.getOwnPropertyDescriptor(
+ window.HTMLInputElement.prototype,
+ "value",
+ )?.set;
+ await act(async () => {
+ setter?.call(search, "zzz-no-match");
+ search?.dispatchEvent(new Event("input", { bubbles: true }));
+ });
+ await act(async () => {
+ // Synthetic keydown does not run the browser's implicit-submission
+ // default action, so the defect shows as a missing cancellation:
+ // pre-fix, a no-match Enter left the event uncancelled and the real
+ // browser submitted the form.
+ const event = new KeyboardEvent("keydown", {
+ key: "Enter",
+ bubbles: true,
+ cancelable: true,
+ });
+ search?.dispatchEvent(event);
+ expect(event.defaultPrevented).toBe(true);
+ });
+ await settle();
+
+ expect(document.querySelector(".new-workbench-agent-empty")).not.toBeNull();
+ expect(
+ calls.some(
+ (call) =>
+ call.path.endsWith("/chat/workbenches") &&
+ call.init?.method === "POST",
+ ),
+ ).toBe(false);
+ expect(navigated).toEqual([]);
+ });
+
+ test("Escape closes the agent picker and returns focus to its trigger", async () => {
+ stubFetch(() => undefined);
+ await renderPicker();
+
+ for (let i = 0; i < 20; i++) {
+ await settle();
+ if (container?.textContent?.includes("+ Add agent")) break;
+ }
+ const addAgent = Array.from(
+ container?.querySelectorAll("button") ?? [],
+ ).find((button) => button.textContent === "+ Add agent");
+ await act(async () => {
+ addAgent?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
+ });
+ const search = document.querySelector(
+ 'input[role="combobox"]',
+ );
+ await act(async () => {
+ search?.dispatchEvent(
+ new KeyboardEvent("keydown", { key: "Escape", bubbles: true }),
+ );
+ });
+ await settle();
+
+ expect(document.querySelector(".new-workbench-agent-popover")).toBeNull();
+ expect(document.activeElement).toBe(addAgent ?? null);
+ });
+
test("Shift+Enter does not submit — the prompt box stays open for a new line", async () => {
stubFetch(() => undefined);
await renderPicker();
@@ -436,8 +636,8 @@ describe("NewWorkbenchPickerRoute", () => {
});
await renderPicker();
- const justTalk = prefabCards().find((card) =>
- card.textContent?.includes("Just start talking"),
+ const justTalk = container?.querySelector(
+ ".new-workbench-empty-channel",
);
await act(async () => {
justTalk?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
diff --git a/apps/web/test/toast-single-system.test.tsx b/apps/web/test/toast-single-system.test.tsx
index 53993abaea..afdb85871c 100644
--- a/apps/web/test/toast-single-system.test.tsx
+++ b/apps/web/test/toast-single-system.test.tsx
@@ -141,12 +141,8 @@ async function renderPickerWithToaster(): Promise {
}
}
-function justStartTalkingCard(): HTMLButtonElement | undefined {
- return Array.from(
- container?.querySelectorAll(
- "button.new-workbench-prefab-card",
- ) ?? [],
- ).find((card) => card.textContent?.includes("Just start talking"));
+function emptyChannelButton(): HTMLButtonElement | null {
+ return container?.querySelector(".new-workbench-empty-channel") ?? null;
}
describe("the one toast system (CL-6372)", () => {
@@ -163,7 +159,7 @@ describe("the one toast system (CL-6372)", () => {
await renderPickerWithToaster();
await act(async () => {
- justStartTalkingCard()?.dispatchEvent(
+ emptyChannelButton()?.dispatchEvent(
new MouseEvent("click", { bubbles: true }),
);
});
@@ -213,7 +209,7 @@ describe("the one toast system (CL-6372)", () => {
await renderPickerWithToaster();
await act(async () => {
- justStartTalkingCard()?.dispatchEvent(
+ emptyChannelButton()?.dispatchEvent(
new MouseEvent("click", { bubbles: true }),
);
});
@@ -231,7 +227,7 @@ describe("the one toast system (CL-6372)", () => {
await renderPickerWithToaster();
await act(async () => {
- justStartTalkingCard()?.dispatchEvent(
+ emptyChannelButton()?.dispatchEvent(
new MouseEvent("click", { bubbles: true }),
);
});
diff --git a/packages/chat/src/agent-turns.test.ts b/packages/chat/src/agent-turns.test.ts
index 2c48149324..d617151706 100644
--- a/packages/chat/src/agent-turns.test.ts
+++ b/packages/chat/src/agent-turns.test.ts
@@ -26,7 +26,7 @@ describe("createInMemoryAgentTurnStore", () => {
expect(turn.requestMessageIds).toEqual(["msg_1"]);
});
- test("occurrences advance per (workbench, agent), never across them", async () => {
+ test("occurrences advance per agent across workbenches", async () => {
const store = createInMemoryAgentTurnStore();
const first = await store.startTurn(BASE);
const second = await store.startTurn(BASE);
@@ -44,7 +44,7 @@ describe("createInMemoryAgentTurnStore", () => {
"turn__1",
]);
expect(otherAgent.childRunId).toBe("turn__0");
- expect(otherWorkbench.childRunId).toBe("turn__0");
+ expect(otherWorkbench.childRunId).toBe("turn__2");
});
test("finishing records the outcome, the reply, and the section run", async () => {
@@ -251,6 +251,13 @@ describe("createInMemoryAgentTurnStore", () => {
});
});
+ test("the same agent in another workbench is not blocked", async () => {
+ const store = createInMemoryAgentTurnStore();
+ await store.startTurn(BASE);
+
+ await store.waitUntilFree({ ...BASE, workbenchId: "wb_2" });
+ });
+
test("a failed turn also frees the wait", async () => {
const store = createInMemoryAgentTurnStore();
const opened = await store.startTurn(BASE);
diff --git a/packages/chat/src/agent-turns.ts b/packages/chat/src/agent-turns.ts
index bee52b4c52..c1a861871d 100644
--- a/packages/chat/src/agent-turns.ts
+++ b/packages/chat/src/agent-turns.ts
@@ -8,9 +8,9 @@
//
// A turn's identity is the pair (warm section run, occurrence). The
// workflow runtime names an occurrence's child run `turn__` and
-// assigns `n` sequentially per section run; this store allocates the
-// same sequence per (workbench, agent) so a row's `childRunId` is the id
-// the reply message actually carries.
+// assigns `n` sequentially per section run; an agent's warm section run
+// can serve more than one workbench, so this store allocates the same
+// sequence per agent rather than restarting it in every room.
import {
AGENT_RUNTIME_SECTION_ID,
agentRuntimeTurnRunId,
@@ -28,8 +28,9 @@ export interface AgentTurn {
readonly tenantId: string;
readonly workbenchId: string;
readonly agentAddress: string;
- /** The warm (agent, workbench) section run, when the dispatch reached
- * far enough to learn it; null for a turn that never got that far. */
+ /** The warm per-agent section run, when the dispatch reached far enough
+ * to learn it; null for a turn that never got that far. One warm run
+ * exists per agent, each inbound message an occurrence. */
readonly sectionRunId: string | null;
/** `turn__` — what the reply message's `run_id` carries. */
readonly childRunId: string;
@@ -56,13 +57,15 @@ export interface FinishAgentTurnInput {
readonly turnId: string;
readonly status: Exclude;
readonly sectionRunId?: string;
+ /** The sidecar's actual child run id, when it reconciles an existing run. */
+ readonly childRunId?: string;
readonly replyMessageId?: string;
readonly error?: string;
}
export interface AgentTurnStore {
/**
- * Opens the next turn for (workbench, agent), allocating the
+ * Opens the next turn for an agent, allocating the
* occurrence — and therefore the child run id — the section run will
* use. Called before the execution plane is touched, so an in-flight
* turn is on the projection from its first moment.
@@ -86,8 +89,8 @@ export interface AgentTurnStore {
* newest-occurrence pick, which is the one documented fallback.
*
* `waitUntilFree` (below) is what `dispatchTurn` (`./workbench-service.ts`)
- * calls before opening a second occurrence for the same (workbench,
- * agent) — the one-in-flight-turn-per-workbench claim (`./turn-queue.ts`)
+ * calls before opening a second occurrence for the same (workbench, agent) — the
+ * one-in-flight-turn-per-workbench claim (`./turn-queue.ts`)
* is a different, coarser guarantee (it only spans the fast dispatch
* handoff, not the agent's actual reply) and on its own was NOT enough:
* two messages sent to the same agent a few seconds apart could each win
@@ -108,6 +111,17 @@ export interface AgentTurnStore {
*/
readonly childRunId?: string;
}): Promise;
+ /**
+ * Resolves an occurrence from the child run id on an inbound sidecar event,
+ * regardless of whether the turn has already settled. This is agent-global:
+ * a closed occurrence in one workbench must never be mistaken for an
+ * unknown event and rebound to a running occurrence in another.
+ */
+ findTurnByChildRun(input: {
+ readonly tenantId: string;
+ readonly agentAddress: string;
+ readonly childRunId: string;
+ }): Promise;
/**
* Every `running` turn for a workbench, across every agent — the
* cancel endpoint's own read (CL-7201): a workbench can have more than
@@ -127,7 +141,7 @@ export interface AgentTurnStore {
* if none is running right now, otherwise when the current one closes
* via `finishTurn`, or (backstop) once it ages past `AGENT_TURN_STALE_MS`
* and this store's own staleness sweep would fail it anyway. `dispatchTurn`
- * awaits this before opening a new occurrence for an agent, so two
+ * awaits this before opening a new occurrence for a workbench's agent, so two
* messages to the SAME agent — one arriving mid-generation of the
* other's reply — serialize into two turns in arrival order instead of
* two simultaneously-`running` rows that `findRunningTurn` could only
@@ -187,6 +201,13 @@ function newTurnId(): string {
return `turn_${crypto.randomUUID().replace(/-/g, "")}`;
}
+function agentKey(input: {
+ readonly tenantId: string;
+ readonly agentAddress: string;
+}): string {
+ return `${input.tenantId} ${input.agentAddress}`;
+}
+
function sectionKey(input: {
readonly tenantId: string;
readonly workbenchId: string;
@@ -328,7 +349,7 @@ export function createInMemoryAgentTurnStore(
return {
async startTurn(input) {
expireStaleTurns();
- const key = sectionKey(input);
+ const key = agentKey(input);
const occurrence = nextOccurrence.get(key) ?? 0;
nextOccurrence.set(key, occurrence + 1);
const childRunId = agentRuntimeTurnRunId(occurrence);
@@ -368,7 +389,7 @@ export function createInMemoryAgentTurnStore(
workbenchId: existing.workbenchId,
agentAddress: existing.agentAddress,
sectionRunId: input.sectionRunId ?? existing.sectionRunId,
- childRunId: existing.childRunId,
+ childRunId: input.childRunId ?? existing.childRunId,
occurrence: existing.occurrence,
requestMessageIds: existing.requestMessageIds,
replyMessageId: input.replyMessageId ?? existing.replyMessageId,
@@ -386,6 +407,18 @@ export function createInMemoryAgentTurnStore(
return runningTurn(input);
},
+ async findTurnByChildRun(input) {
+ expireStaleTurns();
+ return [...rows.values()]
+ .filter(
+ (turn) =>
+ turn.tenantId === input.tenantId &&
+ turn.agentAddress === input.agentAddress &&
+ turn.childRunId === input.childRunId,
+ )
+ .sort(compareTurnsNewestFirst)[0];
+ },
+
async findRunningTurns(input) {
expireStaleTurns();
return [...rows.values()].filter(
@@ -475,7 +508,7 @@ function toAgentTurn(row: AgentTurnRow): AgentTurn {
/**
* The Postgres-backed projection. Occurrence allocation happens inside
- * the insert (`max(occurrence) + 1` over the section's own rows) with a
+ * the insert (`max(occurrence) + 1` over the agent's own rows) with a
* unique constraint behind it, so two dispatches racing for one agent
* cannot both claim the same child run id — one of them fails loudly
* rather than two turns quietly sharing a run id.
@@ -488,7 +521,7 @@ export function createDrizzleAgentTurnStore<
async function expireStaleTurns(scope: {
readonly tenantId: string;
- readonly workbenchId: string;
+ readonly workbenchId?: string;
readonly agentAddress?: string;
}): Promise {
const cutoff = new Date(now() - AGENT_TURN_STALE_MS);
@@ -502,7 +535,9 @@ export function createDrizzleAgentTurnStore<
.where(
and(
eq(agentTurns.tenantId, scope.tenantId),
- eq(agentTurns.workbenchId, scope.workbenchId),
+ ...(scope.workbenchId !== undefined
+ ? [eq(agentTurns.workbenchId, scope.workbenchId)]
+ : []),
...(scope.agentAddress !== undefined
? [eq(agentTurns.agentAddress, scope.agentAddress)]
: []),
@@ -538,14 +573,37 @@ export function createDrizzleAgentTurnStore<
return row === undefined ? undefined : toAgentTurn(row as AgentTurnRow);
}
+ async function resolveTurnByChildRun(input: {
+ readonly tenantId: string;
+ readonly agentAddress: string;
+ readonly childRunId: string;
+ }): Promise {
+ await expireStaleTurns(input);
+ const [row] = await db
+ .select()
+ .from(agentTurns)
+ .where(
+ and(
+ eq(agentTurns.tenantId, input.tenantId),
+ eq(agentTurns.agentAddress, input.agentAddress),
+ eq(agentTurns.childRunId, input.childRunId),
+ ),
+ )
+ .orderBy(desc(agentTurns.startedAt), desc(agentTurns.occurrence))
+ .limit(1);
+ return row === undefined ? undefined : toAgentTurn(row as AgentTurnRow);
+ }
+
return {
async startTurn(input) {
- await expireStaleTurns(input);
+ await expireStaleTurns({
+ tenantId: input.tenantId,
+ agentAddress: input.agentAddress,
+ });
const occurrenceSql = sql`(
SELECT COALESCE(MAX(${agentTurns.occurrence}) + 1, 0)
FROM ${agentTurns}
WHERE ${agentTurns.tenantId} = ${input.tenantId}
- AND ${agentTurns.workbenchId} = ${input.workbenchId}
AND ${agentTurns.agentAddress} = ${input.agentAddress}
)`;
const [row] = await db
@@ -582,6 +640,9 @@ export function createDrizzleAgentTurnStore<
...(input.sectionRunId !== undefined
? { sectionRunId: input.sectionRunId }
: {}),
+ ...(input.childRunId !== undefined
+ ? { childRunId: input.childRunId }
+ : {}),
...(input.replyMessageId !== undefined
? { replyMessageId: input.replyMessageId }
: {}),
@@ -605,6 +666,10 @@ export function createDrizzleAgentTurnStore<
return resolveRunningTurn(input);
},
+ async findTurnByChildRun(input) {
+ return resolveTurnByChildRun(input);
+ },
+
async findRunningTurns(input) {
await expireStaleTurns(input);
const rows = await db
diff --git a/packages/chat/src/chat-orchestrator.ts b/packages/chat/src/chat-orchestrator.ts
index 73ce91b0cb..d3f9881765 100644
--- a/packages/chat/src/chat-orchestrator.ts
+++ b/packages/chat/src/chat-orchestrator.ts
@@ -118,7 +118,11 @@ export type ChatOrchestratorDeps = {
*/
agentTurns?: Pick<
AgentTurnStore,
- "findRunningTurn" | "finishTurn" | "listTurns" | "getTurn"
+ | "findRunningTurn"
+ | "findTurnByChildRun"
+ | "finishTurn"
+ | "listTurns"
+ | "getTurn"
>;
/**
* Resolves a gate-blocked event's `correlationId` to the approval row the
@@ -700,6 +704,32 @@ async function latestTurnForAgent(
return turns.find((turn) => turn.agentAddress === agentAddress);
}
+async function runningTurnForReply(
+ agentTurns:
+ Pick | undefined,
+ input: {
+ readonly tenantId: string;
+ readonly workbenchId: string;
+ readonly agentAddress: string;
+ readonly childRunId?: string;
+ },
+) {
+ if (agentTurns === undefined) return undefined;
+ const exact = await agentTurns.findRunningTurn(input);
+ if (exact !== undefined || input.childRunId === undefined) return exact;
+ const known = await agentTurns.findTurnByChildRun({
+ tenantId: input.tenantId,
+ agentAddress: input.agentAddress,
+ childRunId: input.childRunId,
+ });
+ if (known !== undefined) return undefined;
+ return agentTurns.findRunningTurn({
+ tenantId: input.tenantId,
+ workbenchId: input.workbenchId,
+ agentAddress: input.agentAddress,
+ });
+}
+
async function postReply(
deps: ChatOrchestratorDeps,
agentAddress: string,
@@ -721,7 +751,7 @@ async function postReply(
const members: ReplyMember[] = [];
for (const workbenchId of resolved.workbenchIds) {
- const turn = await deps.agentTurns?.findRunningTurn({
+ const turn = await runningTurnForReply(deps.agentTurns, {
tenantId: resolved.tenantId,
workbenchId,
agentAddress: resolved.roomAddress,
@@ -784,7 +814,7 @@ async function postReply(
}
for (const workbenchId of targetIds) {
- const turn = await deps.agentTurns?.findRunningTurn({
+ const turn = await runningTurnForReply(deps.agentTurns, {
tenantId: resolved.tenantId,
workbenchId,
agentAddress: resolved.roomAddress,
@@ -803,7 +833,7 @@ async function postReply(
workbenchId,
sender: { name: null, address: resolved.roomAddress },
parts: [...parts],
- ...(turn !== undefined ? { runId: turn.childRunId } : {}),
+ ...(turn !== undefined ? { runId: childRunId ?? turn.childRunId } : {}),
...(threadId !== undefined ? { threadId } : {}),
});
if (turn !== undefined) {
@@ -812,6 +842,7 @@ async function postReply(
turnId: turn.id,
status: outcome.status,
replyMessageId: posted.id,
+ ...(childRunId !== undefined ? { childRunId } : {}),
...(outcome.status === "failed" ? { error: outcome.error } : {}),
});
}
diff --git a/packages/chat/src/migrations.ts b/packages/chat/src/migrations.ts
index d91ccd9029..d8d02da322 100644
--- a/packages/chat/src/migrations.ts
+++ b/packages/chat/src/migrations.ts
@@ -562,6 +562,32 @@ export const chatMigrations: readonly ChatMigration[] = [
ON "chat"."workbench_messages" ("tenant_id", "mail_message_id");
`,
},
+ {
+ name: "0030_agent_turns_agent_occurrence_key",
+ sql: `
+ DROP INDEX IF EXISTS "chat"."agent_turns_occurrence_key";
+
+ WITH "ranked" AS (
+ SELECT
+ "id",
+ row_number() OVER (
+ PARTITION BY "tenant_id", "agent_address"
+ ORDER BY "started_at", "id"
+ ) - 1 AS "occurrence"
+ FROM "chat"."agent_turns"
+ )
+ UPDATE "chat"."agent_turns" AS "turn"
+ SET
+ "occurrence" = "ranked"."occurrence",
+ "child_run_id" = concat('turn__', "ranked"."occurrence"::text)
+ FROM "ranked"
+ WHERE "turn"."id" = "ranked"."id";
+
+ CREATE UNIQUE INDEX IF NOT EXISTS "agent_turns_occurrence_key"
+ ON "chat"."agent_turns"
+ ("tenant_id", "agent_address", "occurrence");
+ `,
+ },
];
// Bookkeeping table for this package's own migrations. Named
diff --git a/packages/chat/src/schema.ts b/packages/chat/src/schema.ts
index 7ee2b0ddad..dc241070ea 100644
--- a/packages/chat/src/schema.ts
+++ b/packages/chat/src/schema.ts
@@ -583,7 +583,6 @@ export const agentTurns = chatSchema.table(
),
unique("agent_turns_occurrence_key").on(
table.tenantId,
- table.workbenchId,
table.agentAddress,
table.occurrence,
),
diff --git a/packages/chat/test/agent-turns.drizzle.test.ts b/packages/chat/test/agent-turns.drizzle.test.ts
index 9af077db0f..3682c2cc18 100644
--- a/packages/chat/test/agent-turns.drizzle.test.ts
+++ b/packages/chat/test/agent-turns.drizzle.test.ts
@@ -71,7 +71,7 @@ describeIfDb("createDrizzleAgentTurnStore", () => {
});
});
- test("occurrences advance per (workbench, agent), and a turn round-trips", async () => {
+ test("occurrences advance per agent across workbenches, and a turn round-trips", async () => {
const sql = postgres(scratchUrl, { max: 5, onnotice: () => undefined });
try {
const store = createDrizzleAgentTurnStore(drizzle(sql));
@@ -94,12 +94,19 @@ describeIfDb("createDrizzleAgentTurnStore", () => {
agentAddress: "ins_echo2@acme.example",
requestMessageIds: ["msg_2"],
});
+ const otherWorkbench = await store.startTurn({
+ tenantId: TENANT,
+ workbenchId: "run_workbench2",
+ agentAddress: AGENT,
+ requestMessageIds: ["msg_3"],
+ });
expect([first.childRunId, second.childRunId]).toEqual([
"turn__0",
"turn__1",
]);
expect(otherAgent.childRunId).toBe("turn__0");
+ expect(otherWorkbench.childRunId).toBe("turn__2");
expect(first.status).toBe("running");
expect(first.requestMessageIds).toEqual(["msg_1"]);
@@ -211,7 +218,7 @@ describeIfDb("createDrizzleAgentTurnStore", () => {
const input = {
tenantId: TENANT,
workbenchId: "run_child_run_id",
- agentAddress: AGENT,
+ agentAddress: "ins_lookup@acme.example",
requestMessageIds: ["msg_lookup"],
};
const first = await store.startTurn(input);
diff --git a/packages/chat/test/chat-orchestrator.test.ts b/packages/chat/test/chat-orchestrator.test.ts
index 47cd824d4b..b883d5a757 100644
--- a/packages/chat/test/chat-orchestrator.test.ts
+++ b/packages/chat/test/chat-orchestrator.test.ts
@@ -327,11 +327,115 @@ describe("createChatOrchestrator", () => {
orchestrator.dispose();
});
- // CL-7172: waitUntilFree is per workbench, so the same host can have a
- // running turn in room A and room B at once. Collecting every running
- // row would post one connector.reply into both rooms and finishTurn
- // both — then the second reply would see no running turns and two
- // memberships and silently drop. Originating workbench is singular.
+ test("reconciles a sidecar child run after the projection restarts", async () => {
+ const room = fakeRoom();
+ const agentTurns = createInMemoryAgentTurnStore();
+ const agentAddress = "ins_echo1@ten1.workbench.test";
+ const turn = await agentTurns.startTurn({
+ tenantId: "ten_1",
+ workbenchId: "ins_room_a",
+ agentAddress,
+ requestMessageIds: ["msg_a"],
+ });
+ const events = createSidecarEmitter();
+ const orchestrator = createChatOrchestrator({
+ db: createFakeDb({ id: "ins_echo1", tenantId: "ten_1" }) as never,
+ store: {
+ listWorkbenchSettings: async () => [
+ workbenchRow("ins_room_a", [agentAddress]),
+ workbenchRow("ins_room_b", [agentAddress]),
+ ],
+ },
+ roomMessages: room.roomMessages,
+ publish: room.publish,
+ platform: fakeMail().platform,
+ events,
+ agentTurns,
+ claims: fakeClaims(),
+ approvals: { findByCorrelationId: async () => null },
+ });
+
+ events.emit("agent.event", {
+ agentAddress,
+ sessionId: "ses_1",
+ childRunId: "turn__9",
+ event: { type: "connector.reply", data: { content: "reconciled" } },
+ });
+
+ await new Promise((resolve) => setTimeout(resolve, 0));
+
+ expect(room.posted).toHaveLength(1);
+ expect(room.posted[0]).toMatchObject({
+ workbenchId: "ins_room_a",
+ runId: "turn__9",
+ parts: [{ kind: "text", text: "reconciled" }],
+ });
+ expect(
+ await agentTurns.getTurn({ tenantId: "ten_1", turnId: turn.id }),
+ ).toMatchObject({ status: "completed", childRunId: "turn__9" });
+
+ orchestrator.dispose();
+ });
+
+ test("a reply from a closed occurrence cannot finish a newer turn in another room", async () => {
+ const room = fakeRoom();
+ const agentTurns = createInMemoryAgentTurnStore();
+ const agentAddress = "ins_echo1@ten1.workbench.test";
+ const closed = await agentTurns.startTurn({
+ tenantId: "ten_1",
+ workbenchId: "ins_room_a",
+ agentAddress,
+ requestMessageIds: ["msg_a"],
+ });
+ await agentTurns.finishTurn({
+ tenantId: "ten_1",
+ turnId: closed.id,
+ status: "cancelled",
+ });
+ const current = await agentTurns.startTurn({
+ tenantId: "ten_1",
+ workbenchId: "ins_room_b",
+ agentAddress,
+ requestMessageIds: ["msg_b"],
+ });
+ const events = createSidecarEmitter();
+ const orchestrator = createChatOrchestrator({
+ db: createFakeDb({ id: "ins_echo1", tenantId: "ten_1" }) as never,
+ store: {
+ listWorkbenchSettings: async () => [
+ workbenchRow("ins_room_a", [agentAddress]),
+ workbenchRow("ins_room_b", [agentAddress]),
+ ],
+ },
+ roomMessages: room.roomMessages,
+ publish: room.publish,
+ platform: fakeMail().platform,
+ events,
+ agentTurns,
+ claims: fakeClaims(),
+ approvals: { findByCorrelationId: async () => null },
+ });
+
+ events.emit("agent.event", {
+ agentAddress,
+ sessionId: "ses_1",
+ childRunId: closed.childRunId,
+ event: { type: "connector.reply", data: { content: "late reply" } },
+ });
+
+ await new Promise((resolve) => setTimeout(resolve, 0));
+
+ expect(room.posted).toHaveLength(0);
+ expect(
+ await agentTurns.getTurn({ tenantId: "ten_1", turnId: current.id }),
+ ).toMatchObject({ status: "running", childRunId: "turn__1" });
+
+ orchestrator.dispose();
+ });
+
+ // Direct projection writes can still construct two running rows for a
+ // shared agent. The orchestrator must not spray a single reply into both
+ // rooms; the originating workbench is singular.
test("a connector.reply does not post into two rooms that both have a running turn for the same agent", async () => {
const room = fakeRoom();
const agentTurns = createInMemoryAgentTurnStore();
diff --git a/packages/chat/test/migrations.test.ts b/packages/chat/test/migrations.test.ts
index fd9b553ae6..d15b217ca3 100644
--- a/packages/chat/test/migrations.test.ts
+++ b/packages/chat/test/migrations.test.ts
@@ -51,6 +51,7 @@ const migrationNames = [
"0027_block_responses_notified_at",
"0028_turn_mail_correlation",
"0029_workbench_messages_mail_message_id",
+ "0030_agent_turns_agent_occurrence_key",
];
describeIfDb("applyChatMigrations", () => {
diff --git a/packages/command-palette/test/use-entity-search.test.tsx b/packages/command-palette/test/use-entity-search.test.tsx
index e047036d37..605c15e5d8 100644
--- a/packages/command-palette/test/use-entity-search.test.tsx
+++ b/packages/command-palette/test/use-entity-search.test.tsx
@@ -4,6 +4,7 @@ import { createRoot } from "react-dom/client";
import { useEntitySearch } from "../src/use-entity-search";
import type { UseEntitySearchResult } from "../src/use-entity-search";
+import type { SearchableEntity } from "../src/entity-search";
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
@@ -26,7 +27,13 @@ const SOURCES = [
{ category: "agents", fetch: () => Promise.resolve(AGENTS) },
];
-function mount(initialQuery: string) {
+function mount(
+ initialQuery: string,
+ sources: readonly {
+ category: string;
+ fetch: () => Promise;
+ }[] = SOURCES,
+) {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
@@ -41,7 +48,7 @@ function mount(initialQuery: string) {
enabled: true,
pageSize: 1,
debounceMs: 5,
- sources: SOURCES,
+ sources,
});
return null;
}
@@ -71,10 +78,25 @@ describe("useEntitySearch", () => {
});
test("debounces the query before searching, then reports matches with the raw id never in the title", async () => {
- const harness = mount("");
+ // Hold the workbenches fetch open so `loading: true` is observable for
+ // as long as the test needs it: the hook only drops `loading` once the
+ // search's fetches resolve, so a gated source makes that phase
+ // deterministic instead of racing the 5ms debounce on a loaded runner.
+ let releaseWorkbenches:
+ ((entities: readonly SearchableEntity[]) => void) | undefined;
+ const workbenches = new Promise((resolve) => {
+ releaseWorkbenches = (entities) => resolve(entities);
+ });
+ const harness = mount("", [
+ { category: "workbenches", fetch: () => workbenches },
+ ...SOURCES.slice(1),
+ ]);
await harness.settle();
+ expect(harness.get().loading).toBe(false);
await harness.setQuery("launch");
+ await harness.settle();
expect(harness.get().loading).toBe(true);
+ releaseWorkbenches?.(WORKBENCHES);
await harness.settle();
expect(harness.get().loading).toBe(false);
const titles = harness.get().results.map((result) => result.title);
diff --git a/scripts/checks/report-error-baseline.txt b/scripts/checks/report-error-baseline.txt
index 6b18b3d770..8654b1c98d 100644
--- a/scripts/checks/report-error-baseline.txt
+++ b/scripts/checks/report-error-baseline.txt
@@ -84,7 +84,6 @@ apps/web/src/pages/create-agent-panel.tsx 1 setSubmitError(
apps/web/src/pages/create-skill-dialog.tsx 1 setServerError(cause instanceof Error ? cause.message : String(cause));
apps/web/src/pages/library-page.tsx 1 setUploadError(
apps/web/src/pages/mission-control-page.tsx 1 toast(
-apps/web/src/pages/new-workbench-picker.tsx 1 // The missing-setup-agent precondition reads identically whether
apps/web/src/pages/skill-detail-page.tsx 1 if (options.keepDraft) {
apps/web/src/pages/skill-detail-page.tsx 1 if (statusOf(cause) === 409) {
apps/web/src/pages/skill-detail-page.tsx 1 setActionError(describeApiError(cause, "reading that version"));