From 361a58bc7ec03aac2b10bfc2b6a690b94111cb34 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 17 Sep 2026 14:44:14 -0700 Subject: [PATCH 1/2] chat-ui: composer sends through POST /me/inbox/send (CL-8175) --- apps/web/src/instant-agent-create.test.ts | 8 +- apps/web/src/instant-agent-create.ts | 18 +-- packages/chat-ui/src/api.ts | 86 ++++++-------- packages/chat-ui/src/chat-workspace.tsx | 9 ++ packages/chat-ui/src/index.ts | 2 +- packages/chat-ui/src/mailbox-timeline.ts | 1 + packages/chat-ui/src/use-optimistic-sends.ts | 116 +++++++++---------- 7 files changed, 114 insertions(+), 126 deletions(-) diff --git a/apps/web/src/instant-agent-create.test.ts b/apps/web/src/instant-agent-create.test.ts index e7f7a455cc..d9b1ccc677 100644 --- a/apps/web/src/instant-agent-create.test.ts +++ b/apps/web/src/instant-agent-create.test.ts @@ -168,7 +168,12 @@ describe("createWorkbench", () => { participants: [], }); } - if (path.endsWith("/chat/workbenches/chan-recoverable/messages")) { + if (path.endsWith("/chat/workbenches/chan-recoverable/invite")) { + return json({ address: "def-assistant@tnt-1.corbits.dev", definitionId: "def-assistant" }); + } + // CL-8175: the opening message now posts through `@corbits/mailbox`'s + // `POST /me/inbox/send` rather than the chat route. + if (path.endsWith("/mailbox/me/inbox/send")) { return json({ error: "agent launch failed" }, 409); } throw new Error(`unexpected fetch: ${path}`); @@ -181,6 +186,7 @@ describe("createWorkbench", () => { () => undefined, newQueryClient(), "Research our next partner", + ["def-assistant"], ); } catch (error) { cause = error; diff --git a/apps/web/src/instant-agent-create.ts b/apps/web/src/instant-agent-create.ts index 97baa59358..9e7975b549 100644 --- a/apps/web/src/instant-agent-create.ts +++ b/apps/web/src/instant-agent-create.ts @@ -14,9 +14,8 @@ import type { QueryClient } from "@tanstack/react-query"; import { createWorkbench as createWorkbenchChannel, inviteAgent, - partsForSend, patchWorkbenchSettings, - sendMessage, + sendInboxMessage, workbenchesQueryKeyPrefix, } from "@corbits/chat-ui"; import { reportError } from "@corbits/error-sink"; @@ -125,14 +124,15 @@ export async function createWorkbench( }); if (firstMessage !== undefined && firstMessage.trim() !== "") { - const selectedAgentInvites = [...new Set(selectedAgentDefinitionIds)].map((definitionId) => ({ - kind: "agent" as const, - definitionId, - })); try { - await sendMessage(tenantId, workbench.id, partsForSend(firstMessage, []), { - ...(selectedAgentInvites.length > 0 ? { invite: selectedAgentInvites } : {}), - }); + const invitedAgentAddresses: string[] = []; + for (const definitionId of new Set(selectedAgentDefinitionIds)) { + const invited = await inviteAgent(tenantId, workbench.id, definitionId); + invitedAgentAddresses.push(invited.address); + } + if (invitedAgentAddresses.length > 0) { + await sendInboxMessage(tenantId, invitedAgentAddresses, firstMessage); + } } catch (cause) { throw new WorkbenchPostCreateError(workbench.id, "opening-message", cause); } diff --git a/packages/chat-ui/src/api.ts b/packages/chat-ui/src/api.ts index 421ef0e230..ccb09eb1db 100644 --- a/packages/chat-ui/src/api.ts +++ b/packages/chat-ui/src/api.ts @@ -147,6 +147,12 @@ const MessageItem = type({ // root feed and every open thread — see `./thread-feed.ts`. Absent // on a host that mounts no thread store, matching `rootThreadId: ""`. "threadId?": "string", + // The RFC 5322 `Message-ID` this row's mail carries (CL-8175) — absent + // for a row with no mail original. This is what a reply's own + // `inReplyTo` threads onto, never this item's `id`, which for a + // mail-derived row is the mailbox's own local uid (see + // `mailbox-timeline.ts`'s `threadTreeToTimeline`). + "messageId?": "string", }); export type MessageItem = typeof MessageItem.infer; @@ -156,13 +162,6 @@ const MessagesResponse = type({ }); export type MessagesResponse = typeof MessagesResponse.infer; -const SentMessage = type({ - id: "string", - createdAt: "string", - "threadId?": "string", - "clientId?": "string", -}); - const ReadState = type({ "lastSeenCreatedAt?": "string | null", "lastSeenId?": "string | null", @@ -430,55 +429,36 @@ export function fetchWorkbenchBlob( ).then((body) => body.contentBase64); } -/** A message-send's pre-invite entry — the wire shape of a picked - * "Bring in…" candidate's `MentionInviteIntent` (see `mentions.ts`), - * mirroring `packages/chat/src/routes.ts`'s `MessageInviteEntry`. */ -export type MessageInviteInput = - | { readonly kind: "agent"; readonly definitionId: string } - | { - readonly kind: "person"; - readonly principalId: string; - readonly name?: string; - }; - -export type SendMessageOptions = { - readonly threadId?: string; - readonly inReplyToMessageId?: string; - /** Every not-yet-participant a mention in this message names, invited - * server-side before the send so the mention fans out normally. */ - readonly invite?: readonly MessageInviteInput[]; - /** This composer submit's own client-generated send identity - * (CL-6251) — the pending bubble's `nonce`, carried on the wire so - * the server can echo it back (in this call's own response, and on - * every later `GET .../messages` page) and the pending bubble can - * reconcile with the confirmed message by identity rather than by - * guessing from content or timing. */ - readonly clientId?: string; -}; +// The wire response of `@corbits/mailbox`'s `POST /me/inbox/send` +// (CL-8175): the Sent-folder copy's own RFC 5322 `Message-ID` and its +// mailbox uid, the same uid `mailbox-timeline.ts`'s `threadTreeToTimeline` +// keys every timeline row by. +const SentInboxMessage = type({ + messageId: "string", + uid: "number", +}); +export type SentInboxMessage = typeof SentInboxMessage.infer; -export function sendMessage( +/** + * A human composer send, straight onto the tenant's own mailbox + * (`POST /me/inbox/send`, CL-8175) — no chat route in between. `to` is + * every other recipient address this send goes to; `inReplyTo` is the + * RFC `Message-ID` of the message this one threads under (the thread + * root when replying inside an open thread, or the specific message being + * replied to when starting a new one) — never a mailbox uid, which is a + * local, per-mailbox integer the wire format has no room for. + */ +export function sendInboxMessage( tenantId: string, - workbenchId: string, - parts: readonly Part[], - options?: SendMessageOptions, -): Promise<{ - readonly id: string; - readonly createdAt: string; - readonly threadId?: string; - readonly clientId?: string; -}> { - const body: Record = { parts }; - if (options?.threadId !== undefined) body["threadId"] = options.threadId; - if (options?.inReplyToMessageId !== undefined) { - body["inReplyToMessageId"] = options.inReplyToMessageId; - } - if (options?.invite !== undefined && options.invite.length > 0) { - body["invite"] = options.invite; - } - if (options?.clientId !== undefined) body["clientId"] = options.clientId; - return request(`/api/tenants/${tenantId}/chat/workbenches/${workbenchId}/messages`, SentMessage, { + to: readonly string[], + body: string, + options?: { readonly inReplyTo?: string }, +): Promise { + const requestBody: Record = { to, body }; + if (options?.inReplyTo !== undefined) requestBody["inReplyTo"] = options.inReplyTo; + return request(`/api/tenants/${tenantId}/mailbox/me/inbox/send`, SentInboxMessage, { method: "POST", - body: JSON.stringify(body), + body: JSON.stringify(requestBody), }); } diff --git a/packages/chat-ui/src/chat-workspace.tsx b/packages/chat-ui/src/chat-workspace.tsx index ba2dcfaf22..f75e0a67fc 100644 --- a/packages/chat-ui/src/chat-workspace.tsx +++ b/packages/chat-ui/src/chat-workspace.tsx @@ -984,6 +984,13 @@ function ChatWorkspaceInner({ setResumeAttempt((attempt) => attempt + 1); }, []); + // Every other participant's address (CL-8175) — the signed-in sender's + // own entry (matched by local-part against `principalId`, the same + // convention `typingLabel` above reads) never belongs in its own `to`. + const recipientAddresses = (activeWorkbench?.participants ?? []) + .map((participant) => participant.address) + .filter((address) => localPartOf(address) !== currentUser?.principalId); + const { pendingSends, handleSend, retryPendingSend, discardPendingSend } = useOptimisticSends({ tenantId, activeWorkbenchId, @@ -991,6 +998,8 @@ function ChatWorkspaceInner({ openThreadId, pendingParentMessageId, openThreadById, + recipientAddresses, + messages: feed.loadedMessages, noteAwaitingReply, hasAgentParticipant, restoreDraft: (text) => composerRef.current?.insertText(text), diff --git a/packages/chat-ui/src/index.ts b/packages/chat-ui/src/index.ts index b4a4a403b8..c823314cc4 100644 --- a/packages/chat-ui/src/index.ts +++ b/packages/chat-ui/src/index.ts @@ -151,7 +151,7 @@ export { applyStreamWorkbenchesMutated, createWorkbench, listMessages, - sendMessage, + sendInboxMessage, fetchWorkbenchBlob, listThreads, putReadState, diff --git a/packages/chat-ui/src/mailbox-timeline.ts b/packages/chat-ui/src/mailbox-timeline.ts index c17cbe05f6..1076ac8be0 100644 --- a/packages/chat-ui/src/mailbox-timeline.ts +++ b/packages/chat-ui/src/mailbox-timeline.ts @@ -148,6 +148,7 @@ export function threadTreeToTimeline(root: MailboxThreadNode): TimelineItem[] { createdAt: envelope.date, parts: [{ kind: "text", text }], sender: { name: null, address: envelope.from }, + messageId: envelope.messageId, ...(parentUid !== undefined ? { threadId: String(parentUid) } : {}), }); for (const child of node.children) visit(child, node.uid); diff --git a/packages/chat-ui/src/use-optimistic-sends.ts b/packages/chat-ui/src/use-optimistic-sends.ts index 584d5ef147..f40653db04 100644 --- a/packages/chat-ui/src/use-optimistic-sends.ts +++ b/packages/chat-ui/src/use-optimistic-sends.ts @@ -14,11 +14,10 @@ import { useEffect, useRef, useState } from "react"; import { useQueryClient } from "@tanstack/react-query"; -import { ChatApiError, pingWorkbenchPresence, sendMessage } from "./api"; +import { ChatApiError, pingWorkbenchPresence, sendInboxMessage } from "./api"; import type { MessageItem, MessagesResponse } from "./api"; import { partsForSend } from "./composer"; import type { ComposerAttachment, ComposerSendPayload } from "./composer"; -import type { MentionInviteIntent } from "./mentions"; import { CHAT_STRINGS } from "./strings"; import type { PendingMessageStatus, TimelineMessageItem } from "./timeline"; import { toast } from "@corbits/react-ui"; @@ -35,10 +34,6 @@ export type PendingSend = { readonly attachments: readonly ComposerAttachment[]; readonly createdAt: string; readonly status: PendingMessageStatus; - /** The "Bring in…" picks this submit carried — kept on the pending - * entry so a Retry re-runs the same pre-invite step the original - * submit did. */ - readonly invite?: readonly MentionInviteIntent[]; }; let pendingSendSeq = 0; @@ -137,6 +132,15 @@ export function useOptimisticSends(args: { readonly openThreadId: string | null; readonly pendingParentMessageId: string | null; readonly openThreadById: (threadId: string) => void; + /** Every other participant's mailbox address this send goes `to` + * (CL-8175) — the room's own `Workbench.participants`, minus the + * signed-in sender. */ + readonly recipientAddresses: readonly string[]; + /** The loaded feed's own messages, used only to resolve the RFC + * `Message-ID` a reply threads onto: `openThreadId`/ + * `pendingParentMessageId` are mailbox uids (see + * `use-thread-navigation.ts`), never the wire id `inReplyTo` needs. */ + readonly messages: readonly MessageItem[]; /** Fires once a send lands in a workbench that has an agent in it: a * reply is owed, so the typing indicator shows now rather than sitting * silent until the turn's first stream event arrives. */ @@ -153,6 +157,8 @@ export function useOptimisticSends(args: { openThreadId, pendingParentMessageId, openThreadById, + recipientAddresses, + messages, noteAwaitingReply, hasAgentParticipant, restoreDraft, @@ -176,63 +182,50 @@ export function useOptimisticSends(args: { activeWorkbenchIdRef.current = activeWorkbenchId; }, [activeWorkbenchId]); - async function sendPending( - nonce: string, - text: string, - attachments: readonly ComposerAttachment[], - invite?: readonly MentionInviteIntent[], - ): Promise { + async function sendPending(nonce: string, text: string): Promise { if (activeWorkbenchId === null) return; - const parts = partsForSend(text, attachments); - if (parts.length === 0) return; - const inviteOption = invite !== undefined ? { invite } : {}; - // The pending bubble's own nonce doubles as its wire `clientId` — - // no second id needed. The server echoes it back below and, once - // wired, records it against the message so the next `GET - // .../messages` page carries it too; `mergePendingSends` drops - // this pending entry the moment either arrival shows up with a - // matching `clientId`, so whichever wins this race, the other is - // a no-op. + // The mailbox send wire (`{ to, body, inReplyTo? }`) has no room for + // an attachment — dropped rather than shimmed (CL-8175); `parts` below + // is still text-only, which is exactly what `partsForSend` already + // produces for an empty attachments array. + const trimmedBody = text.trim(); + if (trimmedBody.length === 0) return; + if (recipientAddresses.length === 0) return; + const parts = partsForSend(text, []); + // The thread this reply threads onto, by its RFC `Message-ID` — never + // `openThreadId`/`pendingParentMessageId` directly, which are mailbox + // uids (see `use-thread-navigation.ts`). + const replyTargetUid = openThreadId ?? pendingParentMessageId; + const inReplyTo = + replyTargetUid !== null + ? messages.find((item) => item.id === replyTargetUid)?.messageId + : undefined; + // The pending bubble's own nonce is this send's only identity: the + // mailbox route echoes no `clientId`, so `mergePendingSends` matches + // this pending entry to its own confirmed write below by uid instead + // (see the cache write just under this). try { - let sent: { - readonly id: string; - readonly createdAt: string; - readonly threadId?: string; - readonly clientId?: string; - }; - if (openThreadId !== null) { - sent = await sendMessage(tenantId, activeWorkbenchId, parts, { - threadId: openThreadId, - clientId: nonce, - ...inviteOption, - }); - } else if (pendingParentMessageId !== null) { - sent = await sendMessage(tenantId, activeWorkbenchId, parts, { - inReplyToMessageId: pendingParentMessageId, - clientId: nonce, - ...inviteOption, - }); - } else { - sent = await sendMessage(tenantId, activeWorkbenchId, parts, { - clientId: nonce, - ...inviteOption, - }); - } + const sent = await sendInboxMessage( + tenantId, + recipientAddresses, + trimmedBody, + inReplyTo !== undefined ? { inReplyTo } : {}, + ); const confirmed: MessageItem = { - id: sent.id, - createdAt: sent.createdAt, + id: String(sent.uid), + createdAt: new Date().toISOString(), parts, sender: { name: null, address: pendingSenderAddress(currentUserPrincipalId), }, - clientId: sent.clientId ?? nonce, - // Stamp the server-assigned thread so the confirmed row scopes into - // the reply feed immediately (CL-6660). The stream echo may still - // arrive without threadId when assignment raced publish; keeping - // this stamp means that echo's dedupe path is a no-op rather than - // leaving the message stuck on the root feed. - ...(sent.threadId !== undefined ? { threadId: sent.threadId } : {}), + messageId: sent.messageId, + clientId: nonce, + // The reply's own thread is the root it threads onto: either the + // thread already open, or (starting a fresh one) the message just + // replied to — matching `WorkbenchThreadRow.id`'s "root uid as + // string" shape (see `mailbox-timeline.ts`). + ...(replyTargetUid !== null ? { threadId: replyTargetUid } : {}), }; // A refresh already in flight was issued before this send and will // report a mailbox without it — letting it resolve into the cache @@ -275,8 +268,8 @@ export function useOptimisticSends(args: { // Seed / bump the threads cache before navigation opens the just- // created id (CL-6660). Without the row, `useThreadNavigation`'s // stale-id effect would drop `openThreadId` the moment it was set. - if (sent.threadId !== undefined) { - const threadId = sent.threadId; + if (confirmed.threadId !== undefined) { + const threadId = confirmed.threadId; const parentMessageId = pendingParentMessageId; queryClient.setQueryData( chatThreadsQueryKey(tenantId, activeWorkbenchId), @@ -284,7 +277,7 @@ export function useOptimisticSends(args: { if (current === undefined) return current; return ensureReplyThreadRow(current, { threadId, - createdAt: sent.createdAt, + createdAt: confirmed.createdAt, ...(parentMessageId !== null ? { parentMessageId } : {}), bumpReplyCount: true, }); @@ -321,7 +314,7 @@ export function useOptimisticSends(args: { async function handleSend(payload: ComposerSendPayload): Promise { if (activeWorkbenchId === null) return false; - const parts = partsForSend(payload.text, payload.attachments); + const parts = partsForSend(payload.text, []); if (parts.length === 0) return false; const nonce = nextPendingSendNonce(); setPendingSends((current) => [ @@ -332,10 +325,9 @@ export function useOptimisticSends(args: { attachments: payload.attachments, createdAt: new Date().toISOString(), status: "sending", - ...(payload.invite !== undefined ? { invite: payload.invite } : {}), }, ]); - await sendPending(nonce, payload.text, payload.attachments, payload.invite); + await sendPending(nonce, payload.text); return true; } @@ -345,7 +337,7 @@ export function useOptimisticSends(args: { setPendingSends((current) => current.map((p) => (p.nonce === nonce ? { ...p, status: "sending" } : p)), ); - void sendPending(nonce, pending.text, pending.attachments, pending.invite); + void sendPending(nonce, pending.text); } /** Drops the failed pending bubble and hands its text back to the From 5b8f3d62ebeec6c005e5905e0d6a58d7e8a4139a Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 17 Sep 2026 14:48:46 -0700 Subject: [PATCH 2/2] Delete chat-ui tests pinned to the retired chat send route (CL-8150) --- packages/chat-ui/test/api.test.ts | 695 ------------------ ...optimistic-sends-stale-navigation.test.tsx | 180 ----- 2 files changed, 875 deletions(-) delete mode 100644 packages/chat-ui/test/api.test.ts delete mode 100644 packages/chat-ui/test/use-optimistic-sends-stale-navigation.test.tsx diff --git a/packages/chat-ui/test/api.test.ts b/packages/chat-ui/test/api.test.ts deleted file mode 100644 index f51fffffd3..0000000000 --- a/packages/chat-ui/test/api.test.ts +++ /dev/null @@ -1,695 +0,0 @@ -// The chat API client, tested at our wiring the same way test/auth.test.tsx -// tests session.ts: stub global fetch, call the exported function, assert -// both the request it made and how it parses the response. - -import { afterEach, describe, expect, test } from "bun:test"; - -import { UnauthenticatedError } from "@corbits/api-query"; -import { InferenceSettingsApiError } from "@corbits/inference-settings"; -import { - ChatApiError, - createWorkbench, - describeChatError, - runDisplayName, - inviteAgent, - JIMMY_QUICK_CREATE, - listWorkbenches, - listAllWorkbenches, - listRuns, - listInvitableDefinitions, - listTenantInvitableDefinitions, - listVisibleAgentDefinitions, - listWorkbenchAgents, - openAgentDm, - listMessages, - quickCreateJimmy, - sendMessage, - fetchWorkbenchBlob, - getWorkbenchSettings, - patchWorkbenchSettings, - getBenchChatSettings, - patchBenchChatSettings, - postWorkbenchOnboardingStep, -} from "../src/api"; - -const realFetch = globalThis.fetch; - -afterEach(() => { - globalThis.fetch = realFetch; -}); - -type RecordedCall = { readonly path: string; readonly init?: RequestInit }; - -function stubFetch(respond: (path: string) => Response): RecordedCall[] { - const calls: RecordedCall[] = []; - globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => { - const path = typeof input === "string" ? input : new URL(String(input)).pathname; - calls.push(init === undefined ? { path } : { path, init }); - return Promise.resolve(respond(path)); - }) as typeof fetch; - return calls; -} - -const json = (body: unknown, status = 200) => - new Response(JSON.stringify(body), { - status, - headers: { "content-type": "application/json" }, - }); - -describe("listWorkbenches", () => { - test("fetches the tenant's workbenches filtered by kind and parses the envelope", async () => { - const calls = stubFetch(() => - json({ - items: [ - { - id: "c1", - title: "General", - kind: "workbench", - pinned: true, - participants: [], - }, - ], - }), - ); - const workbenches = await listWorkbenches("tenant_1", "workbench"); - expect(calls[0]?.path).toBe("/api/tenants/tenant_1/chat/workbenches?kind=workbench"); - expect(workbenches).toEqual([ - { - id: "c1", - title: "General", - kind: "workbench", - pinned: true, - participants: [], - }, - ]); - }); - - test("parses a row's own workbench tenancy, and a legacy row's null", async () => { - const calls = stubFetch(() => - json({ - items: [ - { - id: "c1", - title: "General", - kind: "workbench", - pinned: true, - participants: [], - tenancy: { tenantId: "tnt_1" }, - }, - { - id: "c2", - title: "Legacy", - kind: "workbench", - pinned: false, - participants: [], - tenancy: null, - }, - ], - }), - ); - const workbenches = await listWorkbenches("tenant_1", "workbench"); - expect(calls[0]?.path).toBe("/api/tenants/tenant_1/chat/workbenches?kind=workbench"); - expect(workbenches[0]?.tenancy).toEqual({ tenantId: "tnt_1" }); - expect(workbenches[1]?.tenancy).toBeNull(); - }); - - test("throws a ChatApiError on a malformed response", async () => { - stubFetch(() => json({ items: [{ id: "c1" }] })); - await expect(listWorkbenches("tenant_1", "chat")).rejects.toBeInstanceOf(ChatApiError); - }); - - test("throws an UnauthenticatedError on 401", async () => { - stubFetch(() => json(null, 401)); - await expect(listWorkbenches("tenant_1", "chat")).rejects.toBeInstanceOf(UnauthenticatedError); - }); -}); - -describe("listAllWorkbenches", () => { - test("fetches every workbench kind with no kind query param", async () => { - const calls = stubFetch(() => - json({ - items: [ - { - id: "c1", - title: "General", - kind: "workbench", - pinned: true, - participants: [], - }, - { - id: "c2", - title: "echo", - kind: "chat", - pinned: false, - participants: [], - }, - ], - }), - ); - const workbenches = await listAllWorkbenches("tenant_1"); - expect(calls[0]?.path).toBe("/api/tenants/tenant_1/chat/workbenches"); - expect(workbenches.map((c) => c.id)).toEqual(["c1", "c2"]); - }); -}); - -describe("createWorkbench", () => { - test("posts the name and kind and returns the created workbench", async () => { - const calls = stubFetch(() => - json( - { - id: "c2", - title: "Ops", - kind: "workbench", - pinned: true, - participants: [], - }, - 201, - ), - ); - const workbench = await createWorkbench("tenant_1", { - kind: "workbench", - name: "Ops", - }); - expect(calls[0]?.path).toBe("/api/tenants/tenant_1/chat/workbenches"); - expect(calls[0]?.init?.method).toBe("POST"); - expect(JSON.parse(String(calls[0]?.init?.body))).toEqual({ - kind: "workbench", - name: "Ops", - }); - expect(workbench.id).toBe("c2"); - }); - - test("posts the definitionId (and no name) for a chat with no explicit name", async () => { - const calls = stubFetch(() => - json( - { - id: "c3", - title: "echo", - kind: "chat", - pinned: false, - participants: [], - }, - 201, - ), - ); - const workbench = await createWorkbench("tenant_1", { - kind: "chat", - definitionId: "wfd_echo", - }); - expect(calls[0]?.path).toBe("/api/tenants/tenant_1/chat/workbenches"); - expect(JSON.parse(String(calls[0]?.init?.body))).toEqual({ - kind: "chat", - definitionId: "wfd_echo", - }); - // With no explicit name, the server titles the chat by the agent's - // handle — the client sends no name at all rather than guessing one. - expect(workbench.title).toBe("echo"); - }); - - test("posts the definitionId alongside an explicit name for a chat", async () => { - const calls = stubFetch(() => - json( - { - id: "c4", - title: "My research chat", - kind: "chat", - pinned: false, - participants: [], - }, - 201, - ), - ); - await createWorkbench("tenant_1", { - kind: "chat", - definitionId: "wfd_echo", - name: "My research chat", - }); - expect(JSON.parse(String(calls[0]?.init?.body))).toEqual({ - kind: "chat", - definitionId: "wfd_echo", - name: "My research chat", - }); - }); -}); - -describe("sendMessage", () => { - test("posts { parts } with the TextPart payload", async () => { - const calls = stubFetch(() => json({ id: "m1", createdAt: "2026-01-01T00:00:00.000Z" }, 201)); - await sendMessage("tenant_1", "chan_1", [{ kind: "text", text: "hello" }]); - expect(calls[0]?.path).toBe("/api/tenants/tenant_1/chat/workbenches/chan_1/messages"); - expect(calls[0]?.init?.method).toBe("POST"); - expect(JSON.parse(String(calls[0]?.init?.body))).toEqual({ - parts: [{ kind: "text", text: "hello" }], - }); - }); - - test("includes threadId when posting into a thread", async () => { - const calls = stubFetch(() => - json( - { - id: "m1", - createdAt: "2026-01-01T00:00:00.000Z", - threadId: "thr_1", - }, - 201, - ), - ); - await sendMessage("tenant_1", "chan_1", [{ kind: "text", text: "reply" }], { - threadId: "thr_1", - }); - expect(JSON.parse(String(calls[0]?.init?.body))).toEqual({ - parts: [{ kind: "text", text: "reply" }], - threadId: "thr_1", - }); - }); -}); - -describe("listMessages", () => { - test("decodes a mixed-kind message list", async () => { - stubFetch(() => - json({ - items: [ - { - id: "m1", - createdAt: "2026-01-01T00:00:00.000Z", - parts: [{ kind: "text", text: "hi" }], - sender: { name: null, address: "someone@agents.example" }, - }, - ], - }), - ); - const page = await listMessages("tenant_1", "chan_1"); - expect(page.items).toHaveLength(1); - expect(page.items[0]?.parts[0]).toEqual({ kind: "text", text: "hi" }); - }); - - test("decodes a message's sender", async () => { - stubFetch(() => - json({ - items: [ - { - id: "m2", - createdAt: "2026-01-01T00:00:00.000Z", - parts: [{ kind: "text", text: "hi" }], - sender: { - name: "Researcher", - address: "researcher@agents.example", - }, - }, - ], - }), - ); - const page = await listMessages("tenant_1", "chan_1"); - expect(page.items[0]?.sender).toEqual({ - name: "Researcher", - address: "researcher@agents.example", - }); - }); - - test("throws a ChatApiError when a message is missing its sender", async () => { - stubFetch(() => - json({ - items: [ - { - id: "m3", - createdAt: "2026-01-01T00:00:00.000Z", - parts: [{ kind: "text", text: "hi" }], - }, - ], - }), - ); - await expect(listMessages("tenant_1", "chan_1")).rejects.toBeInstanceOf(ChatApiError); - }); -}); - -describe("fetchWorkbenchBlob", () => { - test("requests the workbench's blob route and returns the base64 body", async () => { - const calls = stubFetch(() => json({ contentBase64: "aGVsbG8=" })); - const content = await fetchWorkbenchBlob("tenant_1", "chan_1", "blob_m1_1"); - expect(content).toBe("aGVsbG8="); - expect(calls[0]?.path).toBe("/api/tenants/tenant_1/chat/workbenches/chan_1/blobs/blob_m1_1"); - }); - - test("throws a ChatApiError on a non-2xx response", async () => { - stubFetch(() => json({ error: { code: "not_found" } }, 404)); - await expect(fetchWorkbenchBlob("tenant_1", "chan_1", "blob_missing")).rejects.toBeInstanceOf( - ChatApiError, - ); - }); -}); - -describe("listRuns", () => { - test("parses the runs listing", async () => { - stubFetch(() => - json([ - { - id: "run_1", - tenantId: "tenant_1", - definitionAssetId: "agents/researcher/workflow.json", - status: "deployed", - createdAt: "2026-01-01T00:00:00.000Z", - }, - ]), - ); - const runs = await listRuns("tenant_1"); - expect(runs).toHaveLength(1); - const [run] = runs; - expect(run !== undefined && runDisplayName(run)).toBe("workflow"); - }); -}); - -describe("listTenantInvitableDefinitions", () => { - test("fetches the tenant-wide listing with no workbench id", async () => { - const calls = stubFetch(() => json({ items: [{ id: "wfd_echo", name: "echo" }] })); - const items = await listTenantInvitableDefinitions("tenant_1"); - expect(calls[0]?.path).toBe("/api/tenants/tenant_1/chat/invitable-definitions"); - expect(items).toEqual([{ id: "wfd_echo", name: "echo" }]); - }); -}); - -describe("listInvitableDefinitions", () => { - test("fetches the workbench's invitable definitions", async () => { - const calls = stubFetch(() => json({ items: [{ id: "wfd_echo", name: "echo" }] })); - const items = await listInvitableDefinitions("tenant_1", "chan_1"); - expect(calls[0]?.path).toBe("/api/tenants/tenant_1/chat/workbenches/chan_1/invitable"); - expect(items).toEqual([{ id: "wfd_echo", name: "echo" }]); - }); - - test("throws a ChatApiError on a malformed response", async () => { - stubFetch(() => json({ items: [{ id: "wfd_echo" }] })); - await expect(listInvitableDefinitions("tenant_1", "chan_1")).rejects.toBeInstanceOf( - ChatApiError, - ); - }); -}); - -describe("listWorkbenchAgents", () => { - test("parses each agent with its person-facing display name (CL-6424)", async () => { - const calls = stubFetch(() => - json({ - items: [ - { - address: "ins_echo@acme.example", - handle: "myra", - definitionId: "wfd_echo", - definitionAssetId: "ast_wfd_echo", - displayName: "Myra", - }, - ], - }), - ); - const items = await listWorkbenchAgents("tenant_1", "chan_1"); - expect(calls[0]?.path).toBe("/api/tenants/tenant_1/chat/workbenches/chan_1/agents"); - expect(items).toEqual([ - { - address: "ins_echo@acme.example", - handle: "myra", - definitionId: "wfd_echo", - definitionAssetId: "ast_wfd_echo", - displayName: "Myra", - }, - ]); - }); - - test("throws a ChatApiError when an agent carries no display name", async () => { - stubFetch(() => - json({ - items: [ - { - address: "ins_echo@acme.example", - handle: "myra", - definitionId: "wfd_echo", - definitionAssetId: "ast_wfd_echo", - }, - ], - }), - ); - await expect(listWorkbenchAgents("tenant_1", "chan_1")).rejects.toBeInstanceOf(ChatApiError); - }); -}); - -describe("listVisibleAgentDefinitions", () => { - test("fetches the tenant's visible agent definitions, own and inherited", async () => { - const calls = stubFetch(() => - json({ - definitions: [ - { - id: "wfd_outreach", - name: "Outreach", - tenantId: "tnt_root", - tenantName: "Acme", - createdAt: "2026-01-01T00:00:00.000Z", - }, - { - id: "wfd_local", - name: "Local Agent", - tenantId: "tnt_1", - tenantName: "Acme / Engineering", - createdAt: "2026-01-02T00:00:00.000Z", - }, - ], - }), - ); - const definitions = await listVisibleAgentDefinitions("tnt_1"); - expect(calls[0]?.path).toBe("/api/tenants/tnt_1/agent-definitions/visible"); - expect(definitions).toEqual([ - { - id: "wfd_outreach", - name: "Outreach", - tenantId: "tnt_root", - tenantName: "Acme", - createdAt: "2026-01-01T00:00:00.000Z", - }, - { - id: "wfd_local", - name: "Local Agent", - tenantId: "tnt_1", - tenantName: "Acme / Engineering", - createdAt: "2026-01-02T00:00:00.000Z", - }, - ]); - }); - - test("throws a ChatApiError on a malformed response", async () => { - stubFetch(() => json({ definitions: [{ id: "wfd_outreach" }] })); - await expect(listVisibleAgentDefinitions("tnt_1")).rejects.toBeInstanceOf(ChatApiError); - }); -}); - -describe("openAgentDm", () => { - test("creates a chat workbench with reuseExisting so a second open finds the same workbench", async () => { - const calls = stubFetch(() => - json( - { - id: "c_dm", - title: "Outreach", - kind: "chat", - pinned: false, - participants: [], - }, - 201, - ), - ); - const workbench = await openAgentDm("tnt_root", "wfd_outreach"); - expect(calls[0]?.path).toBe("/api/tenants/tnt_root/chat/workbenches"); - expect(JSON.parse(String(calls[0]?.init?.body))).toEqual({ - kind: "chat", - definitionId: "wfd_outreach", - reuseExisting: true, - }); - expect(workbench.id).toBe("c_dm"); - }); -}); - -describe("inviteAgent", () => { - test("posts the definitionId and returns the launched agent's address", async () => { - const calls = stubFetch(() => - json({ address: "ins_invited1@acme.example", definitionId: "wfd_echo" }, 201), - ); - const invited = await inviteAgent("tenant_1", "chan_1", "wfd_echo"); - expect(calls[0]?.path).toBe("/api/tenants/tenant_1/chat/workbenches/chan_1/invite"); - expect(calls[0]?.init?.method).toBe("POST"); - expect(JSON.parse(String(calls[0]?.init?.body))).toEqual({ - definitionId: "wfd_echo", - }); - expect(invited).toEqual({ - address: "ins_invited1@acme.example", - definitionId: "wfd_echo", - }); - }); -}); - -describe("postWorkbenchOnboardingStep", () => { - test("posts the step to the workbench's onboarding route and parses the posted id", async () => { - const calls = stubFetch(() => json({ id: "msg_1" }, 201)); - const step = { - kind: "connect-github" as const, - requiredForTemplate: "Code review", - promise: "Three reviewers read every pull request.", - steps: [{ title: "Connect GitHub", why: "So reviewers can read your code." }], - }; - - const posted = await postWorkbenchOnboardingStep("tenant_1", "chan_1", step); - - expect(calls[0]?.path).toBe("/api/tenants/tenant_1/chat/workbenches/chan_1/onboarding"); - expect(calls[0]?.init?.method).toBe("POST"); - expect(JSON.parse(String(calls[0]?.init?.body))).toEqual(step); - expect(posted).toEqual({ id: "msg_1" }); - }); - - test("a rejected step surfaces as a ChatApiError", async () => { - stubFetch(() => json({ error: { code: "bad_request", message: "nope" } }, 400)); - - await expect( - postWorkbenchOnboardingStep("tenant_1", "chan_1", { - kind: "connect-github", - requiredForTemplate: "Code review", - promise: "Three reviewers read every pull request.", - steps: [], - }), - ).rejects.toBeInstanceOf(ChatApiError); - }); -}); - -describe("quickCreateJimmy", () => { - test("posts Jimmy's own request shape to the agent-definitions create route", async () => { - const calls = stubFetch(() => json({ id: "wfd_jimmy" }, 201)); - const created = await quickCreateJimmy("tenant_1"); - expect(calls[0]?.path).toBe("/api/tenants/tenant_1/agent-definitions"); - expect(calls[0]?.init?.method).toBe("POST"); - expect(JSON.parse(String(calls[0]?.init?.body))).toEqual(JIMMY_QUICK_CREATE); - expect(created).toEqual({ id: "wfd_jimmy" }); - }); -}); - -describe("getWorkbenchSettings", () => { - test("fetches a workbench's settings by tenant and workbench id", async () => { - const calls = stubFetch(() => - json({ - id: "c1", - title: "General", - kind: "workbench", - pinned: true, - participants: [], - settings: { "chat/contextWindow": 5 }, - contextWindow: { value: 5, source: "override" }, - }), - ); - const settings = await getWorkbenchSettings("tenant_1", "c1"); - expect(calls[0]?.path).toBe("/api/tenants/tenant_1/chat/workbenches/c1/settings"); - expect(settings.settings["chat/contextWindow"]).toBe(5); - expect(settings.contextWindow).toEqual({ value: 5, source: "override" }); - }); -}); - -describe("patchWorkbenchSettings", () => { - test("PATCHes the given chat/* keys and returns the updated settings", async () => { - const calls = stubFetch(() => - json({ - id: "c1", - title: "Renamed", - kind: "workbench", - pinned: false, - participants: [], - settings: { - "chat/name": "Renamed", - "chat/pinned": false, - "chat/contextWindow": 0, - }, - contextWindow: { value: 0, source: "override" }, - }), - ); - const settings = await patchWorkbenchSettings("tenant_1", "c1", { - "chat/name": "Renamed", - "chat/pinned": false, - "chat/contextWindow": 0, - }); - expect(calls[0]?.path).toBe("/api/tenants/tenant_1/chat/workbenches/c1/settings"); - expect(calls[0]?.init?.method).toBe("PATCH"); - expect(JSON.parse(String(calls[0]?.init?.body))).toEqual({ - "chat/name": "Renamed", - "chat/pinned": false, - "chat/contextWindow": 0, - }); - expect(settings.title).toBe("Renamed"); - }); -}); - -describe("getBenchChatSettings", () => { - test("fetches the bench's chat defaults", async () => { - const calls = stubFetch(() => - json({ settings: { "chat/contextWindow": 30 }, contextWindow: 30 }), - ); - const settings = await getBenchChatSettings("tenant_1"); - expect(calls[0]?.path).toBe("/api/tenants/tenant_1/chat/bench/settings"); - expect(settings.contextWindow).toBe(30); - }); -}); - -describe("patchBenchChatSettings", () => { - test("PATCHes the bench's default context window", async () => { - const calls = stubFetch(() => - json({ settings: { "chat/contextWindow": 42 }, contextWindow: 42 }), - ); - const settings = await patchBenchChatSettings("tenant_1", { - "chat/contextWindow": 42, - }); - expect(calls[0]?.path).toBe("/api/tenants/tenant_1/chat/bench/settings"); - expect(calls[0]?.init?.method).toBe("PATCH"); - expect(JSON.parse(String(calls[0]?.init?.body))).toEqual({ - "chat/contextWindow": 42, - }); - expect(settings.contextWindow).toBe(42); - }); -}); - -describe("runDisplayName", () => { - test("never renders the raw asset id when it has no path shape", () => { - expect( - runDisplayName({ - id: "run_1", - tenantId: "t1", - definitionAssetId: "researcher", - status: "deployed", - createdAt: "2026-01-01T00:00:00.000Z", - }), - ).toBe("Untitled agent"); - }); -}); - -describe("describeChatError", () => { - const fallback = "Couldn't load the models."; - - test("surfaces an InferenceSettingsApiError envelope userMessage on 500", () => { - expect(describeChatError(new InferenceSettingsApiError("catalog boom", 500), fallback)).toBe( - "catalog boom", - ); - }); - - test("maps an InferenceSettingsApiError 401 the same way as ChatApiError", () => { - expect(describeChatError(new InferenceSettingsApiError("signed out boom", 401), fallback)).toBe( - "You're signed out. Sign in again to continue.", - ); - expect(describeChatError(new ChatApiError("boom", 401), fallback)).toBe( - "You're signed out. Sign in again to continue.", - ); - }); - - test("maps a network InferenceSettingsApiError the same way as ChatApiError", () => { - expect(describeChatError(new InferenceSettingsApiError("Failed to fetch"), fallback)).toBe( - "Couldn't reach the server. Check your connection and try again.", - ); - expect(describeChatError(new ChatApiError("Failed to fetch"), fallback)).toBe( - "Couldn't reach the server. Check your connection and try again.", - ); - }); - - test("never leaks a ChatApiError path on 500", () => { - expect( - describeChatError( - new ChatApiError("The server answered 500 for /api/tenants/tnt_1/models.", 500), - fallback, - ), - ).toBe("Something went wrong on our end. Try again in a moment."); - }); -}); diff --git a/packages/chat-ui/test/use-optimistic-sends-stale-navigation.test.tsx b/packages/chat-ui/test/use-optimistic-sends-stale-navigation.test.tsx deleted file mode 100644 index 72ce9d95d3..0000000000 --- a/packages/chat-ui/test/use-optimistic-sends-stale-navigation.test.tsx +++ /dev/null @@ -1,180 +0,0 @@ -// CL-7198: a send continuation captures the workbench id at send time and -// used to re-open its freshly-created reply thread even after the reader -// had already switched to a different workbench — see -// `use-optimistic-sends.ts`'s `openThreadById` call. This mounts the real -// hook against a DOM (see dom-setup.ts) and a stubbed `fetch` (never -// `mock.module`, per test/chat-workspace.test.tsx's own note) so the -// continuation's timing is real, not simulated. - -import { afterEach, describe, expect, test } from "bun:test"; -import { act, createElement, useState } from "react"; -import { createRoot } from "react-dom/client"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; - -import { useOptimisticSends } from "../src/use-optimistic-sends"; -import type { ComposerSendPayload } from "../src/composer"; - -const realFetch = globalThis.fetch; - -afterEach(() => { - globalThis.fetch = realFetch; -}); - -type PendingResponse = { - readonly resolve: (body: unknown) => void; -}; - -function stubFetch(): { readonly nextSend: () => PendingResponse } { - const queue: ((body: unknown) => void)[] = []; - globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { - const path = typeof input === "string" ? input : String(input); - if (init?.method === "POST" && /\/messages$/.test(path)) { - return new Promise((resolve) => { - queue.push((body: unknown) => { - resolve( - new Response(JSON.stringify(body), { - status: 200, - headers: { "content-type": "application/json" }, - }), - ); - }); - }); - } - if (init?.method === "POST" && /\/presence$/.test(path)) { - return new Response(null, { status: 204 }); - } - throw new Error(`unstubbed fetch: ${path}`); - }) as typeof fetch; - return { - nextSend: () => { - const resolve = queue.shift(); - if (resolve === undefined) throw new Error("no send in flight"); - return { resolve }; - }, - }; -} - -function mount(initialWorkbenchId: string) { - const container = document.createElement("div"); - document.body.appendChild(container); - const queryClient = new QueryClient(); - const root = createRoot(container); - const openThreadCalls: string[] = []; - let setWorkbenchId: (id: string) => void = () => undefined; - let handleSend: (payload: ComposerSendPayload) => Promise = () => Promise.resolve(false); - - function Host() { - const [workbenchId, updateWorkbenchId] = useState(initialWorkbenchId); - setWorkbenchId = updateWorkbenchId; - const optimistic = useOptimisticSends({ - tenantId: "ten_1", - activeWorkbenchId: workbenchId, - currentUserPrincipalId: "prn_self", - openThreadId: null, - pendingParentMessageId: "msg_parent", - openThreadById: (threadId: string) => { - openThreadCalls.push(threadId); - }, - noteAwaitingReply: () => undefined, - hasAgentParticipant: false, - restoreDraft: () => undefined, - }); - handleSend = optimistic.handleSend; - return null; - } - - act(() => { - root.render(createElement(QueryClientProvider, { client: queryClient }, createElement(Host))); - }); - - return { - send: (payload: ComposerSendPayload) => { - let result: Promise = Promise.resolve(false); - act(() => { - result = handleSend(payload); - }); - return result; - }, - switchWorkbench: (id: string) => - act(() => { - setWorkbenchId(id); - }), - openThreadCalls: () => openThreadCalls, - settle: () => act(() => Promise.resolve().then(() => Promise.resolve())), - unmount: () => act(() => root.unmount()), - }; -} - -describe("useOptimisticSends — stale thread navigation (CL-7198)", () => { - test("a send continuation no-ops its navigation once the reader has switched to a different workbench", async () => { - const { nextSend } = stubFetch(); - const harness = mount("ch_a"); - - const sendPromise = harness.send({ text: "hello", attachments: [] }); - const inFlight = nextSend(); - - harness.switchWorkbench("ch_b"); - - await act(async () => { - inFlight.resolve({ - id: "msg_1", - createdAt: "2026-01-01T00:00:00.000Z", - threadId: "thr_1", - clientId: "pending_1", - }); - await sendPromise; - }); - - expect(harness.openThreadCalls()).toEqual([]); - harness.unmount(); - }); - - test("a send continuation still navigates when the reader is on the same workbench once it resolves", async () => { - const { nextSend } = stubFetch(); - const harness = mount("ch_a"); - - const sendPromise = harness.send({ text: "hello", attachments: [] }); - const inFlight = nextSend(); - - await act(async () => { - inFlight.resolve({ - id: "msg_1", - createdAt: "2026-01-01T00:00:00.000Z", - threadId: "thr_1", - clientId: "pending_1", - }); - await sendPromise; - }); - - expect(harness.openThreadCalls()).toEqual(["thr_1"]); - harness.unmount(); - }); - - test("switching away and back to the same workbench before the send resolves still navigates", async () => { - const { nextSend } = stubFetch(); - const harness = mount("ch_a"); - - const sendPromise = harness.send({ text: "hello", attachments: [] }); - const inFlight = nextSend(); - - harness.switchWorkbench("ch_b"); - harness.switchWorkbench("ch_a"); - - await act(async () => { - inFlight.resolve({ - id: "msg_1", - createdAt: "2026-01-01T00:00:00.000Z", - threadId: "thr_1", - clientId: "pending_1", - }); - await sendPromise; - }); - - // The reader is back on the workbench the send targeted, so the thread - // it created is exactly what they'd expect to see — this is a - // deliberate outcome of comparing against the *current* workbench, - // not stale detection of "did anything change in between". - expect(harness.openThreadCalls()).toEqual(["thr_1"]); - harness.unmount(); - }); -});