From 8e7310bb0dd47398a1cc981f341a5076b09e5dd9 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Tue, 20 Oct 2026 21:07:20 -0700 Subject: [PATCH 1/2] test(web): cover tenant-scoped opening messages and /new send click --- .../src/bench/pending-opening-message.test.ts | 36 +++++++++++++++++++ e2e/lib/first-run.ts | 18 +++++----- 2 files changed, 44 insertions(+), 10 deletions(-) create mode 100644 apps/web/src/bench/pending-opening-message.test.ts diff --git a/apps/web/src/bench/pending-opening-message.test.ts b/apps/web/src/bench/pending-opening-message.test.ts new file mode 100644 index 000000000..593258904 --- /dev/null +++ b/apps/web/src/bench/pending-opening-message.test.ts @@ -0,0 +1,36 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; + +import { clearOpeningMessage, parkOpeningMessage, readOpeningMessage } from "../opening-message"; + +const store = new Map(); + +beforeEach(() => { + store.clear(); + Object.defineProperty(globalThis, "sessionStorage", { + configurable: true, + value: { + getItem: (key: string) => store.get(key) ?? null, + setItem: (key: string, value: string) => void store.set(key, value), + removeItem: (key: string) => void store.delete(key), + }, + }); +}); + +afterEach(() => { + Reflect.deleteProperty(globalThis, "sessionStorage"); +}); + +describe("opening message tenant isolation", () => { + test("parking and clearing an opening message is scoped to the originating workbench tenant", () => { + parkOpeningMessage("tnt_bench_a", "Draft the Q4 launch note"); + parkOpeningMessage("tnt_bench_b", "Audit the billing webhook"); + + expect(readOpeningMessage("tnt_bench_a")).toBe("Draft the Q4 launch note"); + expect(readOpeningMessage("tnt_bench_b")).toBe("Audit the billing webhook"); + + clearOpeningMessage("tnt_bench_a"); + + expect(readOpeningMessage("tnt_bench_a")).toBeNull(); + expect(readOpeningMessage("tnt_bench_b")).toBe("Audit the billing webhook"); + }); +}); diff --git a/e2e/lib/first-run.ts b/e2e/lib/first-run.ts index 694e52e26..a1b6f2010 100644 --- a/e2e/lib/first-run.ts +++ b/e2e/lib/first-run.ts @@ -21,8 +21,8 @@ export async function clickText(page: Page, selector: string, label: string): Pr /** * Types `message` into the /new composer and starts the workbench. The - * composer and its send button stay disabled until workspace roles have - * loaded (CL-9780), so typing or clicking earlier is silently lost. + * composer and its send button stay disabled until the workbench list has + * loaded, so typing or clicking earlier is silently lost. */ export async function startWorkbench(page: Page, message: string): Promise { await page.waitForFunction( @@ -33,14 +33,12 @@ export async function startWorkbench(page: Page, message: string): Promise { timeout: STEP_TIMEOUT }, ); await page.type("textarea", message); - await page.waitForFunction( - `(() => { - const send = document.querySelector("button[aria-label='Start this workbench']"); - return send instanceof HTMLButtonElement && !send.disabled; - })()`, - { timeout: STEP_TIMEOUT }, - ); - await page.click("button[aria-label='Start this workbench']"); + const sendScript = `(() => { + const send = document.querySelector("button[aria-label='Start this workbench']"); + return send instanceof HTMLButtonElement && !send.disabled ? send : null; + })()`; + await page.waitForFunction(sendScript, { timeout: STEP_TIMEOUT }); + await page.evaluate(`(${sendScript}).click()`); } export async function runFirstRunFlow(page: Page, origin: string): Promise { From 6045972941f2e0ca1a9b8ab8f7ecb6eebd102c41 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Tue, 20 Oct 2026 21:56:03 -0700 Subject: [PATCH 2/2] fix(web): scope new-workbench opening message delivery to its tenant (#1116) --- .../src/bench/pending-opening-message.test.ts | 66 ++++++++- .../web/src/bench/pending-opening-message.tsx | 131 +++++++++++++++--- apps/web/src/pages/new-workbench-picker.tsx | 2 +- apps/web/src/pages/workbench-page.tsx | 2 +- 4 files changed, 172 insertions(+), 29 deletions(-) diff --git a/apps/web/src/bench/pending-opening-message.test.ts b/apps/web/src/bench/pending-opening-message.test.ts index 593258904..9f4770902 100644 --- a/apps/web/src/bench/pending-opening-message.test.ts +++ b/apps/web/src/bench/pending-opening-message.test.ts @@ -1,6 +1,14 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { QueryClient } from "@tanstack/react-query"; -import { clearOpeningMessage, parkOpeningMessage, readOpeningMessage } from "../opening-message"; +import type { WorkbenchParticipant } from "@/chat/threads-api"; +import { workbenchKeys } from "../chat-path"; +import { parkOpeningMessage, readOpeningMessage } from "../opening-message"; +import { + createPendingOpeningMessageSession, + dispatchOpeningMessage, + syncPendingOpeningMessageSession, +} from "./pending-opening-message"; const store = new Map(); @@ -21,15 +29,63 @@ afterEach(() => { }); describe("opening message tenant isolation", () => { - test("parking and clearing an opening message is scoped to the originating workbench tenant", () => { + test("switching workbenches resets pending session state to the new tenant", () => { parkOpeningMessage("tnt_bench_a", "Draft the Q4 launch note"); - parkOpeningMessage("tnt_bench_b", "Audit the billing webhook"); + const sessionA = { + ...createPendingOpeningMessageSession("tnt_bench_a"), + timedOut: true, + }; + const sessionB = syncPendingOpeningMessageSession(sessionA, "tnt_bench_b"); + expect(sessionB).toEqual({ + workbenchTenantId: "tnt_bench_b", + text: null, + timedOut: false, + }); expect(readOpeningMessage("tnt_bench_a")).toBe("Draft the Q4 launch note"); - expect(readOpeningMessage("tnt_bench_b")).toBe("Audit the billing webhook"); + }); + + test("in-flight send uses and clears only the originating workbench's opening message", async () => { + const queryClient = new QueryClient(); + parkOpeningMessage("tnt_bench_a", "Draft the Q4 launch note"); + + const calls: Array<{ + readonly workbenchTenantId: string; + readonly participants: readonly WorkbenchParticipant[]; + readonly content: string; + }> = []; + let resolveSend!: () => void; + + const participantsA = [ + { id: "a1", kind: "agent" as const, name: "Worker", address: "run_a@bench-a.localhost" }, + ]; + const pendingSend = dispatchOpeningMessage({ + queryClient, + workbenchTenantId: "tnt_bench_a", + participants: participantsA, + content: "Draft the Q4 launch note", + send: (args) => { + calls.push(args); + return new Promise((resolve) => { + resolveSend = resolve; + }); + }, + }); + + parkOpeningMessage("tnt_bench_b", "Audit the billing webhook"); + expect(queryClient.getQueryData(workbenchKeys.pendingTurn("tnt_bench_a"))).toBeDefined(); + expect(queryClient.getQueryData(workbenchKeys.pendingTurn("tnt_bench_b"))).toBeUndefined(); - clearOpeningMessage("tnt_bench_a"); + resolveSend(); + await pendingSend; + expect(calls).toEqual([ + { + workbenchTenantId: "tnt_bench_a", + participants: participantsA, + content: "Draft the Q4 launch note", + }, + ]); expect(readOpeningMessage("tnt_bench_a")).toBeNull(); expect(readOpeningMessage("tnt_bench_b")).toBe("Audit the billing webhook"); }); diff --git a/apps/web/src/bench/pending-opening-message.tsx b/apps/web/src/bench/pending-opening-message.tsx index e203816af..ae13790c6 100644 --- a/apps/web/src/bench/pending-opening-message.tsx +++ b/apps/web/src/bench/pending-opening-message.tsx @@ -3,7 +3,7 @@ // retry; it is only cleared once the send succeeds. import { Button } from "@corbits/react-ui"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useMutation, useQueryClient, type QueryClient } from "@tanstack/react-query"; import { useEffect, useRef, useState } from "react"; import { sendToWorkbench, type WorkbenchParticipant } from "@/chat/threads-api"; @@ -15,6 +15,58 @@ import "./pending-opening-message.css"; const CAP_MS = 60_000; +export async function dispatchOpeningMessage({ + queryClient, + workbenchTenantId, + participants, + content, + send = sendToWorkbench, +}: { + readonly queryClient: QueryClient; + readonly workbenchTenantId: string; + readonly participants: readonly WorkbenchParticipant[]; + readonly content: string; + readonly send?: typeof sendToWorkbench; +}): Promise { + markTurnPending(queryClient, workbenchTenantId); + try { + await send({ + workbenchTenantId, + participants, + content, + }); + } catch (cause) { + queryClient.setQueryData(workbenchKeys.pendingTurn(workbenchTenantId), null); + throw cause; + } + clearOpeningMessage(workbenchTenantId); + await queryClient.invalidateQueries({ queryKey: workbenchKeys.scope(workbenchTenantId) }); +} + +export interface PendingOpeningMessageSession { + readonly workbenchTenantId: string; + readonly text: string | null; + readonly timedOut: boolean; +} + +export function createPendingOpeningMessageSession( + workbenchTenantId: string, +): PendingOpeningMessageSession { + return { + workbenchTenantId, + text: readOpeningMessage(workbenchTenantId), + timedOut: false, + }; +} + +export function syncPendingOpeningMessageSession( + session: PendingOpeningMessageSession, + workbenchTenantId: string, +): PendingOpeningMessageSession { + if (session.workbenchTenantId === workbenchTenantId) return session; + return createPendingOpeningMessageSession(workbenchTenantId); +} + export function PendingOpeningMessage({ workbenchTenantId, participants, @@ -23,29 +75,49 @@ export function PendingOpeningMessage({ readonly participants: readonly WorkbenchParticipant[]; }) { const queryClient = useQueryClient(); - const [text, setText] = useState(() => readOpeningMessage(workbenchTenantId)); - const [timedOut, setTimedOut] = useState(false); - const attempted = useRef(false); + const [session, setSession] = useState(() => + createPendingOpeningMessageSession(workbenchTenantId), + ); + const current = syncPendingOpeningMessageSession(session, workbenchTenantId); + if (current !== session) { + setSession(current); + } + const attemptedTenantId = useRef(null); + const text = current.text; + const timedOut = current.timedOut; useEffect(() => { - const timer = setTimeout(() => setTimedOut(true), CAP_MS); + const timer = setTimeout( + () => + setSession((prev) => + prev.workbenchTenantId === workbenchTenantId ? { ...prev, timedOut: true } : prev, + ), + CAP_MS, + ); return () => clearTimeout(timer); - }, []); + }, [workbenchTenantId]); const send = useMutation({ - mutationFn: (content: string) => - sendToWorkbench({ - workbenchTenantId, - participants, + mutationFn: ({ + targetTenantId, + targetParticipants, + content, + }: { + readonly targetTenantId: string; + readonly targetParticipants: readonly WorkbenchParticipant[]; + readonly content: string; + }) => + dispatchOpeningMessage({ + queryClient, + workbenchTenantId: targetTenantId, + participants: targetParticipants, content, }), - onMutate: () => markTurnPending(queryClient, workbenchTenantId), - onSuccess: () => { - clearOpeningMessage(workbenchTenantId); - setText(null); - void queryClient.invalidateQueries({ queryKey: workbenchKeys.scope(workbenchTenantId) }); + onSuccess: (_data, variables) => { + setSession((prev) => + prev.workbenchTenantId === variables.targetTenantId ? { ...prev, text: null } : prev, + ); }, - onError: () => queryClient.setQueryData(workbenchKeys.pendingTurn(workbenchTenantId), null), }); const live = participants.some((p) => p.kind === "agent" && p.address.includes("@")); @@ -54,9 +126,14 @@ export function PendingOpeningMessage({ // warns), and StrictMode double-renders would double-send without the // ref guard. useEffect(() => { - if (text === null || !live || attempted.current || send.isPending) return; - attempted.current = true; - send.mutate(text); + if (text === null || !live || attemptedTenantId.current === workbenchTenantId || send.isPending) + return; + attemptedTenantId.current = workbenchTenantId; + send.mutate({ + targetTenantId: workbenchTenantId, + targetParticipants: participants, + content: text, + }); }); if (text === null) return null; @@ -76,12 +153,22 @@ export function PendingOpeningMessage({ variant="ghost" size="sm" onClick={() => { - if (live) send.mutate(text); - else + if (live) { + send.mutate({ + targetTenantId: workbenchTenantId, + targetParticipants: participants, + content: text, + }); + } else { void queryClient.invalidateQueries({ queryKey: workbenchKeys.participants(workbenchTenantId), }); - setTimedOut(false); + } + setSession((prev) => + prev.workbenchTenantId === workbenchTenantId + ? { ...prev, timedOut: false } + : prev, + ); }} > Retry diff --git a/apps/web/src/pages/new-workbench-picker.tsx b/apps/web/src/pages/new-workbench-picker.tsx index 36f36fc17..f462583be 100644 --- a/apps/web/src/pages/new-workbench-picker.tsx +++ b/apps/web/src/pages/new-workbench-picker.tsx @@ -96,7 +96,7 @@ export function NewWorkbenchPickerRoute() { create.mutate({ benchTenantId: selectedTenantId, openingMessage: text }); } - const composerDisabled = selectedTenantId === null; + const composerDisabled = selectedTenantId === null || workbenches.isPending; return (
diff --git a/apps/web/src/pages/workbench-page.tsx b/apps/web/src/pages/workbench-page.tsx index 0c1f2ced5..501a1ad97 100644 --- a/apps/web/src/pages/workbench-page.tsx +++ b/apps/web/src/pages/workbench-page.tsx @@ -527,5 +527,5 @@ export function WorkbenchRoute({ path }: { readonly path: string }) { ); } - return ; + return ; }