From ca24281acf20a3914ce236908e0f2e0cadb19156 Mon Sep 17 00:00:00 2001 From: 0xPratik Date: Tue, 8 Sep 2026 14:48:06 +0545 Subject: [PATCH 1/6] Add tests for creating agents from the composer --- apps/web/test/new-workbench-picker.test.tsx | 104 ++++++++++++++++++++ 1 file changed, 104 insertions(+) diff --git a/apps/web/test/new-workbench-picker.test.tsx b/apps/web/test/new-workbench-picker.test.tsx index 70c3fe0fc..2320f5674 100644 --- a/apps/web/test/new-workbench-picker.test.tsx +++ b/apps/web/test/new-workbench-picker.test.tsx @@ -491,6 +491,110 @@ describe("NewWorkbenchPickerRoute", () => { expect(navigated).toEqual(["/w/chan_new"]); }); + test.each(["Research Buddy", "", null])( + "creating an agent with description %j preserves the draft and selects it by name", + async (description) => { + stubFetch((path) => { + if (path.includes("/catalog/models")) + return json({ data: [], nextCursor: null }); + if (path.endsWith("/skills")) return json({ skills: [] }); + if (path.endsWith("/agent-definitions/draft")) { + return json({ draft: { systemPrompt: "Research carefully." } }); + } + if (path.endsWith("/agent-definitions")) { + return json( + { + id: "wfd_new", + tenantId: "tnt_1", + name: "research-buddy", + description, + currentVersion: "1", + status: "deployed", + createdAt: "2026-09-08T00:00:00.000Z", + updatedAt: "2026-09-08T00:00:00.000Z", + skills: [], + }, + 201, + ); + } + return undefined; + }); + await renderPicker(); + await settle(); + await act(async () => typeIntoPrompt("Research our next partner")); + const button = (label: string) => + [...document.querySelectorAll("button")].find( + (element) => element.textContent === label, + ); + await act(async () => button("+ Add agent")?.click()); + expect(button("+ Create agent")).toBeDefined(); + await act(async () => button("+ Create agent")?.click()); + await settle(); + expect(document.querySelector(".new-workbench-agent-popover")).toBeNull(); + expect(document.querySelector('[role="dialog"]')?.textContent).toContain( + "New agent", + ); + const name = document.getElementById("create-agent-name"); + const setter = Object.getOwnPropertyDescriptor( + window.HTMLInputElement.prototype, + "value", + )?.set; + if (!(name instanceof HTMLInputElement) || setter === undefined) { + throw new Error("Agent name input is missing"); + } + await act(async () => { + setter.call(name, "Research Buddy"); + name.dispatchEvent(new Event("input", { bubbles: true })); + }); + await act(async () => button("Get started")?.click()); + await settle(); + expect(document.querySelector('[role="dialog"]') === null).toBe(true); + expect(promptInput()?.value).toBe("Research our next partner"); + expect( + document.querySelector(".new-workbench-agent-chip")?.textContent, + ).toContain("Research Buddy"); + }, + ); + + test("an empty agent list still offers creation and cancel preserves the draft", async () => { + stubFetch((path) => { + if (path.includes("/catalog/models")) + return json({ data: [], nextCursor: null }); + if (path.endsWith("/skills")) return json({ skills: [] }); + return undefined; + }); + const queryClient = createTestQueryClient(); + await renderPicker(undefined, queryClient); + await act(async () => { + queryClient.setQueryData( + ["tenant", "tnt_1", "invitable-definitions"], + [], + ); + typeIntoPrompt("Keep this draft"); + }); + const button = (label: string) => + [...document.querySelectorAll("button")].find( + (element) => element.textContent === label, + ); + await act(async () => button("+ Add agent")?.click()); + expect( + document.querySelector(".new-workbench-agent-empty")?.textContent, + ).toContain("No agents are available yet."); + expect(button("+ Create agent")).toBeDefined(); + await act(async () => button("+ Create agent")?.click()); + await settle(); + expect(document.querySelector('[role="dialog"]')).not.toBeNull(); + const cancel = [ + ...document.querySelectorAll('[role="dialog"] button'), + ].find((element) => element.textContent === "Cancel"); + expect(cancel?.disabled).toBe(false); + await act(async () => cancel?.click()); + await settle(); + expect(document.querySelector('[role="dialog"]') === null).toBe(true); + expect(promptInput()?.value).toBe("Keep this draft"); + expect(document.querySelector(".new-workbench-agent-chip")).toBeNull(); + }); + test("Enter on a no-match agent search does not create a workbench", async () => { const calls = stubBlankCreate( undefined, From 35650a0275865fd071549d22def396f42fd57e28 Mon Sep 17 00:00:00 2001 From: 0xPratik Date: Tue, 8 Sep 2026 14:48:06 +0545 Subject: [PATCH 2/6] Add agent creation to the workbench composer picker --- apps/web/src/pages/new-workbench-picker.tsx | 55 +++++++++++++++++++-- 1 file changed, 50 insertions(+), 5 deletions(-) diff --git a/apps/web/src/pages/new-workbench-picker.tsx b/apps/web/src/pages/new-workbench-picker.tsx index a22fc7013..99dbe3560 100644 --- a/apps/web/src/pages/new-workbench-picker.tsx +++ b/apps/web/src/pages/new-workbench-picker.tsx @@ -30,6 +30,7 @@ import { ApiQueryError, describeApiError } from "@corbits/api-query"; import { reportError } from "@corbits/error-sink"; import { useAPIQuery } from "../api"; +import { CreateAgentPanel } from "./create-agent-panel"; import { TemplateLibraryPage } from "../workbench-templates-api"; import { useBench } from "../bench-context"; import { @@ -120,6 +121,7 @@ export function NewWorkbenchPickerRoute() { readonly string[] >([]); const [agentPickerOpen, setAgentPickerOpen] = useState(false); + const [createAgentOpen, setCreateAgentOpen] = useState(false); const [agentQuery, setAgentQuery] = useState(""); const [activeAgentIndex, setActiveAgentIndex] = useState(0); const [creating, setCreating] = useState(false); @@ -172,6 +174,7 @@ export function NewWorkbenchPickerRoute() { useEffect(() => { setAgentPickerOpen(false); + setCreateAgentOpen(false); setAgentQuery(""); setActiveAgentIndex(0); setSelectedAgentDefinitionIds([]); @@ -429,10 +432,6 @@ export function NewWorkbenchPickerRoute() { Couldn't load agents. You can still start without one. - ) : invitableAgents.data?.length === 0 ? ( - - No agents are available yet. - ) : (
{filteredAgents.length === 0 ? (

- No agents match that search. + {invitableAgents.data?.length === 0 + ? "No agents are available yet." + : "No agents match that search."}

) : (
)} +
) : null}
@@ -652,6 +664,39 @@ export function NewWorkbenchPickerRoute() { )} + {selectedTenantId !== null && createAgentOpen ? ( + { + queryClient.setQueryData< + Awaited> + >( + ["tenant", selectedTenantId, "invitable-definitions"], + (current) => [ + ...(current ?? []).filter( + (agent) => agent.id !== definition.id, + ), + { + id: definition.id, + name: definition.name, + ...(definition.description !== null && + definition.description !== "" + ? { description: definition.description } + : {}), + }, + ], + ); + setSelectedAgentDefinitionIds((current) => [ + ...current, + definition.id, + ]); + promptRef.current?.focus(); + }} + /> + ) : null} ); } From 85fbfe65cb439f38fad6cded948a20d5f151b6b9 Mon Sep 17 00:00:00 2001 From: 0xPratik Date: Tue, 8 Sep 2026 15:09:29 +0545 Subject: [PATCH 3/6] Add regression test for concurrent connector metadata reads --- apps/sidecar/src/conversation-state.test.ts | 53 ++++++++++++++++++++- 1 file changed, 52 insertions(+), 1 deletion(-) diff --git a/apps/sidecar/src/conversation-state.test.ts b/apps/sidecar/src/conversation-state.test.ts index 0f66038af..a3a4120ce 100644 --- a/apps/sidecar/src/conversation-state.test.ts +++ b/apps/sidecar/src/conversation-state.test.ts @@ -2,7 +2,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { afterEach, expect, test } from "bun:test"; +import { afterEach, expect, spyOn, test } from "bun:test"; import type { Principal, @@ -311,3 +311,54 @@ test("prepareConversationForOriginatingWorkbench evicts the warm agent on room c expect(evictions).toHaveLength(1); expect(evictions[0]).toContain("chan_b"); }); + +test("reactor startup waits for an in-flight connector metadata write", async () => { + const root = tmpDir("conv-startup-metadata-"); + const { store } = await makeStore(root); + await store.bindOriginatingWorkbench("chan_a"); + const metadataPath = path.join(root, "local", "metadata.json"); + const truncated = Promise.withResolvers(); + const resumeWrite = Promise.withResolvers(); + const writeFile = fs.promises.writeFile.bind(fs.promises); + let paused = false; + const writeSpy = spyOn(fs.promises, "writeFile").mockImplementation( + async (file, data, options) => { + if (file === metadataPath && !paused) { + paused = true; + await writeFile(file, "", options); + truncated.resolve(); + await resumeWrite.promise; + } + await writeFile(file, data, options); + }, + ); + try { + const seed = store.seedInbound({ + ref: { uid: 1, mailbox: "INBOX" }, + headers: { + from: "human@example.test", + to: ["agent@example.test"], + date: "2026-09-08T00:00:00.000Z", + messageId: "", + interchangeType: "conversation.message", + }, + flags: [], + content: "heartbeat", + signatureStatus: "valid", + }); + await truncated.promise; + const loaded = store.storage.load(); + const outcome = loaded.then( + () => "loaded", + () => "invalid metadata", + ); + await Bun.sleep(20); + resumeWrite.resolve(); + await seed; + expect(await outcome).toBe("loaded"); + await store.mirrorToSubstrate(); + } finally { + resumeWrite.resolve(); + writeSpy.mockRestore(); + } +}); From 3208e1697da3fbe3cd654f9a12fba57490468266 Mon Sep 17 00:00:00 2001 From: 0xPratik Date: Tue, 8 Sep 2026 15:09:29 +0545 Subject: [PATCH 4/6] Serialize reactor metadata access with connector updates --- apps/sidecar/src/conversation-state.ts | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/apps/sidecar/src/conversation-state.ts b/apps/sidecar/src/conversation-state.ts index 6179c1ca8..84dfa721a 100644 --- a/apps/sidecar/src/conversation-state.ts +++ b/apps/sidecar/src/conversation-state.ts @@ -474,7 +474,8 @@ export async function createDurableConversationStore( // does not poison the chain, while each caller still observes its own op's // result (or rejection) through the returned promise. // - // This serializes mirror-vs-mirror and mirror-vs-restore only. It does NOT + // Reactor startup reads and metadata writes join this same queue, so + // startup cannot read a connector metadata file mid-write. It does NOT // address the reactor-vs-mirror peek-snapshot window documented on // `runMirror` below (nothing must append to the reactor's turn array // between its last writeTurns and the mirror's peekTurns) -- that is a @@ -830,8 +831,22 @@ export async function createDurableConversationStore( }); } + const storageOverrides: Pick = { + load: (signal) => serializeStateOp(() => baseStorage.load(signal)), + writeMetadata: (metadata, signal) => + serializeStateOp(() => baseStorage.writeMetadata(metadata, signal)), + }; + const storage = new Proxy(baseStorage, { + get(target, prop) { + if (prop === "load") return storageOverrides.load; + if (prop === "writeMetadata") return storageOverrides.writeMetadata; + const value = Reflect.get(target, prop, target); + return typeof value === "function" ? value.bind(target) : value; + }, + }); + return { - storage: baseStorage, + storage, restoreFromSubstrate, mirrorToSubstrate, seedInbound, From 1d70bdfec5e99b81b4d2b71dbb8aade2328e77dd Mon Sep 17 00:00:00 2001 From: 0xPratik Date: Wed, 9 Sep 2026 12:59:55 +0545 Subject: [PATCH 5/6] Add regression tests for stale agent creation and empty labels --- apps/web/test/new-workbench-picker.test.tsx | 117 ++++++++++++++++++++ 1 file changed, 117 insertions(+) diff --git a/apps/web/test/new-workbench-picker.test.tsx b/apps/web/test/new-workbench-picker.test.tsx index 2320f5674..690cddae5 100644 --- a/apps/web/test/new-workbench-picker.test.tsx +++ b/apps/web/test/new-workbench-picker.test.tsx @@ -556,6 +556,123 @@ describe("NewWorkbenchPickerRoute", () => { }, ); + test("a late agent creation cannot invite an agent from the previous tenant", async () => { + let sentInvite: unknown; + const calls = stubBlankCreate((body) => { + sentInvite = body.invite; + }); + const baseFetch = globalThis.fetch; + const pending = Promise.withResolvers(); + let creationStarted = false; + globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => { + const path = String(input); + if (path.includes("/catalog/models")) + return Promise.resolve(json({ data: [], nextCursor: null })); + if (path.endsWith("/skills")) + return Promise.resolve(json({ skills: [] })); + if (path.endsWith("/agent-definitions/draft")) + return Promise.resolve( + json({ draft: { systemPrompt: "Research carefully." } }), + ); + if (path.endsWith("/agent-definitions")) { + creationStarted = true; + return pending.promise; + } + return baseFetch(input, init); + }) as typeof fetch; + const queryClient = createTestQueryClient(); + const navigated: string[] = []; + await renderPicker((to) => navigated.push(to), queryClient); + const button = (label: string) => + [...document.querySelectorAll("button")].find( + (element) => element.textContent === label, + ); + await act(async () => button("+ Add agent")?.click()); + await act(async () => button("+ Create agent")?.click()); + await settle(); + const name = document.getElementById("create-agent-name"); + const setter = Object.getOwnPropertyDescriptor( + window.HTMLInputElement.prototype, + "value", + )?.set; + if (!(name instanceof HTMLInputElement) || setter === undefined) + throw new Error("Agent name input is missing"); + await act(async () => { + setter.call(name, "Research Buddy"); + name.dispatchEvent(new Event("input", { bubbles: true })); + }); + await act(async () => button("Get started")?.click()); + expect(creationStarted).toBe(true); + await act(async () => { + queryClient.setQueryData(["me", "principals"], { + data: [ + { + ...MEMBERSHIP.data[0], + tenantId: "tnt_2", + tenantName: "Second Bench", + }, + ], + nextCursor: null, + }); + }); + await settle(); + await settle(); + expect(document.querySelector('[role="dialog"]')).toBeNull(); + expect( + calls.some((call) => + call.path.includes("/tenants/tnt_2/chat/invitable-definitions"), + ), + ).toBe(true); + await act(async () => + pending.resolve( + json( + { + id: "wfd_new", + tenantId: "tnt_1", + name: "research-buddy", + description: null, + currentVersion: "1", + status: "deployed", + createdAt: "2026-09-08T00:00:00.000Z", + updatedAt: "2026-09-08T00:00:00.000Z", + skills: [], + }, + 201, + ), + ), + ); + await settle(); + 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 && navigated.length === 0; i++) await settle(); + expect(navigated).toEqual(["/w/chan_new"]); + expect(sentInvite).toBeUndefined(); + }); + + test("empty descriptions from the agent list display the agent name", async () => { + stubFetch(() => undefined); + const queryClient = createTestQueryClient(); + await renderPicker(undefined, queryClient); + await act(async () => + queryClient.setQueryData( + ["tenant", "tnt_1", "invitable-definitions"], + [{ id: "wfd_empty", name: "research-buddy", description: "" }], + ), + ); + await settle(); + const add = [...document.querySelectorAll("button")].find( + (button) => button.textContent === "+ Add agent", + ); + await act(async () => add?.click()); + expect( + document.getElementById("new-workbench-agent-wfd_empty")?.textContent, + ).toContain("Research Buddy"); + }); + test("an empty agent list still offers creation and cancel preserves the draft", async () => { stubFetch((path) => { if (path.includes("/catalog/models")) From 9e332066081f9480ed7b2f9085834823afd39248 Mon Sep 17 00:00:00 2001 From: 0xPratik Date: Wed, 9 Sep 2026 12:59:55 +0545 Subject: [PATCH 6/6] Guard agent creation results after tenant changes --- apps/web/src/pages/new-workbench-picker.tsx | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/apps/web/src/pages/new-workbench-picker.tsx b/apps/web/src/pages/new-workbench-picker.tsx index 99dbe3560..205c4a084 100644 --- a/apps/web/src/pages/new-workbench-picker.tsx +++ b/apps/web/src/pages/new-workbench-picker.tsx @@ -103,13 +103,22 @@ function agentDisplayName({ readonly name: string; readonly description?: string; }): string { - return description ?? humanizeSlug(name); + return description === undefined || description === "" + ? humanizeSlug(name) + : description; } export function NewWorkbenchPickerRoute() { const navigate = useNavigate(); const queryClient = useQueryClient(); const { selectedTenantId } = useBench(); + const currentTenantIdRef = useRef(selectedTenantId); + useEffect(() => { + currentTenantIdRef.current = selectedTenantId; + return () => { + currentTenantIdRef.current = null; + }; + }, [selectedTenantId]); const library = useAPIQuery( selectedTenantId === null ? "" @@ -671,6 +680,7 @@ export function NewWorkbenchPickerRoute() { onOpenChange={setCreateAgentOpen} tenantId={selectedTenantId} onCreated={(definition) => { + if (currentTenantIdRef.current !== selectedTenantId) return; queryClient.setQueryData< Awaited> >(