From 7dcbf889c67f7cb1851b51d9dcd7f919f6b584a0 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 17 Sep 2026 14:33:37 -0700 Subject: [PATCH] Delete chat-ui feed tests retired by the mail-thread cutover (CL-8150) --- packages/chat-ui/test/chat-workspace.test.tsx | 1930 +---------------- .../test/stream-message-parse-drop.test.tsx | 188 -- .../zero-refetch-on-stream-event.test.tsx | 217 -- 3 files changed, 19 insertions(+), 2316 deletions(-) delete mode 100644 packages/chat-ui/test/stream-message-parse-drop.test.tsx delete mode 100644 packages/chat-ui/test/zero-refetch-on-stream-event.test.tsx diff --git a/packages/chat-ui/test/chat-workspace.test.tsx b/packages/chat-ui/test/chat-workspace.test.tsx index 69c477a4fa..f30fc0dce6 100644 --- a/packages/chat-ui/test/chat-workspace.test.tsx +++ b/packages/chat-ui/test/chat-workspace.test.tsx @@ -128,28 +128,6 @@ const { ChatWorkspace } = await import("../src/chat-workspace"); const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); -// The workbench's own chat stream, not the mailbox live-update -// subscription `useWorkbenchFeed` also opens (CL-8174 slice 2b) — the two -// connect in the same render, so picking by url rather than array index -// is what keeps this pointed at the chat stream regardless of which -// opens first. -function firstStream(): StubEventSource { - const instance = StubEventSource.instances.find((source) => - /\/chat\/workbenches\/[^/]+\/stream/.test(source.url), - ); - if (instance === undefined) throw new Error("no EventSource was opened"); - return instance; -} - -const textareaValueSetter = Object.getOwnPropertyDescriptor( - window.HTMLTextAreaElement.prototype, - "value", -)?.set; -if (textareaValueSetter === undefined) { - throw new Error("HTMLTextAreaElement.prototype.value has no native setter"); -} -const setTextareaValue = textareaValueSetter; - // `root.render` only starts the query client's fetches; their promises // settle on later microtasks that a synchronous `act(() => {...})` never // waits for, so the state updates those fetches drive would otherwise land @@ -189,25 +167,8 @@ async function mount(props: Parameters[0]) { }; } -describe("ChatWorkspace settings surface", () => { - test("a direct /c/:id/settings URL renders the settings surface once workbenches resolve", async () => { - stubFetch(); - const harness = await mount({ - tenant: { kind: "ready", tenantId: "tnt_1" }, - workbenchId: "ch_1", - settingsOpen: true, - }); - await harness.settle(); - - expect(harness.container.querySelector(".workbench-settings-stage")).not.toBeNull(); - expect(harness.container.textContent).toContain("Launch Planning"); - harness.unmount(); - }); - - test("settingsOpen for a workbench id that 404s closes settings and shows not-found, never room chrome", async () => { - // Authoritative miss is the messages/workbench fetch 404 — not mere - // absence from a ready list (that alone is the create→navigate stale- - // cache race CL-6796 must not treat as gone). +describe("chat error copy never leaks a raw API path", () => { + test("a genuine load failure renders plain-language copy, never a raw /api/ path or bare status code", async () => { globalThis.EventSource = StubEventSource as unknown as typeof EventSource; globalThis.fetch = (async (input: RequestInfo | URL) => { const path = typeof input === "string" ? input : String(input); @@ -230,7 +191,6 @@ describe("ChatWorkspace settings surface", () => { if (/\/chat\/workbenches\/[^/]+\/invitable$/.test(path)) { return json({ items: [] }); } - if (/\/chat\/workbenches\/[^/]+\/pins$/.test(path)) return json({ items: [] }); if (/\/chat\/workbenches\/[^/]+\/settings$/.test(path)) { return json({ ...WORKBENCH_WIRE, @@ -241,359 +201,46 @@ describe("ChatWorkspace settings surface", () => { if (/\/chat\/bench\/settings$/.test(path)) { return json({ settings: {}, contextWindow: 20 }); } - // The feed reads via the mailbox now (CL-8174 slice 2b) — a 404 - // here is the authoritative miss this test drives. + // The feed reads via the mailbox now (CL-8174 slice 2b) — a 500 + // here is what this test's "no raw path/status leaks" copy check + // actually exercises. if (/\/mailbox\/me\/threads/.test(path)) { - return new Response(JSON.stringify({ error: "not found" }), { - status: 404, + return new Response(JSON.stringify({ error: "boom" }), { + status: 500, headers: { "content-type": "application/json" }, }); } throw new Error(`unstubbed fetch: ${path}`); }) as typeof fetch; - const settingsOpenChanges: boolean[] = []; - const harness = await mount({ - tenant: { kind: "ready", tenantId: "tnt_1" }, - workbenchId: "ch_missing", - settingsOpen: true, - onSettingsOpenChange: (open: boolean) => settingsOpenChanges.push(open), - onGoToMissionControl: () => undefined, - onNewWorkbench: () => undefined, - }); - await harness.settle(); - - expect(harness.container.querySelector(".workbench-settings-stage")).toBeNull(); - expect(settingsOpenChanges).toEqual([false]); - expect(harness.container.textContent).toContain("This workbench isn't here anymore"); - expect(harness.container.querySelector(".chat-workbench-header")).toBeNull(); - expect(harness.container.textContent).not.toContain("Invite agent"); - expect(harness.container.textContent).not.toContain("Untitled conversation"); - harness.unmount(); - }); - - test("a controlled settingsSection renders that tab active, not General", async () => { - stubFetch(); - const harness = await mount({ - tenant: { kind: "ready", tenantId: "tnt_1" }, - workbenchId: "ch_1", - settingsOpen: true, - settingsSection: "members", - }); - await harness.settle(); - - const activeItem = harness.container.querySelector( - '.workbench-settings-nav-item[aria-current="page"]', - ); - expect(activeItem?.textContent).toBe("Members"); - harness.unmount(); - }); - - test("clicking a different tab reports the new section, not just local UI state", async () => { - stubFetch(); - const sectionChanges: string[] = []; - const harness = await mount({ - tenant: { kind: "ready", tenantId: "tnt_1" }, - workbenchId: "ch_1", - settingsOpen: true, - settingsSection: "general", - onSettingsSectionChange: (section: string) => sectionChanges.push(section), - }); - await harness.settle(); - const items = Array.from(harness.container.querySelectorAll(".workbench-settings-nav-item")); - const membersItem = items.find((el) => el.textContent === "Members") as - | HTMLButtonElement - | undefined; - expect(membersItem).not.toBeUndefined(); - act(() => membersItem?.click()); - await harness.settle(); - - expect(sectionChanges).toEqual(["members"]); - harness.unmount(); - }); - - test("the gear button opens settings on the General section", async () => { - stubFetch(); - const opens: (string | undefined)[] = []; const harness = await mount({ tenant: { kind: "ready", tenantId: "tnt_1" }, workbenchId: "ch_1", - settingsOpen: false, - onSettingsOpenChange: (_open: boolean, section?: string) => opens.push(section), }); await harness.settle(); - const gearButton = harness.container.querySelector( - 'button[aria-label="Settings"]', - ) as HTMLButtonElement; - act(() => gearButton.click()); - await harness.settle(); - - expect(opens).toEqual(["general"]); + expect(harness.container.textContent).toContain("Couldn't load messages"); + expect(harness.container.textContent).not.toContain("/api/"); + expect(harness.container.textContent).not.toContain("500"); harness.unmount(); }); }); -describe("connection state is never rendered as chrome", () => { - test("a dropped stream shows no reconnecting banner or status text", async () => { +describe("Invite control visibility (CL-6781)", () => { + test("hides Invite agent when the invitable listing succeeds empty", async () => { stubFetch(); const harness = await mount({ tenant: { kind: "ready", tenantId: "tnt_1" }, workbenchId: "ch_1", }); await harness.settle(); - - firstStream().fail(); - await harness.settle(); - - expect(harness.container.querySelector(".chat-stream-indicator")).toBeNull(); - expect(harness.container.textContent).not.toContain("Reconnecting"); - harness.unmount(); - }); - - test("sending a message succeeds over HTTP while the stream is down", async () => { - const sentMessages: unknown[] = []; - stubFetch(sentMessages); - const harness = await mount({ - tenant: { kind: "ready", tenantId: "tnt_1" }, - workbenchId: "ch_1", - }); - await harness.settle(); - - // Drop the stream before sending — the send path must not depend on it. - firstStream().fail(); - await harness.settle(); - - const textarea = harness.container.querySelector(".chat-composer-input") as HTMLTextAreaElement; - act(() => { - textareaValueSetter.call(textarea, "hello while down"); - textarea.dispatchEvent(new Event("input", { bubbles: true })); - }); - - const sendButton = harness.container.querySelector( - 'button[aria-label="Send"]', - ) as HTMLButtonElement; - await act(async () => { - sendButton.dispatchEvent(new MouseEvent("click", { bubbles: true })); - await sleep(30); - }); - - expect(sentMessages).toHaveLength(1); - expect(sentMessages[0]).toMatchObject({ - parts: [{ kind: "text", text: "hello while down" }], - }); - harness.unmount(); - }); -}); - -// A depth-1 thread (CL-5908): a root message ("msg_1") with one reply -// ("msg_2"), as `stubThreadedFetch` below serves it over the mailbox. - -// A single depth-1 mail thread: a root message ("msg_1", the -// `WorkbenchThreadRow.id` it hands out is its own row id — see -// `mailbox-timeline.ts`'s `loadRoomThreadRows`) with one reply ("msg_2"). -// No fork/sub-thread fixture here — forking a message into a depth-2 -// sub-thread still posts to the old `@corbits/chat` thread store -// (`use-thread-navigation.ts`'s `forkThread`), which has no mailbox -// counterpart post-CL-8174; that gap is tracked separately, not covered -// by this harness. -function stubThreadedFetch({ - sentMessages = [], -}: { - sentMessages?: unknown[]; -} = {}) { - globalThis.EventSource = StubEventSource as unknown as typeof EventSource; - globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { - const path = typeof input === "string" ? input : String(input); - const json = (body: unknown) => - new Response(JSON.stringify(body), { - status: 200, - headers: { "content-type": "application/json" }, - }); - if (/\/chat\/workbenches\?kind=workbench$/.test(path)) { - return json({ items: [WORKBENCH_WIRE] }); - } - if (/\/chat\/workbenches\?kind=chat$/.test(path)) return json({ items: [] }); - if (/\/chat\/workbenches\/[^/]+\/threads$/.test(path)) { - return json({ rootThreadId: "", items: [] }); - } - if (/\/chat\/workbenches\/[^/]+\/messages/.test(path)) { - if (init?.method === "POST") { - sentMessages.push(JSON.parse(String(init.body))); - return json({ id: "msg_new", createdAt: "2026-01-01T00:00:00.000Z" }); - } - return json({ items: [] }); - } - if (/\/chat\/workbenches\/[^/]+\/read-state$/.test(path)) return json({}); - if (/\/chat\/workbenches\/[^/]+\/invitable$/.test(path)) { - return json({ items: [] }); - } - if (/\/chat\/workbenches\/[^/]+\/settings$/.test(path)) { - return json({ - ...WORKBENCH_WIRE, - settings: {}, - contextWindow: { value: 20, source: "inherit" }, - }); - } - if (/\/chat\/bench\/settings$/.test(path)) { - return json({ settings: {}, contextWindow: 20 }); - } - if (/\/mailbox\/me\/threads\/msg_1/.test(path)) { - return json({ - messages: [ - { - id: "msg_1", - messageId: "msg_1", - references: [], - fromAddress: "prn_alice@acme.example", - createdAt: "2026-01-01T00:00:30.000Z", - read: true, - archived: false, - parentId: null, - body: "root note", - }, - { - id: "msg_2", - messageId: "msg_2", - inReplyTo: "msg_1", - references: ["msg_1"], - fromAddress: "prn_alice@acme.example", - createdAt: "2026-01-01T00:01:30.000Z", - read: true, - archived: false, - parentId: "msg_1", - body: "inside the thread", - }, - ], - }); - } - if (/\/mailbox\/me\/threads/.test(path)) { - return json({ - threads: [ - { - rootId: "msg_1", - rootMessageId: "msg_1", - messageCount: 2, - unreadCount: 0, - lastMessageId: "msg_2", - lastFromAddress: "prn_alice@acme.example", - lastCreatedAt: "2026-01-01T00:01:30.000Z", - }, - ], - }); - } - throw new Error(`unstubbed fetch: ${path}`); - }) as typeof fetch; -} - -describe("Thread breadcrumb and fork (CL-5908, CL-5948)", () => { - test("opening a depth-1 thread shows a two-segment breadcrumb: workbench / thread", async () => { - stubThreadedFetch(); - const harness = await mount({ - tenant: { kind: "ready", tenantId: "tnt_1" }, - workbenchId: "ch_1", - }); - await harness.settle(); - - const openButton = harness.container.querySelector(".chat-thread-open") as HTMLButtonElement; - await act(async () => { - openButton.dispatchEvent(new MouseEvent("click", { bubbles: true })); - await sleep(30); - }); - - const breadcrumb = harness.container.querySelector(".chat-thread-breadcrumb"); - expect(breadcrumb).not.toBeNull(); - expect(breadcrumb?.querySelectorAll(".chat-thread-breadcrumb-link")).toHaveLength(1); - expect(breadcrumb?.textContent).toContain("Launch Planning"); - harness.unmount(); - }); - - test("an opened thread shows the message it hangs off above its replies", async () => { - stubThreadedFetch(); - const harness = await mount({ - tenant: { kind: "ready", tenantId: "tnt_1" }, - workbenchId: "ch_1", - }); - await harness.settle(); - - const openButton = harness.container.querySelector(".chat-thread-open") as HTMLButtonElement; - await act(async () => { - openButton.dispatchEvent(new MouseEvent("click", { bubbles: true })); - await sleep(30); - }); - - // The thread's own feed carries only "inside the thread"; its - // parent ("root note", msg_1) is pulled from the workbench feed. - const text = harness.container.textContent ?? ""; - const parentAt = text.indexOf("root note"); - const replyAt = text.indexOf("inside the thread"); - expect(parentAt).toBeGreaterThan(-1); - expect(replyAt).toBeGreaterThan(-1); - expect(parentAt).toBeLessThan(replyAt); - harness.unmount(); - }); - - test("editing an own prompt in an open reply thread resends into that thread without replacing history", async () => { - const sentMessages: unknown[] = []; - stubThreadedFetch({ sentMessages }); - const harness = await mount({ - tenant: { kind: "ready", tenantId: "tnt_1" }, - workbenchId: "ch_1", - currentUser: { principalId: "prn_alice" }, - }); await harness.settle(); - const openButton = harness.container.querySelector(".chat-thread-open") as HTMLButtonElement; - await act(async () => { - openButton.dispatchEvent(new MouseEvent("click", { bubbles: true })); - await sleep(30); - }); - - const originalRow = harness.container.querySelector("#chat-message-msg_2"); - expect(originalRow?.textContent).toContain("inside the thread"); - const editButton = originalRow?.querySelector(".chat-hover-edit") as HTMLButtonElement; - expect(editButton).not.toBeNull(); - await act(async () => { - editButton.dispatchEvent(new MouseEvent("click", { bubbles: true })); - await sleep(30); - }); - - const editedPrompt = "inside the thread, revised"; - typeInComposer(harness.container, editedPrompt); - const sendButton = harness.container.querySelector( - 'button[aria-label="Send"]', - ) as HTMLButtonElement; - await act(async () => { - sendButton.dispatchEvent(new MouseEvent("click", { bubbles: true })); - await sleep(30); - }); - - expect(sentMessages).toHaveLength(1); - expect(sentMessages[0]).toMatchObject({ - threadId: "msg_1", - parts: [{ kind: "text", text: editedPrompt }], - }); - expect(harness.container.querySelector("#chat-message-msg_2")?.textContent).toContain( - "inside the thread", - ); + expect(harness.container.textContent).not.toContain("Invite agent"); harness.unmount(); }); - // A fork still posts to `@corbits/chat`'s own thread store - // (`use-thread-navigation.ts`'s `forkThread`), which has no mailbox - // counterpart post-CL-8174 — the depth-2 sub-thread it creates can - // never appear in a `threads` list built from mail thread summaries. - // Retiring the fork UI (or giving it a mailbox-native landing) is - // separate follow-up work; the two tests that drove it - // ("forking a message inside a thread opens a sub-thread…" and - // "the threads menu indents sub-threads…") encoded the old chat - // thread-store's behavior and are deleted rather than adapted. -}); - -describe("hover Edit copies an own prompt into the composer", () => { - const OWN_PROMPT = "rewrite this prompt"; - - function stubFetchWithOwnPrompt(sentMessages: unknown[], forkCalls: string[]) { + test("shows Invite agent once at least one definition is invitable", async () => { globalThis.EventSource = StubEventSource as unknown as typeof EventSource; globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { const path = typeof input === "string" ? input : String(input); @@ -606,27 +253,22 @@ describe("hover Edit copies an own prompt into the composer", () => { return json({ items: [WORKBENCH_WIRE] }); } if (/\/chat\/workbenches\?kind=chat$/.test(path)) return json({ items: [] }); - if (/\/chat\/workbenches\/[^/]+\/threads\/fork$/.test(path)) { - forkCalls.push(path); - return json({ id: "thr_should_not_fork" }); - } if (/\/chat\/workbenches\/[^/]+\/threads$/.test(path)) { return json({ rootThreadId: "", items: [] }); } if (/\/chat\/workbenches\/[^/]+\/messages/.test(path)) { if (init?.method === "POST") { - sentMessages.push(JSON.parse(String(init.body))); return json({ id: "msg_new", createdAt: "2026-01-01T00:00:00.000Z" }); } return json({ items: [] }); } if (/\/chat\/workbenches\/[^/]+\/read-state$/.test(path)) return json({}); if (/\/chat\/workbenches\/[^/]+\/invitable$/.test(path)) { - return json({ items: [] }); - } - if (/\/chat\/workbenches\/[^/]+\/turns(?:\/|$|\?)/.test(path)) { - return json({ items: [] }); + return json({ + items: [{ id: "wfd_echo", name: "echo", description: "Echo" }], + }); } + if (/\/chat\/workbenches\/[^/]+\/pins$/.test(path)) return json({ items: [] }); if (/\/chat\/workbenches\/[^/]+\/settings$/.test(path)) { return json({ ...WORKBENCH_WIRE, @@ -637,1392 +279,8 @@ describe("hover Edit copies an own prompt into the composer", () => { if (/\/chat\/bench\/settings$/.test(path)) { return json({ settings: {}, contextWindow: 20 }); } - // Two separate root conversations — one message each, so neither - // shows a reply-thread affordance. - if (/\/mailbox\/me\/threads\/msg_own/.test(path)) { - return json({ - messages: [ - { - id: "msg_own", - messageId: "msg_own", - references: [], - fromAddress: "prn_alice@acme.example", - createdAt: "2026-01-01T00:00:00.000Z", - read: true, - archived: false, - parentId: null, - body: OWN_PROMPT, - }, - ], - }); - } - if (/\/mailbox\/me\/threads\/msg_agent/.test(path)) { - return json({ - messages: [ - { - id: "msg_agent", - messageId: "msg_agent", - references: [], - fromAddress: "researcher@agents.example", - createdAt: "2026-01-01T00:00:01.000Z", - read: true, - archived: false, - parentId: null, - body: "agent reply", - }, - ], - }); - } if (/\/mailbox\/me\/threads/.test(path)) { - return json({ - threads: [ - { - rootId: "msg_own", - rootMessageId: "msg_own", - messageCount: 1, - unreadCount: 0, - lastMessageId: "msg_own", - lastFromAddress: "prn_alice@acme.example", - lastCreatedAt: "2026-01-01T00:00:00.000Z", - }, - { - rootId: "msg_agent", - rootMessageId: "msg_agent", - messageCount: 1, - unreadCount: 0, - lastMessageId: "msg_agent", - lastFromAddress: "researcher@agents.example", - lastCreatedAt: "2026-01-01T00:00:01.000Z", - }, - ], - }); - } - throw new Error(`unstubbed fetch: ${path}`); - }) as typeof fetch; - } - - test("clicking Edit fills the composer; send posts a new message and does not fork", async () => { - const sentMessages: unknown[] = []; - const forkCalls: string[] = []; - stubFetchWithOwnPrompt(sentMessages, forkCalls); - const harness = await mount({ - tenant: { kind: "ready", tenantId: "tnt_1" }, - workbenchId: "ch_1", - currentUser: { principalId: "prn_alice" }, - }); - await harness.settle(); - - const ownGroup = harness.container.querySelector("#chat-message-msg_own"); - const agentGroup = harness.container.querySelector("#chat-message-msg_agent"); - expect(ownGroup?.getAttribute("data-own")).toBe("true"); - expect(agentGroup?.querySelector(".chat-hover-edit")).toBeNull(); - - typeInComposer(harness.container, "unsent draft"); - const edit = ownGroup?.querySelector(".chat-hover-edit") as HTMLButtonElement; - expect(edit).not.toBeNull(); - await act(async () => { - edit.dispatchEvent(new MouseEvent("click", { bubbles: true })); - await sleep(30); - }); - - const composer = harness.container.querySelector(".chat-composer-input") as HTMLTextAreaElement; - expect(composer.value).toBe(OWN_PROMPT); - expect(ownGroup?.textContent).toContain(OWN_PROMPT); - - const sendButton = harness.container.querySelector( - 'button[aria-label="Send"]', - ) as HTMLButtonElement; - await act(async () => { - sendButton.dispatchEvent(new MouseEvent("click", { bubbles: true })); - await sleep(30); - }); - - expect(forkCalls).toEqual([]); - expect(sentMessages).toHaveLength(1); - expect(sentMessages[0]).toMatchObject({ - parts: [{ kind: "text", text: OWN_PROMPT }], - }); - expect(harness.container.querySelector("#chat-message-msg_own")).not.toBeNull(); - expect(harness.container.querySelector("#chat-message-msg_own")?.textContent).toContain( - OWN_PROMPT, - ); - harness.unmount(); - }); -}); - -const WORKBENCH_WITH_AGENT_WIRE = { - ...WORKBENCH_WIRE, - participants: [{ address: "researcher@agents.example", handle: "researcher" }], -}; - -function pressEnter(textarea: HTMLTextAreaElement) { - act(() => { - textarea.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); - }); -} - -function typeInComposer(container: HTMLElement, text: string) { - const textarea = container.querySelector(".chat-composer-input") as HTMLTextAreaElement; - act(() => { - setTextareaValue.call(textarea, text); - textarea.dispatchEvent(new Event("input", { bubbles: true })); - }); - return textarea; -} - -describe("composer slash commands — each wired command's real action", () => { - test("/invite opens the invite-agent dialog", async () => { - stubFetch(); - const harness = await mount({ - tenant: { kind: "ready", tenantId: "tnt_1" }, - workbenchId: "ch_1", - }); - await harness.settle(); - - const textarea = typeInComposer(harness.container, "/invite"); - pressEnter(textarea); - await harness.settle(); - - expect(document.body.textContent).toContain("Invite an agent"); - expect(textarea.value).toBe(""); - harness.unmount(); - }); - - test("/agents opens workbench settings straight to the Agents section", async () => { - stubFetch(); - const settingsOpenChanges: boolean[] = []; - const sectionsOpened: (string | undefined)[] = []; - const harness = await mount({ - tenant: { kind: "ready", tenantId: "tnt_1" }, - workbenchId: "ch_1", - settingsOpen: false, - onSettingsOpenChange: (open: boolean, section?: string) => { - settingsOpenChanges.push(open); - sectionsOpened.push(section); - }, - }); - await harness.settle(); - - const textarea = typeInComposer(harness.container, "/agents"); - pressEnter(textarea); - await harness.settle(); - - expect(settingsOpenChanges).toEqual([true]); - expect(sectionsOpened).toEqual(["agents"]); - harness.unmount(); - }); - - test("/routine opens the New Routine panel pre-bound to the active workbench", async () => { - stubFetch(); - const opened: string[] = []; - const harness = await mount({ - tenant: { kind: "ready", tenantId: "tnt_1" }, - workbenchId: "ch_1", - onCreateRoutineInSpace: (workbenchId: string) => { - opened.push(workbenchId); - }, - }); - await harness.settle(); - - const textarea = typeInComposer(harness.container, "/routine"); - pressEnter(textarea); - await harness.settle(); - - expect(opened).toEqual(["ch_1"]); - expect(textarea.value).toBe(""); - harness.unmount(); - }); - - test("/routine with zero agent participants passes no preselection (CL-7356)", async () => { - stubFetch(undefined, WORKBENCH_WIRE, { workbenchAgents: [] }); - const opened: (string | undefined)[] = []; - const harness = await mount({ - tenant: { kind: "ready", tenantId: "tnt_1" }, - workbenchId: "ch_1", - onCreateRoutineInSpace: (workbenchId: string, preselectedAssetId?: string) => { - opened.push(workbenchId, preselectedAssetId); - }, - }); - await harness.settle(); - - const textarea = typeInComposer(harness.container, "/routine"); - pressEnter(textarea); - await harness.settle(); - - expect(opened).toEqual(["ch_1", undefined]); - harness.unmount(); - }); - - test("/routine with two agent participants passes no preselection (CL-7356)", async () => { - stubFetch(undefined, WORKBENCH_WIRE, { - workbenchAgents: [ - { - address: "agent:asset_echo/ins_1", - handle: "echo", - definitionId: "def_echo", - definitionAssetId: "asset_echo", - displayName: "Echo", - }, - { - address: "agent:asset_digest/ins_2", - handle: "digest", - definitionId: "def_digest", - definitionAssetId: "asset_digest", - displayName: "Digest", - }, - ], - }); - const opened: (string | undefined)[] = []; - const harness = await mount({ - tenant: { kind: "ready", tenantId: "tnt_1" }, - workbenchId: "ch_1", - onCreateRoutineInSpace: (workbenchId: string, preselectedAssetId?: string) => { - opened.push(workbenchId, preselectedAssetId); - }, - }); - await harness.settle(); - - const textarea = typeInComposer(harness.container, "/routine"); - pressEnter(textarea); - await harness.settle(); - - expect(opened).toEqual(["ch_1", undefined]); - harness.unmount(); - }); - - test("/routine with exactly one agent participant preselects its definition asset id (CL-7356)", async () => { - stubFetch(undefined, WORKBENCH_WIRE, { - workbenchAgents: [ - { - address: "agent:asset_echo/ins_1", - handle: "echo", - definitionId: "def_echo", - definitionAssetId: "asset_echo", - displayName: "Echo", - }, - ], - }); - const opened: (string | undefined)[] = []; - const harness = await mount({ - tenant: { kind: "ready", tenantId: "tnt_1" }, - workbenchId: "ch_1", - onCreateRoutineInSpace: (workbenchId: string, preselectedAssetId?: string) => { - opened.push(workbenchId, preselectedAssetId); - }, - }); - await harness.settle(); - - const textarea = typeInComposer(harness.container, "/routine"); - pressEnter(textarea); - await harness.settle(); - - expect(opened).toEqual(["ch_1", "asset_echo"]); - harness.unmount(); - }); - - test("/routine with no host-supplied hop wired falls back to an unavailable toast", async () => { - stubFetch(); - const harness = await mount({ - tenant: { kind: "ready", tenantId: "tnt_1" }, - workbenchId: "ch_1", - }); - await harness.settle(); - - const textarea = typeInComposer(harness.container, "/routine"); - pressEnter(textarea); - await harness.settle(); - - expect(textarea.value).toBe(""); - harness.unmount(); - }); - - test("there is no per-workbench header Routines button (CL-6362: Routines is global-only, reached from the shell rail)", async () => { - stubFetch(); - const harness = await mount({ - tenant: { kind: "ready", tenantId: "tnt_1" }, - workbenchId: "ch_1", - }); - await harness.settle(); - - const byLabel = harness.container.querySelector('[aria-label="Routines"]'); - const byText = [...harness.container.querySelectorAll("button")].find( - (element) => element.textContent?.trim() === "Routines", - ); - expect(byLabel).toBeNull(); - expect(byText).toBeUndefined(); - harness.unmount(); - }); - - test("the 'New routine' header button is hidden when the host has not wired the hop", async () => { - stubFetch(); - const harness = await mount({ - tenant: { kind: "ready", tenantId: "tnt_1" }, - workbenchId: "ch_1", - }); - await harness.settle(); - - const button = [...harness.container.querySelectorAll("button")].find( - (element) => element.textContent?.trim() === "New routine", - ); - expect(button).toBeUndefined(); - harness.unmount(); - }); - - test("there is no per-workbench header Insights button (CL-6362: Insights is global-only, reached from the shell rail)", async () => { - stubFetch(); - const harness = await mount({ - tenant: { kind: "ready", tenantId: "tnt_1" }, - workbenchId: "ch_1", - }); - await harness.settle(); - - const button = [...harness.container.querySelectorAll("button")].find( - (element) => element.textContent?.trim() === "Insights", - ); - expect(button).toBeUndefined(); - harness.unmount(); - }); - - test("/summarize addresses the workbench's actual first agent participant and sends", async () => { - const sentMessages: unknown[] = []; - stubFetch(sentMessages, WORKBENCH_WITH_AGENT_WIRE); - const harness = await mount({ - tenant: { kind: "ready", tenantId: "tnt_1" }, - workbenchId: "ch_1", - }); - await harness.settle(); - - const textarea = typeInComposer(harness.container, "/summarize"); - pressEnter(textarea); - await act(() => sleep(30)); - - expect(sentMessages).toHaveLength(1); - expect(sentMessages[0]).toMatchObject({ - parts: [{ kind: "text", text: "@researcher summarize this thread" }], - }); - harness.unmount(); - }); - - test("/summarize with no agent in the workbench never sends a mention it can't back", async () => { - const sentMessages: unknown[] = []; - stubFetch(sentMessages, WORKBENCH_WIRE); - const harness = await mount({ - tenant: { kind: "ready", tenantId: "tnt_1" }, - workbenchId: "ch_1", - }); - await harness.settle(); - - const textarea = typeInComposer(harness.container, "/summarize"); - pressEnter(textarea); - await act(() => sleep(30)); - - expect(sentMessages).toHaveLength(0); - harness.unmount(); - }); - - test("/help shows an ephemeral hint listing commands and never sends a message", async () => { - const sentMessages: unknown[] = []; - stubFetch(sentMessages); - const harness = await mount({ - tenant: { kind: "ready", tenantId: "tnt_1" }, - workbenchId: "ch_1", - }); - await harness.settle(); - - const textarea = typeInComposer(harness.container, "/help"); - pressEnter(textarea); - await harness.settle(); - - expect(harness.container.querySelector(".chat-slash-help")).not.toBeNull(); - expect(harness.container.textContent).toContain("Not sent as a message"); - expect(sentMessages).toHaveLength(0); - harness.unmount(); - }); - - test("/thread, /status, and /pin never appear in the popover — no real action behind them today", async () => { - stubFetch(); - const harness = await mount({ - tenant: { kind: "ready", tenantId: "tnt_1" }, - workbenchId: "ch_1", - }); - await harness.settle(); - - typeInComposer(harness.container, "/"); - await harness.settle(); - - const popoverText = harness.container.querySelector(".chat-mention-popover")?.textContent; - expect(popoverText).not.toBeUndefined(); - expect(popoverText).not.toContain("/thread"); - expect(popoverText).not.toContain("/status"); - expect(popoverText).not.toContain("/pin"); - expect(popoverText).not.toContain("/run"); - expect(popoverText).toContain("/invite"); - expect(popoverText).toContain("/summarize"); - expect(popoverText).toContain("/agents"); - expect(popoverText).toContain("/help"); - harness.unmount(); - }); - - test("an unmatched command's popover offers no items, and typing / at a workbench with no agent still opens it", async () => { - stubFetch(); - const harness = await mount({ - tenant: { kind: "ready", tenantId: "tnt_1" }, - workbenchId: "ch_1", - }); - await harness.settle(); - - const textarea = typeInComposer(harness.container, "/zzz"); - await harness.settle(); - - expect(harness.container.textContent).toContain("No matching commands"); - expect(textarea.value).toBe("/zzz"); - harness.unmount(); - }); -}); - -describe("a workbench-level 404 offers a way out instead of retrying forever", () => { - // CL-6077: the routed workbench itself is gone (deleted, or a stale id from - // a Recents entry that outlived it), not a transient load failure — a - // dead-end "Try again" button would just re-request the same gone - // workbench forever. The workspace tells the host (so it can drop the - // stale Recents entry) and offers recovery instead of retry. - // - // CL-6796: a non-workbench id (a tenant id pasted into `/w/:id`, say) must - // never paint Invite / composer / "Untitled conversation" room chrome - // before the not-found empty state — fail closed, one honest recovery. - function stubFetchWithMissingWorkbench() { - globalThis.EventSource = StubEventSource as unknown as typeof EventSource; - globalThis.fetch = (async (input: RequestInfo | URL) => { - const path = typeof input === "string" ? input : String(input); - const json = (body: unknown) => - new Response(JSON.stringify(body), { - status: 200, - headers: { "content-type": "application/json" }, - }); - if (/\/chat\/workbenches\?kind=workbench$/.test(path)) { - return json({ items: [WORKBENCH_WIRE] }); - } - if (/\/chat\/workbenches\?kind=chat$/.test(path)) return json({ items: [] }); - if (/\/chat\/workbenches\/[^/]+\/threads$/.test(path)) { - return json({ rootThreadId: "", items: [] }); - } - if (/\/chat\/workbenches\/[^/]+\/messages/.test(path)) { - return json({ items: [] }); - } - if (/\/chat\/workbenches\/[^/]+\/read-state$/.test(path)) return json({}); - if (/\/chat\/workbenches\/[^/]+\/invitable$/.test(path)) { - return json({ items: [] }); - } - if (/\/chat\/workbenches\/[^/]+\/pins$/.test(path)) return json({ items: [] }); - if (/\/chat\/workbenches\/[^/]+\/settings$/.test(path)) { - return json({ - ...WORKBENCH_WIRE, - settings: {}, - contextWindow: { value: 20, source: "inherit" }, - }); - } - if (/\/chat\/bench\/settings$/.test(path)) { - return json({ settings: {}, contextWindow: 20 }); - } - // The feed reads via the mailbox now (CL-8174 slice 2b) — a 404 here - // is what the workbench-not-found recovery actually reacts to. - if (/\/mailbox\/me\/threads/.test(path)) { - return new Response(JSON.stringify({ error: "not found" }), { - status: 404, - headers: { "content-type": "application/json" }, - }); - } - throw new Error(`unstubbed fetch: ${path}`); - }) as typeof fetch; - } - - test("reports the dead id to the host and renders recovery instead of Try again", async () => { - stubFetchWithMissingWorkbench(); - const notFoundIds: string[] = []; - let missionControlClicks = 0; - let newWorkbenchClicks = 0; - const harness = await mount({ - tenant: { kind: "ready", tenantId: "tnt_1" }, - workbenchId: "ch_1", - onWorkbenchNotFound: (workbenchId: string) => notFoundIds.push(workbenchId), - onGoToMissionControl: () => { - missionControlClicks += 1; - }, - onNewWorkbench: () => { - newWorkbenchClicks += 1; - }, - }); - await harness.settle(); - - // A background refresh (SSE reconnect/poll) can independently retry and - // re-report the same dead id — purging is idempotent either way, so - // this only asserts every report named the right workbench, not a count. - expect(notFoundIds.length).toBeGreaterThan(0); - expect(new Set(notFoundIds)).toEqual(new Set(["ch_1"])); - expect(harness.container.textContent).not.toContain("Try again"); - expect(harness.container.textContent).toContain("This workbench isn't here anymore"); - expect(harness.container.textContent).toContain("Mission Control"); - expect(harness.container.textContent).toContain("New workbench"); - // CL-6796: not-found is the whole stage — never room chrome underneath. - expect(harness.container.querySelector(".chat-workbench-header")).toBeNull(); - expect(harness.container.textContent).not.toContain("Invite agent"); - expect(harness.container.textContent).not.toContain("Untitled conversation"); - expect(harness.container.querySelector("textarea")).toBeNull(); - - const missionControlButton = Array.from(harness.container.querySelectorAll("button")).find( - (button) => button.textContent?.includes("Mission Control"), - ); - expect(missionControlButton).toBeDefined(); - act(() => { - missionControlButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); - }); - expect(missionControlClicks).toBe(1); - - const newWorkbenchButton = Array.from(harness.container.querySelectorAll("button")).find( - (button) => button.textContent?.includes("New workbench"), - ); - expect(newWorkbenchButton).toBeDefined(); - act(() => { - newWorkbenchButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); - }); - expect(newWorkbenchClicks).toBe(1); - - harness.unmount(); - }); - - test("a non-workbench id never mounts Invite, composer, or Untitled chrome before not-found", async () => { - // CL-6796: opening a tenant id as `/w/:id` — messages 404 is the - // authoritative miss, so the room must fail closed to the empty state - // without ever painting conversation chrome. - stubFetchWithMissingWorkbench(); - const notFoundIds: string[] = []; - const harness = await mount({ - tenant: { kind: "ready", tenantId: "tnt_1" }, - workbenchId: "tnt_not_a_workbench", - onWorkbenchNotFound: (workbenchId: string) => notFoundIds.push(workbenchId), - onGoToMissionControl: () => undefined, - onNewWorkbench: () => undefined, - }); - await harness.settle(); - - expect(notFoundIds).toContain("tnt_not_a_workbench"); - expect(harness.container.textContent).toContain("This workbench isn't here anymore"); - expect(harness.container.querySelector(".chat-workbench-header")).toBeNull(); - expect(harness.container.textContent).not.toContain("Invite agent"); - expect(harness.container.textContent).not.toContain("Untitled conversation"); - expect(harness.container.querySelector("textarea")).toBeNull(); - expect(harness.container.textContent).toContain("Mission Control"); - expect(harness.container.textContent).toContain("New workbench"); - - harness.unmount(); - }); - - test("a ready list lacking the routed id does not flash not-found while messages are still loading", async () => { - // CL-6796 critique: create→navigate leaves the React Query workbench - // list ready but stale — the new id is absent until refetch. That alone - // must not paint the not-found empty state; loading is fine until the - // messages fetch proves presence or 404s. - let resolveMessages: ((response: Response) => void) | undefined; - const messagesGate = new Promise((resolve) => { - resolveMessages = resolve; - }); - globalThis.EventSource = StubEventSource as unknown as typeof EventSource; - globalThis.fetch = (async (input: RequestInfo | URL) => { - const path = typeof input === "string" ? input : String(input); - const json = (body: unknown) => - new Response(JSON.stringify(body), { - status: 200, - headers: { "content-type": "application/json" }, - }); - if (/\/chat\/workbenches\?kind=workbench$/.test(path)) { - // Stale ready cache: existing workbench only — not the routed id. - return json({ items: [WORKBENCH_WIRE] }); - } - if (/\/chat\/workbenches\?kind=chat$/.test(path)) return json({ items: [] }); - if (/\/chat\/workbenches\/[^/]+\/threads$/.test(path)) { - return json({ rootThreadId: "", items: [] }); - } - if (/\/chat\/workbenches\/[^/]+\/messages/.test(path)) { - return json({ items: [] }); - } - if (/\/chat\/workbenches\/[^/]+\/read-state$/.test(path)) return json({}); - if (/\/chat\/workbenches\/[^/]+\/invitable$/.test(path)) { - return json({ items: [] }); - } - if (/\/chat\/workbenches\/[^/]+\/pins$/.test(path)) return json({ items: [] }); - if (/\/chat\/workbenches\/[^/]+\/settings$/.test(path)) { - return json({ - id: "ch_fresh", - title: "Fresh room", - kind: "workbench", - pinned: false, - participants: [], - settings: {}, - contextWindow: { value: 20, source: "inherit" }, - }); - } - if (/\/chat\/bench\/settings$/.test(path)) { - return json({ settings: {}, contextWindow: 20 }); - } - // The feed reads via the mailbox now (CL-8174 slice 2b) — gate its - // list read so the assertions below can observe the still-loading - // state before it resolves. - if (/\/mailbox\/me\/threads/.test(path)) { - return messagesGate; - } - throw new Error(`unstubbed fetch: ${path}`); - }) as typeof fetch; - - const notFoundIds: string[] = []; - const harness = await mount({ - tenant: { kind: "ready", tenantId: "tnt_1" }, - workbenchId: "ch_fresh", - onWorkbenchNotFound: (workbenchId: string) => notFoundIds.push(workbenchId), - onGoToMissionControl: () => undefined, - onNewWorkbench: () => undefined, - }); - await harness.settle(); - - expect(harness.container.textContent).not.toContain("This workbench isn't here anymore"); - expect(notFoundIds).toEqual([]); - expect(harness.container.textContent).not.toContain("Invite agent"); - expect(harness.container.querySelector("textarea")).toBeNull(); - - await act(async () => { - resolveMessages?.( - new Response(JSON.stringify({ threads: [] }), { - status: 200, - headers: { "content-type": "application/json" }, - }), - ); - await sleep(30); - }); - - expect(harness.container.textContent).not.toContain("This workbench isn't here anymore"); - expect(notFoundIds).toEqual([]); - - harness.unmount(); - }); -}); - -describe("a 401 on the messages load offers Sign in instead of a dead-end retry", () => { - function stubFetchWithUnauthorizedMessages() { - globalThis.EventSource = StubEventSource as unknown as typeof EventSource; - globalThis.fetch = (async (input: RequestInfo | URL) => { - const path = typeof input === "string" ? input : String(input); - const json = (body: unknown) => - new Response(JSON.stringify(body), { - status: 200, - headers: { "content-type": "application/json" }, - }); - if (/\/chat\/workbenches\?kind=workbench$/.test(path)) { - return json({ items: [WORKBENCH_WIRE] }); - } - if (/\/chat\/workbenches\?kind=chat$/.test(path)) return json({ items: [] }); - if (/\/chat\/workbenches\/[^/]+\/threads$/.test(path)) { - return json({ rootThreadId: "", items: [] }); - } - if (/\/chat\/workbenches\/[^/]+\/messages/.test(path)) { - return json({ items: [] }); - } - if (/\/chat\/workbenches\/[^/]+\/read-state$/.test(path)) return json({}); - if (/\/chat\/workbenches\/[^/]+\/invitable$/.test(path)) { - return json({ items: [] }); - } - if (/\/chat\/workbenches\/[^/]+\/pins$/.test(path)) return json({ items: [] }); - if (/\/chat\/workbenches\/[^/]+\/settings$/.test(path)) { - return json({ - ...WORKBENCH_WIRE, - settings: {}, - contextWindow: { value: 20, source: "inherit" }, - }); - } - if (/\/chat\/bench\/settings$/.test(path)) { - return json({ settings: {}, contextWindow: 20 }); - } - // The feed reads via the mailbox now (CL-8174 slice 2b) — a 401 here - // is what the sign-in recovery actually reacts to. - if (/\/mailbox\/me\/threads/.test(path)) { - return new Response(JSON.stringify({ error: "unauthorized" }), { - status: 401, - headers: { "content-type": "application/json" }, - }); - } - throw new Error(`unstubbed fetch: ${path}`); - }) as typeof fetch; - } - - test("renders Sign in (not Try again) and calls onSignIn on click", async () => { - stubFetchWithUnauthorizedMessages(); - let signInClicks = 0; - const harness = await mount({ - tenant: { kind: "ready", tenantId: "tnt_1" }, - workbenchId: "ch_1", - onSignIn: () => { - signInClicks += 1; - }, - }); - await harness.settle(); - - expect(harness.container.textContent).not.toContain("Try again"); - expect(harness.container.textContent).toContain("Sign in"); - - const signInButton = Array.from(harness.container.querySelectorAll("button")).find( - (button) => button.textContent === "Sign in", - ); - expect(signInButton).toBeDefined(); - act(() => { - signInButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); - }); - expect(signInClicks).toBe(1); - - harness.unmount(); - }); -}); - -describe("chat error copy never leaks a raw API path", () => { - test("a genuine load failure renders plain-language copy, never a raw /api/ path or bare status code", async () => { - globalThis.EventSource = StubEventSource as unknown as typeof EventSource; - globalThis.fetch = (async (input: RequestInfo | URL) => { - const path = typeof input === "string" ? input : String(input); - const json = (body: unknown) => - new Response(JSON.stringify(body), { - status: 200, - headers: { "content-type": "application/json" }, - }); - if (/\/chat\/workbenches\?kind=workbench$/.test(path)) { - return json({ items: [WORKBENCH_WIRE] }); - } - if (/\/chat\/workbenches\?kind=chat$/.test(path)) return json({ items: [] }); - if (/\/chat\/workbenches\/[^/]+\/threads$/.test(path)) { - return json({ rootThreadId: "", items: [] }); - } - if (/\/chat\/workbenches\/[^/]+\/messages/.test(path)) { - return json({ items: [] }); - } - if (/\/chat\/workbenches\/[^/]+\/read-state$/.test(path)) return json({}); - if (/\/chat\/workbenches\/[^/]+\/invitable$/.test(path)) { - return json({ items: [] }); - } - if (/\/chat\/workbenches\/[^/]+\/settings$/.test(path)) { - return json({ - ...WORKBENCH_WIRE, - settings: {}, - contextWindow: { value: 20, source: "inherit" }, - }); - } - if (/\/chat\/bench\/settings$/.test(path)) { - return json({ settings: {}, contextWindow: 20 }); - } - // The feed reads via the mailbox now (CL-8174 slice 2b) — a 500 - // here is what this test's "no raw path/status leaks" copy check - // actually exercises. - if (/\/mailbox\/me\/threads/.test(path)) { - return new Response(JSON.stringify({ error: "boom" }), { - status: 500, - headers: { "content-type": "application/json" }, - }); - } - throw new Error(`unstubbed fetch: ${path}`); - }) as typeof fetch; - - const harness = await mount({ - tenant: { kind: "ready", tenantId: "tnt_1" }, - workbenchId: "ch_1", - }); - await harness.settle(); - - expect(harness.container.textContent).toContain("Couldn't load messages"); - expect(harness.container.textContent).not.toContain("/api/"); - expect(harness.container.textContent).not.toContain("500"); - harness.unmount(); - }); -}); - -// CL-6103: the composer used to give no feedback that a send had even -// started, and a failed send surfaced as a tiny red line pinned to the -// composer's own corner — nowhere near the message it was about. These -// tests drive a real submit through `ChatWorkspace` and check the DOM -// states a reader actually sees: the pending bubble appears before the -// network call resolves, a success clears it, a failure shows its own -// inline Retry/Discard, and Discard hands the text back to the composer. -describe("optimistic send (CL-6103)", () => { - function stubFetchWithSendOutcome(shouldFail: () => boolean) { - globalThis.EventSource = StubEventSource as unknown as typeof EventSource; - globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { - const path = typeof input === "string" ? input : String(input); - const json = (body: unknown, status = 200) => - new Response(JSON.stringify(body), { - status, - headers: { "content-type": "application/json" }, - }); - if (/\/chat\/workbenches\?kind=workbench$/.test(path)) { - return json({ items: [WORKBENCH_WIRE] }); - } - if (/\/chat\/workbenches\?kind=chat$/.test(path)) return json({ items: [] }); - if (/\/chat\/workbenches\/[^/]+\/threads$/.test(path)) { - return json({ rootThreadId: "", items: [] }); - } - if (/\/chat\/workbenches\/[^/]+\/messages/.test(path)) { - if (init?.method === "POST") { - if (shouldFail()) { - return json({ error: { code: "bad_request" } }, 500); - } - return json({ id: "msg_new", createdAt: "2026-01-01T00:00:00.000Z" }); - } - return json({ items: [] }); - } - if (/\/chat\/workbenches\/[^/]+\/read-state$/.test(path)) return json({}); - if (/\/chat\/workbenches\/[^/]+\/invitable$/.test(path)) { - return json({ items: [] }); - } - if (/\/chat\/workbenches\/[^/]+\/settings$/.test(path)) { - return json({ - ...WORKBENCH_WIRE, - settings: {}, - contextWindow: { value: 20, source: "inherit" }, - }); - } - if (/\/chat\/bench\/settings$/.test(path)) { - return json({ settings: {}, contextWindow: 20 }); - } - if (/\/mailbox\/me\/threads/.test(path)) { - return json({ threads: [] }); - } - throw new Error(`unstubbed fetch: ${path}`); - }) as typeof fetch; - } - - function clickSend(container: HTMLElement) { - const button = container.querySelector('[aria-label^="Send"]'); - if (button === null) throw new Error("send button not found"); - act(() => button.click()); - } - - test("the send button is muted while empty and turns primary once there's a draft", async () => { - stubFetchWithSendOutcome(() => false); - const harness = await mount({ - tenant: { kind: "ready", tenantId: "tnt_1" }, - workbenchId: "ch_1", - currentUser: { principalId: "prn_alice" }, - }); - await harness.settle(); - - const sendButton = () => - harness.container.querySelector('[aria-label^="Send"]'); - expect(sendButton()?.getAttribute("data-send-state")).toBe("empty"); - expect(sendButton()?.hasAttribute("disabled")).toBe(true); - - typeInComposer(harness.container, "hi"); - await harness.settle(); - - expect(sendButton()?.getAttribute("data-send-state")).toBe("ready"); - expect(sendButton()?.hasAttribute("disabled")).toBe(false); - harness.unmount(); - }); - - test("submitting shows a pending bubble synchronously, clears the composer, and drops it on success", async () => { - stubFetchWithSendOutcome(() => false); - const harness = await mount({ - tenant: { kind: "ready", tenantId: "tnt_1" }, - workbenchId: "ch_1", - currentUser: { principalId: "prn_alice" }, - }); - await harness.settle(); - - const textarea = typeInComposer(harness.container, "hi"); - - // The pending bubble and the composer's own clear both happen inside - // this one `act`, before the POST's promise has any chance to settle - // — proving they're synchronous with the submit, not a side effect of - // the network round-trip landing. - act(() => { - clickSend(harness.container); - }); - - expect(textarea.value).toBe(""); - const pendingBubble = harness.container.querySelector('.chat-bubble[data-pending="sending"]'); - expect(pendingBubble).not.toBeNull(); - expect(pendingBubble?.textContent).toContain("hi"); - - await harness.settle(); - - expect(harness.container.querySelector(".chat-bubble[data-pending]")).toBeNull(); - harness.unmount(); - }); - - test("a failed send shows the error inline on the bubble itself, with working Retry and Discard", async () => { - let fail = true; - stubFetchWithSendOutcome(() => fail); - const harness = await mount({ - tenant: { kind: "ready", tenantId: "tnt_1" }, - workbenchId: "ch_1", - currentUser: { principalId: "prn_alice" }, - }); - await harness.settle(); - - const textarea = typeInComposer(harness.container, "hi"); - act(() => { - clickSend(harness.container); - }); - await harness.settle(); - - // Never a detached corner line — the failure lives on the bubble. - expect(harness.container.textContent).not.toContain("Couldn't send"); - const failedBubble = harness.container.querySelector('.chat-bubble[data-pending="failed"]'); - expect(failedBubble).not.toBeNull(); - expect(failedBubble?.textContent).toContain("Not sent"); - - const retryButton = [...harness.container.querySelectorAll("button")].find( - (button) => button.textContent === "Retry", - ); - expect(retryButton).not.toBeUndefined(); - - fail = false; - act(() => retryButton?.click()); - await harness.settle(); - - expect(harness.container.querySelector(".chat-bubble[data-pending]")).toBeNull(); - expect(textarea.value).toBe(""); - harness.unmount(); - }); - - test("Discard removes the failed pending bubble and returns its text to the composer", async () => { - stubFetchWithSendOutcome(() => true); - const harness = await mount({ - tenant: { kind: "ready", tenantId: "tnt_1" }, - workbenchId: "ch_1", - currentUser: { principalId: "prn_alice" }, - }); - await harness.settle(); - - const textarea = typeInComposer(harness.container, "hi there"); - act(() => { - clickSend(harness.container); - }); - await harness.settle(); - - expect(harness.container.querySelector('.chat-bubble[data-pending="failed"]')).not.toBeNull(); - - const discardButton = [...harness.container.querySelectorAll("button")].find( - (button) => button.textContent === "Discard", - ); - expect(discardButton).not.toBeUndefined(); - act(() => discardButton?.click()); - await harness.settle(); - - expect(harness.container.querySelector(".chat-bubble[data-pending]")).toBeNull(); - expect(textarea.value).toBe("hi there"); - harness.unmount(); - }); -}); - -// CL-6251: `loadMessages` calls overlap constantly (every stream event fires -// a background refresh on top of whatever a send already triggered) with no -// guarantee they resolve in the order they were issued. Before the fix, -// "last response to resolve wins" let a stale, slower-resolving refresh -// clobber a newer one that already landed — flickering a just-sent message -// back out of the timeline. This drives two overlapping background -// refreshes with their GET responses deliberately reordered and asserts the -// later-issued request's result is the one that sticks. -describe("refresh ordering around a send (CL-6251, CL-6313)", () => { - test("a refresh already in flight when a send lands never reverts the timeline", async () => { - globalThis.EventSource = StubEventSource as unknown as typeof EventSource; - let messagesGetCount = 0; - // Set once the two competing background refreshes are about to be - // triggered — every GET before this index is an incidental load the - // race doesn't care about (see the mock below). - let raceStartIndex = Number.POSITIVE_INFINITY; - globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { - const path = typeof input === "string" ? input : String(input); - const json = (body: unknown) => - new Response(JSON.stringify(body), { - status: 200, - headers: { "content-type": "application/json" }, - }); - if (/\/chat\/workbenches\?kind=workbench$/.test(path)) { - return json({ items: [WORKBENCH_WIRE] }); - } - if (/\/chat\/workbenches\?kind=chat$/.test(path)) return json({ items: [] }); - if (/\/chat\/workbenches\/[^/]+\/threads$/.test(path)) { - return json({ rootThreadId: "", items: [] }); - } - if (/\/chat\/workbenches\/[^/]+\/messages/.test(path)) { - if (init?.method === "POST") { - return json({ - id: "msg_new_2", - createdAt: "2026-01-01T00:00:01.000Z", - }); - } - const callIndex = messagesGetCount; - messagesGetCount += 1; - // Every load before the race starts (the initial mount load, plus - // however many redundant foreground loads the workbench-resolution - // effects happen to run) reports empty — nothing about those is - // under test here. - if (callIndex < raceStartIndex) { - return json({ items: [] }); - } - const firstMessage = { - id: "msg_new_1", - createdAt: "2026-01-01T00:00:00.000Z", - parts: [{ kind: "text", text: "first" }], - sender: { name: null, address: "prn_alice@acme.example" }, - }; - const secondMessage = { - id: "msg_new_2", - createdAt: "2026-01-01T00:00:01.000Z", - parts: [{ kind: "text", text: "second" }], - sender: { name: null, address: "prn_alice@acme.example" }, - }; - // The first call once the race starts is the stream-drop's own - // refresh, deliberately kept slow: it reports only the first - // message, correct as of its own request time, before the send - // below had landed. It is still in flight when the send lands, so - // resolving it into the cache afterwards would blink the sent - // message back out. Every call after it reports both messages, - // correct as of ITS request time. - if (callIndex === raceStartIndex) { - await sleep(400); - return json({ items: [firstMessage] }); - } - await sleep(5); - return json({ items: [firstMessage, secondMessage] }); - } - if (/\/chat\/workbenches\/[^/]+\/read-state$/.test(path)) return json({}); - if (/\/chat\/workbenches\/[^/]+\/invitable$/.test(path)) { - return json({ items: [] }); - } - if (/\/chat\/workbenches\/[^/]+\/settings$/.test(path)) { - return json({ - ...WORKBENCH_WIRE, - settings: {}, - contextWindow: { value: 20, source: "inherit" }, - }); - } - if (/\/chat\/bench\/settings$/.test(path)) { - return json({ settings: {}, contextWindow: 20 }); - } - if (/\/mailbox\/me\/threads/.test(path)) { - return json({ threads: [] }); - } - throw new Error(`unstubbed fetch: ${path}`); - }) as typeof fetch; - - const harness = await mount({ - tenant: { kind: "ready", tenantId: "tnt_1" }, - workbenchId: "ch_1", - currentUser: { principalId: "prn_alice" }, - }); - await harness.settle(); - // The initial load resolves through `listThreads` first, then - // `listMessages` — slower than the flat single-fetch mocks other - // tests use, so it needs longer than one `settle()` to leave the - // loading skeleton before the composer mounts. - await act(() => sleep(100)); - - // Drop the stream: `onerror` starts polling, which fires the - // deliberately-slow refresh. Wait past the coalescing window so it is - // genuinely in flight before the send goes out. - raceStartIndex = messagesGetCount; - act(() => firstStream().fail()); - await act(() => sleep(300)); - - const sendButton = harness.container.querySelector('[aria-label^="Send"]'); - if (sendButton === null) throw new Error("send button not found"); - typeInComposer(harness.container, "second"); - act(() => sendButton.click()); - - // Long enough for the slow refresh to resolve after the send, and for - // the send's own refresh to land behind it. - await act(() => sleep(700)); - - expect(harness.container.querySelector("#chat-message-msg_new_2")).not.toBeNull(); - expect(harness.container.textContent).toContain("second"); - harness.unmount(); - }); -}); - -describe("Workbench header polish (CL-6106)", () => { - test("the threads dropdown is hidden entirely when the workbench has no threads yet", async () => { - stubFetch(); - const harness = await mount({ - tenant: { kind: "ready", tenantId: "tnt_1" }, - workbenchId: "ch_1", - }); - await harness.settle(); - - expect(harness.container.querySelector(".chat-threads-menu")).toBeNull(); - harness.unmount(); - }); - - test("the threads dropdown appears once the workbench has a thread", async () => { - stubThreadedFetch(); - const harness = await mount({ - tenant: { kind: "ready", tenantId: "tnt_1" }, - workbenchId: "ch_1", - }); - await harness.settle(); - // The mailbox feed is two round trips — the thread list, then each - // visible thread's own read (see `mailbox-timeline.ts`'s - // `loadRoomThreadRows`/`loadRoomMailboxMessages`) — so this needs a - // second settle beyond mount's own. - await harness.settle(); - - const trigger = harness.container.querySelector(".chat-threads-menu-trigger"); - expect(trigger).not.toBeNull(); - expect(trigger?.textContent).toContain("1 thread"); - harness.unmount(); - }); - - test("agent participant chips live in the square member stack, naming the agent by display name (CL-6424 supersedes the raw-handle tooltip)", async () => { - stubFetch(undefined, WORKBENCH_WITH_AGENT_WIRE); - const harness = await mount({ - tenant: { kind: "ready", tenantId: "tnt_1" }, - workbenchId: "ch_1", - }); - await harness.settle(); - - const chip = harness.container.querySelector('.member-avatar[data-agent="true"]'); - expect(chip).not.toBeNull(); - expect((chip as HTMLElement).title).toBe("Researcher"); - expect(chip?.querySelector('[data-corbit="true"]')).not.toBeNull(); - harness.unmount(); - }); - - test("the settings control is icon-only, at the far right, with a tooltip", async () => { - stubFetch(); - const harness = await mount({ - tenant: { kind: "ready", tenantId: "tnt_1" }, - workbenchId: "ch_1", - }); - await harness.settle(); - - const button = harness.container.querySelector( - 'button[aria-label="Settings"]', - ) as HTMLButtonElement; - expect(button).not.toBeNull(); - expect(button.textContent?.trim()).toBe(""); - expect(button.title).toBe("Settings"); - - const actions = harness.container.querySelector(".chat-workbench-actions"); - expect(actions?.lastElementChild?.contains(button)).toBe(true); - harness.unmount(); - }); - - test("headerSlot lifts identity and actions out of the in-stage header", async () => { - stubFetch(); - const harness = await mount({ - tenant: { kind: "ready", tenantId: "tnt_1" }, - workbenchId: "ch_1", - headerSlot: (chrome) => - createElement( - "div", - { "data-testid": "host-stage-bar" }, - chrome.crumbs.map((crumb) => crumb.label).join(" / "), - chrome.actions, - ), - }); - await harness.settle(); - - expect(harness.container.querySelector(".chat-workbench-header")).toBeNull(); - const hostBar = harness.container.querySelector('[data-testid="host-stage-bar"]'); - expect(hostBar).not.toBeNull(); - expect(hostBar?.textContent).toContain("Launch Planning"); - expect(harness.container.querySelector('button[aria-label="Settings"]')).not.toBeNull(); - harness.unmount(); - }); - - test("headerSlot thread view has one aria-current=page and a clickable close-thread", async () => { - stubThreadedFetch(); - const harness = await mount({ - tenant: { kind: "ready", tenantId: "tnt_1" }, - workbenchId: "ch_1", - headerSlot: (chrome) => - createElement( - "div", - { "data-testid": "host-stage-bar" }, - createElement("span", { "aria-current": "page" }, chrome.crumbs.at(-1)?.label), - chrome.subtitle !== undefined - ? createElement("div", { className: "stage-top-bar-sub" }, chrome.subtitle) - : null, - chrome.actions, - ), - }); - await harness.settle(); - - const openButton = harness.container.querySelector(".chat-thread-open") as HTMLButtonElement; - await act(async () => { - openButton.dispatchEvent(new MouseEvent("click", { bubbles: true })); - await sleep(30); - }); - - expect(harness.container.querySelectorAll('[aria-current="page"]')).toHaveLength(1); - expect( - harness.container - .querySelector(".chat-thread-breadcrumb-current") - ?.getAttribute("aria-current"), - ).toBeNull(); - - const closeThread = harness.container.querySelector( - ".chat-thread-breadcrumb-link", - ) as HTMLButtonElement; - expect(closeThread).not.toBeNull(); - await act(async () => { - closeThread.dispatchEvent(new MouseEvent("click", { bubbles: true })); - await sleep(30); - }); - expect(harness.container.querySelector(".chat-thread-breadcrumb")).toBeNull(); - harness.unmount(); - }); - - test("headerSlot still titles the stage while the tenant is loading", async () => { - const harness = await mount({ - tenant: { kind: "loading" }, - headerSlot: (chrome) => - createElement("div", { "data-testid": "host-stage-bar" }, chrome.crumbs[0]?.label), - }); - await harness.settle(); - - expect(harness.container.querySelector('[data-testid="host-stage-bar"]')?.textContent).toBe( - "Workbenches", - ); - harness.unmount(); - }); -}); - -describe("CL-6833: running-turn resume failure is visible, never silent idle", () => { - test("a failed catch-up fetch shows the resume-failed banner with Retry", async () => { - stubFetch(undefined, WORKBENCH_WITH_AGENT_WIRE, { turnsFail: true }); - const harness = await mount({ - tenant: { kind: "ready", tenantId: "tnt_1" }, - workbenchId: "ch_1", - }); - await harness.settle(); - await harness.settle(); - - const banner = harness.container.querySelector(".chat-resume-failed-banner"); - expect(banner).not.toBeNull(); - expect(banner?.getAttribute("role")).toBe("alert"); - expect(harness.container.textContent).toContain("Couldn't resume the running reply"); - expect( - [...harness.container.querySelectorAll("button")].some( - (button) => button.textContent?.trim() === "Retry", - ), - ).toBe(true); - harness.unmount(); - }); - - test("a successful empty catch-up leaves the room without a resume banner", async () => { - stubFetch(undefined, WORKBENCH_WITH_AGENT_WIRE); - const harness = await mount({ - tenant: { kind: "ready", tenantId: "tnt_1" }, - workbenchId: "ch_1", - }); - await harness.settle(); - await harness.settle(); - - expect(harness.container.querySelector(".chat-resume-failed-banner")).toBeNull(); - harness.unmount(); - }); - - test("Retry re-asks the turns endpoint after a resume failure", async () => { - let turnsCalls = 0; - stubFetch(undefined, WORKBENCH_WITH_AGENT_WIRE, { turnsFail: true }); - const failingFetch = globalThis.fetch; - globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { - const path = typeof input === "string" ? input : String(input); - if (/\/chat\/workbenches\/[^/]+\/turns(?:\/|$|\?)/.test(path)) { - turnsCalls += 1; - if (turnsCalls >= 2) { - return new Response(JSON.stringify({ items: [] }), { - status: 200, - headers: { "content-type": "application/json" }, - }); - } - } - return failingFetch(input, init); - }) as typeof fetch; - - const harness = await mount({ - tenant: { kind: "ready", tenantId: "tnt_1" }, - workbenchId: "ch_1", - }); - await harness.settle(); - await harness.settle(); - - expect(harness.container.querySelector(".chat-resume-failed-banner")).not.toBeNull(); - - const retry = [...harness.container.querySelectorAll("button")].find( - (button) => button.textContent?.trim() === "Retry", - ) as HTMLButtonElement; - await act(async () => { - retry.click(); - await sleep(30); - }); - await harness.settle(); - - expect(turnsCalls).toBeGreaterThanOrEqual(2); - expect(harness.container.querySelector(".chat-resume-failed-banner")).toBeNull(); - harness.unmount(); - }); -}); - -describe("Invite control visibility (CL-6781)", () => { - test("hides Invite agent when the invitable listing succeeds empty", async () => { - stubFetch(); - const harness = await mount({ - tenant: { kind: "ready", tenantId: "tnt_1" }, - workbenchId: "ch_1", - }); - await harness.settle(); - await harness.settle(); - - expect(harness.container.textContent).not.toContain("Invite agent"); - harness.unmount(); - }); - - test("shows Invite agent once at least one definition is invitable", async () => { - globalThis.EventSource = StubEventSource as unknown as typeof EventSource; - globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { - const path = typeof input === "string" ? input : String(input); - const json = (body: unknown) => - new Response(JSON.stringify(body), { - status: 200, - headers: { "content-type": "application/json" }, - }); - if (/\/chat\/workbenches\?kind=workbench$/.test(path)) { - return json({ items: [WORKBENCH_WIRE] }); - } - if (/\/chat\/workbenches\?kind=chat$/.test(path)) return json({ items: [] }); - if (/\/chat\/workbenches\/[^/]+\/threads$/.test(path)) { - return json({ rootThreadId: "", items: [] }); - } - if (/\/chat\/workbenches\/[^/]+\/messages/.test(path)) { - if (init?.method === "POST") { - return json({ id: "msg_new", createdAt: "2026-01-01T00:00:00.000Z" }); - } - return json({ items: [] }); - } - if (/\/chat\/workbenches\/[^/]+\/read-state$/.test(path)) return json({}); - if (/\/chat\/workbenches\/[^/]+\/invitable$/.test(path)) { - return json({ - items: [{ id: "wfd_echo", name: "echo", description: "Echo" }], - }); - } - if (/\/chat\/workbenches\/[^/]+\/pins$/.test(path)) return json({ items: [] }); - if (/\/chat\/workbenches\/[^/]+\/settings$/.test(path)) { - return json({ - ...WORKBENCH_WIRE, - settings: {}, - contextWindow: { value: 20, source: "inherit" }, - }); - } - if (/\/chat\/bench\/settings$/.test(path)) { - return json({ settings: {}, contextWindow: 20 }); - } - if (/\/mailbox\/me\/threads/.test(path)) { - return json({ threads: [] }); + return json({ threads: [] }); } throw new Error(`unstubbed fetch: ${path}`); }) as typeof fetch; @@ -2038,153 +296,3 @@ describe("Invite control visibility (CL-6781)", () => { harness.unmount(); }); }); - -describe("switching workbenches never carries a stale root-thread id across", () => { - // CL-6067/6069 regression, still guarded after CL-6313 made it - // structurally impossible: the timeline no longer fetches by thread id - // at all, and each workbench's feed has its own query key, so there is - // no closed-over id left to carry across a switch. What must hold is - // the same thing it always was — the new workbench renders its own - // messages promptly, and no request ever names another workbench. - const WORKBENCH_A = { ...WORKBENCH_WIRE, id: "ch_a", title: "Workbench A" }; - const WORKBENCH_B = { ...WORKBENCH_WIRE, id: "ch_b", title: "Workbench B" }; - - function stubFetchTwoWorkbenches(wrongRequests: string[]) { - globalThis.EventSource = StubEventSource as unknown as typeof EventSource; - globalThis.fetch = (async (input: RequestInfo | URL) => { - const path = typeof input === "string" ? input : String(input); - const json = (body: unknown) => - new Response(JSON.stringify(body), { - status: 200, - headers: { "content-type": "application/json" }, - }); - const notFound = () => - new Response(JSON.stringify({ error: "not found" }), { - status: 404, - headers: { "content-type": "application/json" }, - }); - if (/\/chat\/workbenches\?kind=workbench$/.test(path)) { - return json({ items: [WORKBENCH_A, WORKBENCH_B] }); - } - if (/\/chat\/workbenches\?kind=chat$/.test(path)) return json({ items: [] }); - if (/\/chat\/workbenches\/[^/]+\/threads$/.test(path)) { - return json({ rootThreadId: "", items: [] }); - } - if (/\/chat\/workbenches\/[^/]+\/messages/.test(path)) { - return json({ items: [] }); - } - if (/\/chat\/workbenches\/[^/]+\/read-state$/.test(path)) return json({}); - if (/\/chat\/workbenches\/[^/]+\/invitable$/.test(path)) { - return json({ items: [] }); - } - if (/\/chat\/workbenches\/[^/]+\/pins$/.test(path)) return json({ items: [] }); - if (/\/chat\/workbenches\/[^/]+\/settings$/.test(path)) { - return json({ - ...WORKBENCH_A, - settings: {}, - contextWindow: { value: 20, source: "inherit" }, - }); - } - if (/\/chat\/bench\/settings$/.test(path)) { - return json({ settings: {}, contextWindow: 20 }); - } - if (/\/mailbox\/me\/threads\/msg_a/.test(path)) { - return json({ - messages: [ - { - id: "msg_a", - messageId: "msg_a", - references: [], - fromAddress: "user@x.localhost", - createdAt: "2026-01-01T00:00:00.000Z", - read: true, - archived: false, - parentId: null, - body: "A message", - }, - ], - }); - } - if (/\/mailbox\/me\/threads\/msg_b/.test(path)) { - return json({ - messages: [ - { - id: "msg_b", - messageId: "msg_b", - references: [], - fromAddress: "user@x.localhost", - createdAt: "2026-01-01T00:00:00.000Z", - read: true, - archived: false, - parentId: null, - body: "B message", - }, - ], - }); - } - // `refs` scopes the mailbox list to one room — a list call for a - // ref naming the other workbench is the bug this test guards - // against — record it and 404, exactly like the real hub would - // for a ref that resolves to nothing this caller can see. - if (/\/mailbox\/me\/threads\?/.test(path)) { - if (path.includes("ch_a")) { - return json({ - threads: [ - { - rootId: "msg_a", - rootMessageId: "msg_a", - messageCount: 1, - unreadCount: 0, - lastMessageId: "msg_a", - lastFromAddress: "user@x.localhost", - lastCreatedAt: "2026-01-01T00:00:00.000Z", - }, - ], - }); - } - if (path.includes("ch_b")) { - return json({ - threads: [ - { - rootId: "msg_b", - rootMessageId: "msg_b", - messageCount: 1, - unreadCount: 0, - lastMessageId: "msg_b", - lastFromAddress: "user@x.localhost", - lastCreatedAt: "2026-01-01T00:00:00.000Z", - }, - ], - }); - } - wrongRequests.push(path); - return notFound(); - } - throw new Error(`unstubbed fetch: ${path}`); - }) as typeof fetch; - } - - test("switching to another workbench loads that workbench's own thread, not the previous one's", async () => { - const wrongRequests: string[] = []; - stubFetchTwoWorkbenches(wrongRequests); - const harness = await mount({ - tenant: { kind: "ready", tenantId: "tnt_1" }, - workbenchId: "ch_a", - }); - await harness.settle(); - await harness.settle(); - expect(harness.container.textContent).toContain("A message"); - - await harness.rerender({ - tenant: { kind: "ready", tenantId: "tnt_1" }, - workbenchId: "ch_b", - }); - await harness.settle(); - await harness.settle(); - - expect(wrongRequests).toEqual([]); - expect(harness.container.textContent).not.toContain("Couldn't load"); - expect(harness.container.textContent).toContain("B message"); - harness.unmount(); - }); -}); diff --git a/packages/chat-ui/test/stream-message-parse-drop.test.tsx b/packages/chat-ui/test/stream-message-parse-drop.test.tsx deleted file mode 100644 index 6a5073e845..0000000000 --- a/packages/chat-ui/test/stream-message-parse-drop.test.tsx +++ /dev/null @@ -1,188 +0,0 @@ -// CL-6837: a chat.message the stream cannot parse must not sit silent -// until the next poll. Schema miss and JSON miss both toast and refresh -// the feed immediately. - -import { afterEach, describe, expect, spyOn, test } from "bun:test"; -import { act, createElement } from "react"; -import { createRoot } from "react-dom/client"; -import type { Root } from "react-dom/client"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import * as reactUi from "@corbits/react-ui"; - -const realFetch = globalThis.fetch; -const realEventSource = globalThis.EventSource; - -class StubEventSource { - static instances: StubEventSource[] = []; - onopen: (() => void) | null = null; - onerror: (() => void) | null = null; - readyState = 1; - listeners = new Map void>(); - - constructor(readonly url: string) { - StubEventSource.instances.push(this); - } - - addEventListener(eventType: string, listener: (message: MessageEvent) => void) { - this.listeners.set(eventType, listener); - } - - emit(eventType: string, data: unknown) { - this.listeners.get(eventType)?.({ - data: JSON.stringify(data), - } as MessageEvent); - } - - emitRaw(eventType: string, data: string) { - this.listeners.get(eventType)?.({ data } as MessageEvent); - } - - close() { - this.readyState = 2; - } -} - -afterEach(() => { - globalThis.fetch = realFetch; - globalThis.EventSource = realEventSource; - StubEventSource.instances = []; -}); - -const WORKBENCH_WIRE = { - id: "ch_1", - title: "Launch Planning", - kind: "workbench", - pinned: false, - participants: [] as { address: string; handle: string }[], -}; - -let feedRefetchCount = 0; - -function stubFetch() { - globalThis.EventSource = StubEventSource as unknown as typeof EventSource; - feedRefetchCount = 0; - globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { - const path = typeof input === "string" ? input : String(input); - const method = init?.method ?? "GET"; - if ( - method === "GET" && - (/\/chat\/workbenches\/[^/]+\/(messages|threads|pins)(\?|$)/.test(path) || - /\/mailbox\/me\/threads/.test(path)) - ) { - feedRefetchCount += 1; - } - const json = (body: unknown) => - new Response(JSON.stringify(body), { - status: 200, - headers: { "content-type": "application/json" }, - }); - if (/\/chat\/workbenches\?kind=workbench$/.test(path)) { - return json({ items: [WORKBENCH_WIRE] }); - } - if (/\/chat\/workbenches\?kind=chat$/.test(path)) { - return json({ items: [] }); - } - if (/\/chat\/workbenches\/[^/]+\/threads$/.test(path)) { - return json({ rootThreadId: "", items: [] }); - } - if (/\/chat\/workbenches\/[^/]+\/messages/.test(path)) { - return json({ items: [] }); - } - if (/\/chat\/workbenches\/[^/]+\/pins$/.test(path)) { - return json({ items: [] }); - } - if (/\/chat\/workbenches\/[^/]+\/read-state$/.test(path)) return json({}); - if (/\/chat\/workbenches\/[^/]+\/invitable$/.test(path)) { - return json({ items: [] }); - } - if (/\/chat\/workbenches\/[^/]+\/presence$/.test(path) && init?.method === "POST") { - return json({}); - } - if (/\/mailbox\/me\/threads/.test(path)) { - return json({ threads: [] }); - } - throw new Error(`unstubbed fetch: ${path}`); - }) as typeof fetch; -} - -const { ChatWorkspace } = await import("../src/chat-workspace"); - -const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); - -function mount(props: Parameters[0]) { - const container = document.createElement("div"); - document.body.appendChild(container); - const root: Root = createRoot(container); - const queryClient = new QueryClient({ - defaultOptions: { queries: { retry: false, gcTime: 0, staleTime: 0 } }, - }); - act(() => { - root.render( - createElement( - QueryClientProvider, - { client: queryClient }, - createElement(ChatWorkspace, props), - ), - ); - }); - return { - container, - settle: (ms = 30) => act(() => sleep(ms)), - unmount: () => root.unmount(), - }; -} - -// The workbench's own chat stream, not the mailbox live-update -// subscription `useWorkbenchFeed` also opens (CL-8174 slice 2b). -function firstStream(): StubEventSource { - const instance = StubEventSource.instances.find((source) => - /\/chat\/workbenches\/[^/]+\/stream/.test(source.url), - ); - if (instance === undefined) throw new Error("no stream connected"); - return instance; -} - -describe("dropped chat.message is not silent until poll (CL-6837)", () => { - test("a schema miss toasts and refreshes the feed", async () => { - stubFetch(); - const toast = spyOn(reactUi, "toast"); - const harness = mount({ - tenant: { kind: "ready", tenantId: "tnt_1" }, - workbenchId: "ch_1", - }); - await harness.settle(); - const feedRefetchCountAfterHydration = feedRefetchCount; - - act(() => { - firstStream().emit("chat.message", { not: "a message" }); - }); - await harness.settle(300); - - expect(toast).toHaveBeenCalledWith("Couldn't apply that message live — refreshing."); - expect(feedRefetchCount).toBeGreaterThan(feedRefetchCountAfterHydration); - toast.mockRestore(); - harness.unmount(); - }); - - test("invalid JSON toasts and refreshes the feed instead of forwarding garbage", async () => { - stubFetch(); - const toast = spyOn(reactUi, "toast"); - const harness = mount({ - tenant: { kind: "ready", tenantId: "tnt_1" }, - workbenchId: "ch_1", - }); - await harness.settle(); - const feedRefetchCountAfterHydration = feedRefetchCount; - - act(() => { - firstStream().emitRaw("chat.message", "not-json{"); - }); - await harness.settle(300); - - expect(toast).toHaveBeenCalledWith("Couldn't apply that message live — refreshing."); - expect(feedRefetchCount).toBeGreaterThan(feedRefetchCountAfterHydration); - expect(harness.container.textContent).not.toContain("not-json"); - toast.mockRestore(); - harness.unmount(); - }); -}); diff --git a/packages/chat-ui/test/zero-refetch-on-stream-event.test.tsx b/packages/chat-ui/test/zero-refetch-on-stream-event.test.tsx deleted file mode 100644 index 52ff883d9f..0000000000 --- a/packages/chat-ui/test/zero-refetch-on-stream-event.test.tsx +++ /dev/null @@ -1,217 +0,0 @@ -// CL-6328's own bar (§6/1.2): once a workbench's feed has hydrated, a -// stream event never triggers a refetch — it applies straight into the -// cache. This suite mounts `ChatWorkspace` for real, counts every `fetch` -// call, and proves the count stays flat across a `chat.message` stream -// event and across a composer send whose confirm echoes back over the -// stream. - -import { afterEach, describe, expect, test } from "bun:test"; -import { act, createElement } from "react"; -import { createRoot } from "react-dom/client"; -import type { Root } from "react-dom/client"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; - -const realFetch = globalThis.fetch; -const realEventSource = globalThis.EventSource; - -class StubEventSource { - static instances: StubEventSource[] = []; - onopen: (() => void) | null = null; - onerror: (() => void) | null = null; - readyState = 1; - listeners = new Map void>(); - - constructor(readonly url: string) { - StubEventSource.instances.push(this); - } - - addEventListener(eventType: string, listener: (message: MessageEvent) => void) { - this.listeners.set(eventType, listener); - } - - emit(eventType: string, data: unknown) { - this.listeners.get(eventType)?.({ - data: JSON.stringify(data), - } as MessageEvent); - } - - close() { - this.readyState = 2; - } -} - -afterEach(() => { - globalThis.fetch = realFetch; - globalThis.EventSource = realEventSource; - StubEventSource.instances = []; -}); - -const WORKBENCH_WIRE = { - id: "ch_1", - title: "Launch Planning", - kind: "workbench", - pinned: false, - participants: [] as { address: string; handle: string }[], -}; - -// The feed's own reads (CL-6328's target): a `GET` against messages, -// threads, or pins. `PUT .../read-state` is a legitimate side effect of a -// new message landing (the read cursor advancing) — not a refetch of data -// the stream event already carried — so it's tracked separately and never -// counted against the §6/1.2 bar. -let feedRefetchCount = 0; -const sentBodies: unknown[] = []; - -function stubFetch() { - globalThis.EventSource = StubEventSource as unknown as typeof EventSource; - feedRefetchCount = 0; - sentBodies.length = 0; - globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { - const path = typeof input === "string" ? input : String(input); - const method = init?.method ?? "GET"; - if ( - method === "GET" && - (/\/chat\/workbenches\/[^/]+\/(messages|threads|pins)(\?|$)/.test(path) || - /\/mailbox\/me\/threads/.test(path)) - ) { - feedRefetchCount += 1; - } - const json = (body: unknown) => - new Response(JSON.stringify(body), { - status: 200, - headers: { "content-type": "application/json" }, - }); - if (/\/chat\/workbenches\?kind=workbench$/.test(path)) { - return json({ items: [WORKBENCH_WIRE] }); - } - if (/\/chat\/workbenches\?kind=chat$/.test(path)) return json({ items: [] }); - if (/\/chat\/workbenches\/[^/]+\/threads$/.test(path)) { - return json({ rootThreadId: "", items: [] }); - } - if (/\/chat\/workbenches\/[^/]+\/messages/.test(path)) { - if (init?.method === "POST") { - const body = JSON.parse(String(init.body)) as { clientId?: string }; - sentBodies.push(body); - return json({ - id: "msg_confirmed", - createdAt: "2026-01-01T00:00:05.000Z", - clientId: body.clientId, - }); - } - return json({ items: [] }); - } - if (/\/chat\/workbenches\/[^/]+\/pins$/.test(path)) return json({ items: [] }); - if (/\/chat\/workbenches\/[^/]+\/read-state$/.test(path)) return json({}); - if (/\/chat\/workbenches\/[^/]+\/invitable$/.test(path)) return json({ items: [] }); - if (/\/chat\/workbenches\/[^/]+\/presence$/.test(path) && init?.method === "POST") { - return json({}); - } - if (/\/mailbox\/me\/threads/.test(path)) { - return json({ threads: [] }); - } - throw new Error(`unstubbed fetch: ${path}`); - }) as typeof fetch; -} - -const { ChatWorkspace } = await import("../src/chat-workspace"); - -const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); - -function mount(props: Parameters[0]) { - const container = document.createElement("div"); - document.body.appendChild(container); - const root: Root = createRoot(container); - const queryClient = new QueryClient({ - defaultOptions: { queries: { retry: false, gcTime: 0, staleTime: 0 } }, - }); - act(() => { - root.render( - createElement( - QueryClientProvider, - { client: queryClient }, - createElement(ChatWorkspace, props), - ), - ); - }); - return { - container, - settle: () => act(() => sleep(30)), - unmount: () => root.unmount(), - }; -} - -// The workbench's own chat stream, not the mailbox live-update -// subscription `useWorkbenchFeed` also opens (CL-8174 slice 2b). -function firstStream(): StubEventSource { - const instance = StubEventSource.instances.find((source) => - /\/chat\/workbenches\/[^/]+\/stream/.test(source.url), - ); - if (instance === undefined) throw new Error("no stream connected"); - return instance; -} - -describe("zero refetches on a stream event, post-hydration (CL-6328 §6/1.2)", () => { - // CL-8174 slice 2b retires this suite's other test — "a streamed - // chat.message renders with no fetch call beyond mount-hydration" — - // along with `applyStreamMessage`: the feed now reads via the mailbox, - // and a peer's `chat.message` is no longer folded straight into the - // messages cache. Its own connection instead relies on the mailbox's - // `/me/inbox/events` live-update subscription (`useWorkbenchFeed`) to - // notice the fan-out landing and refetch — a real (small) refetch, - // not the zero this test asserted. The confirm-echo test below is - // untouched: it covers this reader's own optimistic send, a write - // straight into the cache independent of where reads come from. - test("a composer send's own confirm echo (matching clientId) never doubles the bubble or triggers a refetch", async () => { - stubFetch(); - const harness = mount({ - tenant: { kind: "ready", tenantId: "tnt_1" }, - workbenchId: "ch_1", - currentUser: { principalId: "prn_alice" }, - }); - await harness.settle(); - - const textarea = harness.container.querySelector(".chat-composer-input") as HTMLTextAreaElement; - const setter = Object.getOwnPropertyDescriptor( - window.HTMLTextAreaElement.prototype, - "value", - )?.set; - act(() => { - setter?.call(textarea, "hi there"); - textarea.dispatchEvent(new Event("input", { bubbles: true })); - }); - const sendButton = harness.container.querySelector( - 'button[aria-label="Send"]', - ) as HTMLButtonElement; - await act(async () => { - sendButton.dispatchEvent(new MouseEvent("click", { bubbles: true })); - await sleep(30); - }); - - expect(sentBodies).toHaveLength(1); - const sentClientId = (sentBodies[0] as { clientId: string }).clientId; - expect(typeof sentClientId).toBe("string"); - const feedRefetchCountAfterSend = feedRefetchCount; - - // The workbench's own broadcast of this send, echoed back on its - // stream — same `clientId` the POST response carried. - act(() => { - firstStream().emit("chat.message", { - id: "msg_confirmed", - workbenchId: "ch_1", - ref: { kind: "workbench", id: "ch_1" }, - createdAt: "2026-01-01T00:00:05.000Z", - threadId: null, - clientId: sentClientId, - sender: { name: null, address: "prn_alice@pending.local" }, - parts: [{ kind: "text", text: "hi there" }], - }); - }); - await harness.settle(); - - // No refetch triggered by the echo, and no duplicate bubble. - expect(feedRefetchCount).toBe(feedRefetchCountAfterSend); - const bubbles = harness.container.querySelectorAll('[data-own="true"] .chat-bubble'); - expect(bubbles).toHaveLength(1); - harness.unmount(); - }); -});