diff --git a/webui/src/components/MessageBubble.tsx b/webui/src/components/MessageBubble.tsx index 7e9ae0ca4..f81966dc3 100644 --- a/webui/src/components/MessageBubble.tsx +++ b/webui/src/components/MessageBubble.tsx @@ -9,6 +9,7 @@ import { import { Check, ChevronRight, + CircleAlert, Clock3, Copy, ImageIcon, @@ -44,6 +45,8 @@ import type { UIImage, UIMediaAttachment, UIMessage, + MessageDeliveryErrorKind, + MessageDeliveryStatus, } from "@/lib/types"; interface MessageBubbleProps { @@ -130,6 +133,91 @@ function MessageCopyButton({ content }: { content: string }) { ); } +function deliveryErrorCopy( + kind: MessageDeliveryErrorKind | undefined, + t: (key: string) => string, +): { title: string; body: string } { + switch (kind) { + case "message_too_big": + return { + title: t("errors.messageTooBig.title"), + body: t("errors.messageTooBig.body"), + }; + case "workspace_scope_rejected": + return { + title: t("errors.workspaceScopeRejected.title"), + body: t("errors.workspaceScopeRejected.body"), + }; + case "turn_rejected": + case undefined: + return { + title: t("errors.turnRejected.title"), + body: t("errors.turnRejected.body"), + }; + default: { + const _exhaustive: never = kind; + return { title: String(_exhaustive), body: "" }; + } + } +} + +function UserDeliveryStatus({ + status, + errorKind, +}: { + status: MessageDeliveryStatus | undefined; + errorKind: MessageDeliveryErrorKind | undefined; +}) { + const { t } = useTranslation(); + if (status !== "sending" && status !== "failed") return null; + if (status === "sending") { + return ( + + + {t("message.delivery.sending")} + + ); + } + + const label = t("message.delivery.failed"); + const { title, body } = deliveryErrorCopy(errorKind, t); + return ( + <> + + + + + +

{title}

+

{body}

+
+
+ + {title}. {body} + + + ); +} + /** Render user turns as compact bubbles and assistant turns as document-like prose. */ export function MessageBubble({ message, @@ -163,6 +251,8 @@ export function MessageBubble({ const parsedMessage = parseQuotedUserMessage(message.content); const userContent = parsedMessage.content; const hasText = userContent.trim().length > 0; + const showDeliveryStatus = + message.deliveryStatus === "sending" || message.deliveryStatus === "failed"; const quotedContext = parsedMessage.quotedContext; const slashCommand = matchingSlashCommand(userContent, slashCommands); const messageText = slashCommand ? ( @@ -208,10 +298,14 @@ export function MessageBubble({ {messageText}

) : null} - {hasText && showCopyAction ? ( + {showDeliveryStatus || (hasText && showCopyAction) ? ( -
- +
+ + {hasText && showCopyAction ? : null}
) : null} diff --git a/webui/src/components/thread/StreamErrorNotice.tsx b/webui/src/components/thread/StreamErrorNotice.tsx index 4875b7353..541fc45d9 100644 --- a/webui/src/components/thread/StreamErrorNotice.tsx +++ b/webui/src/components/thread/StreamErrorNotice.tsx @@ -11,9 +11,8 @@ interface StreamErrorNoticeProps { } /** - * Dismissible banner that surfaces transport-level faults the user needs to - * know about. Rendered above the composer so the message the fault referred - * to remains in view just above. ``role="alert"`` + ``aria-live="assertive"`` + * Fallback banner for transport-level faults that cannot be attached to a + * visible failed message. ``role="alert"`` + ``aria-live="assertive"`` * ensures screen readers announce the failure. */ export function StreamErrorNotice({ error, onDismiss }: StreamErrorNoticeProps) { diff --git a/webui/src/components/thread/ThreadMessages.tsx b/webui/src/components/thread/ThreadMessages.tsx index ad7cd18ca..a9464c316 100644 --- a/webui/src/components/thread/ThreadMessages.tsx +++ b/webui/src/components/thread/ThreadMessages.tsx @@ -107,7 +107,11 @@ export function ThreadMessages({ unit.type === "message" && unit.message.role === "assistant" && forkFlags[index] ? nextUserIndex : undefined; - if (unit.type === "message" && unit.message.role === "user") nextUserIndex += 1; + if ( + unit.type === "message" + && unit.message.role === "user" + && unit.message.deliveryStatus !== "failed" + ) nextUserIndex += 1; return ( = 0; index -= 1) { const message = messages[index]; - if (message.role === "user" && message.turnId) return message.turnId; + if ( + message.role === "user" + && message.deliveryStatus !== "failed" + && message.turnId + ) return message.turnId; } return null; } +function hasInlineDeliveryError( + messages: UIMessage[], + error: StreamError | null, +): boolean { + if (!error?.turnId) return false; + return messages.some((message) => ( + message.role === "user" + && message.turnId === error.turnId + && message.deliveryStatus === "failed" + && message.deliveryErrorKind === error.kind + )); +} + function completedAssistantTurnIds(messages: UIMessage[]): string[] { return Array.from(new Set( messages @@ -1283,7 +1300,7 @@ export function ThreadShell({ const composer = ( <> - {streamError ? ( + {streamError && !hasInlineDeliveryError(messages, streamError) ? ( 0 - ? messages.slice(0, hiddenMessageCount).filter((message) => message.role === "user").length + ? messages.slice(0, hiddenMessageCount).filter( + (message) => message.role === "user" && message.deliveryStatus !== "failed", + ).length : 0); const visibleForkBoundaryMessageCount = forkBoundaryMessageCount !== null && forkBoundaryMessageCount > hiddenMessageCount diff --git a/webui/src/hooks/useNanobotStream.ts b/webui/src/hooks/useNanobotStream.ts index e0bcde04a..a81b3a221 100644 --- a/webui/src/hooks/useNanobotStream.ts +++ b/webui/src/hooks/useNanobotStream.ts @@ -17,6 +17,7 @@ import type { OutboundMcpPresetMention, OutboundMedia, GoalStateWsPayload, + MessageDeliveryStatus, ToolProgressEvent, UIMediaAttachment, UIFileEdit, @@ -519,6 +520,27 @@ function eventTurnId(ev: InboundEvent): string | undefined { return "turn_id" in ev && typeof ev.turn_id === "string" ? ev.turn_id : undefined; } +function transitionTurnDelivery( + messages: UIMessage[], + turnId: string, + status: MessageDeliveryStatus, +): UIMessage[] { + let changed = false; + const next = messages.map((message) => { + if ( + message.role !== "user" + || message.turnId !== turnId + || message.deliveryStatus === status + || (status === "accepted" && message.deliveryStatus !== "sending") + ) { + return message; + } + changed = true; + return { ...message, deliveryStatus: status }; + }); + return changed ? next : messages; +} + export function useNanobotStream( chatId: string | null, initialMessages: UIMessage[] = [], @@ -697,7 +719,15 @@ export function useNanobotStream( ) { fileEditSegmentRef.current = null; } - return prev.filter((message) => message.turnId !== rejectedTurnId); + return prev.flatMap((message) => { + if (message.turnId !== rejectedTurnId) return [message]; + if (message.role !== "user") return []; + return [{ + ...message, + deliveryStatus: "failed", + deliveryErrorKind: err.kind, + }]; + }); }); const remainingStartedAt = client.getRunStartedAt(chatId); @@ -975,6 +1005,11 @@ export function useNanobotStream( } return; } + const turnId = eventTurnId(ev); + if (turnId) { + setMessages((prev) => transitionTurnDelivery(prev, turnId, "accepted")); + } + if (ev.event === "message_accepted") return; const sideChannelEvent = isSideChannelEvent(ev); if ( streamEndTimerRef.current !== null @@ -1354,6 +1389,7 @@ export function useNanobotStream( turnId, turnPhase: "user", turnSeq: 0, + deliveryStatus: "sending", createdAt: Date.now(), ...(previews ? { media: previews } : {}), ...(options?.cliApps?.length ? { cliApps: options.cliApps } : {}), diff --git a/webui/src/i18n/locales/en/common.json b/webui/src/i18n/locales/en/common.json index 417495294..1e8cc1a7a 100644 --- a/webui/src/i18n/locales/en/common.json +++ b/webui/src/i18n/locales/en/common.json @@ -1172,6 +1172,10 @@ }, "message": { "streaming": "streaming", + "delivery": { + "sending": "Sending…", + "failed": "Not sent" + }, "assistantTyping": "Assistant is typing", "toolSingle": "Using a tool", "toolMany": "Used {{count}} tools", diff --git a/webui/src/i18n/locales/es/common.json b/webui/src/i18n/locales/es/common.json index 2a37f01a9..d8847971f 100644 --- a/webui/src/i18n/locales/es/common.json +++ b/webui/src/i18n/locales/es/common.json @@ -1159,6 +1159,10 @@ }, "message": { "streaming": "transmitiendo", + "delivery": { + "sending": "Enviando…", + "failed": "No enviado" + }, "assistantTyping": "El asistente está escribiendo", "toolSingle": "Usando una herramienta", "toolMany": "Se usaron {{count}} herramientas", diff --git a/webui/src/i18n/locales/fr/common.json b/webui/src/i18n/locales/fr/common.json index 1e61365b2..d241c6fa4 100644 --- a/webui/src/i18n/locales/fr/common.json +++ b/webui/src/i18n/locales/fr/common.json @@ -1158,6 +1158,10 @@ }, "message": { "streaming": "en cours de génération", + "delivery": { + "sending": "Envoi…", + "failed": "Non envoyé" + }, "assistantTyping": "L’assistant est en train d’écrire", "toolSingle": "Utilisation d’un outil", "toolMany": "{{count}} outils utilisés", diff --git a/webui/src/i18n/locales/id/common.json b/webui/src/i18n/locales/id/common.json index 60223028a..49b06fc00 100644 --- a/webui/src/i18n/locales/id/common.json +++ b/webui/src/i18n/locales/id/common.json @@ -1158,6 +1158,10 @@ }, "message": { "streaming": "sedang mengalir", + "delivery": { + "sending": "Mengirim…", + "failed": "Belum terkirim" + }, "assistantTyping": "Asisten sedang mengetik", "toolSingle": "Menggunakan sebuah alat", "toolMany": "Menggunakan {{count}} alat", diff --git a/webui/src/i18n/locales/ja/common.json b/webui/src/i18n/locales/ja/common.json index 0e256f775..b85e9c225 100644 --- a/webui/src/i18n/locales/ja/common.json +++ b/webui/src/i18n/locales/ja/common.json @@ -1158,6 +1158,10 @@ }, "message": { "streaming": "生成中", + "delivery": { + "sending": "送信中…", + "failed": "未送信" + }, "assistantTyping": "アシスタントが入力中", "toolSingle": "ツールを使用中", "toolMany": "{{count}} 個のツールを使用", diff --git a/webui/src/i18n/locales/ko/common.json b/webui/src/i18n/locales/ko/common.json index 716bea6da..fa8159d0b 100644 --- a/webui/src/i18n/locales/ko/common.json +++ b/webui/src/i18n/locales/ko/common.json @@ -1158,6 +1158,10 @@ }, "message": { "streaming": "생성 중", + "delivery": { + "sending": "전송 중…", + "failed": "전송되지 않음" + }, "assistantTyping": "도우미가 입력 중", "toolSingle": "도구 사용 중", "toolMany": "도구 {{count}}개 사용됨", diff --git a/webui/src/i18n/locales/pt-BR/common.json b/webui/src/i18n/locales/pt-BR/common.json index f78f15218..e3bac6f03 100644 --- a/webui/src/i18n/locales/pt-BR/common.json +++ b/webui/src/i18n/locales/pt-BR/common.json @@ -1172,6 +1172,10 @@ }, "message": { "streaming": "transmitindo", + "delivery": { + "sending": "Enviando…", + "failed": "Não enviada" + }, "assistantTyping": "Assistente está digitando", "toolSingle": "Usando uma ferramenta", "toolMany": "Foram usadas {{count}} ferramentas", diff --git a/webui/src/i18n/locales/vi/common.json b/webui/src/i18n/locales/vi/common.json index 6d633874b..5ae45f6f7 100644 --- a/webui/src/i18n/locales/vi/common.json +++ b/webui/src/i18n/locales/vi/common.json @@ -1158,6 +1158,10 @@ }, "message": { "streaming": "đang truyền", + "delivery": { + "sending": "Đang gửi…", + "failed": "Chưa gửi" + }, "assistantTyping": "Trợ lý đang nhập", "toolSingle": "Đang dùng một công cụ", "toolMany": "Đã dùng {{count}} công cụ", diff --git a/webui/src/i18n/locales/zh-CN/common.json b/webui/src/i18n/locales/zh-CN/common.json index 40a6feafd..385f27aae 100644 --- a/webui/src/i18n/locales/zh-CN/common.json +++ b/webui/src/i18n/locales/zh-CN/common.json @@ -1172,6 +1172,10 @@ }, "message": { "streaming": "流式输出中", + "delivery": { + "sending": "发送中…", + "failed": "未发送" + }, "assistantTyping": "助手正在输入", "toolSingle": "正在使用工具", "toolMany": "已使用 {{count}} 个工具", diff --git a/webui/src/i18n/locales/zh-TW/common.json b/webui/src/i18n/locales/zh-TW/common.json index 2d02c80da..2052e8ce2 100644 --- a/webui/src/i18n/locales/zh-TW/common.json +++ b/webui/src/i18n/locales/zh-TW/common.json @@ -1158,6 +1158,10 @@ }, "message": { "streaming": "串流輸出中", + "delivery": { + "sending": "傳送中…", + "failed": "未傳送" + }, "assistantTyping": "助理正在輸入", "toolSingle": "正在使用工具", "toolMany": "已使用 {{count}} 個工具", diff --git a/webui/src/lib/nanobot-client.ts b/webui/src/lib/nanobot-client.ts index b7f2b2b5a..1c8d4630e 100644 --- a/webui/src/lib/nanobot-client.ts +++ b/webui/src/lib/nanobot-client.ts @@ -935,6 +935,9 @@ export class NanobotClient { : null; if (parsed.event === "message_accepted") { this.recordRunAcceptance(parsed.chat_id, parsed.turn_id); + if (!isSystemCommandTurnId(turnId)) { + this.dispatch(parsed.chat_id, parsed); + } return; } if (isSystemCommandTurnId(turnId)) { diff --git a/webui/src/lib/types.ts b/webui/src/lib/types.ts index a0ef20047..1f4cb1180 100644 --- a/webui/src/lib/types.ts +++ b/webui/src/lib/types.ts @@ -5,6 +5,11 @@ export type Role = "user" | "assistant" | "tool" | "system"; export type MessageKind = "message" | "trace"; export type UITurnPhase = "user" | "reasoning" | "activity" | "answer" | "complete"; +export type MessageDeliveryStatus = "sending" | "accepted" | "failed"; +export type MessageDeliveryErrorKind = + | "message_too_big" + | "workspace_scope_rejected" + | "turn_rejected"; /** One image attached to a UIMessage. * @@ -76,6 +81,10 @@ export interface UIMessage { turnId?: string; turnPhase?: UITurnPhase; turnSeq?: number; + /** Ephemeral delivery lifecycle for optimistic user messages. */ + deliveryStatus?: MessageDeliveryStatus; + /** Structured rejection reason shown with a failed optimistic message. */ + deliveryErrorKind?: MessageDeliveryErrorKind; } export interface UICliAppAttachment { diff --git a/webui/src/tests/message-bubble.test.tsx b/webui/src/tests/message-bubble.test.tsx index 9b82b8d88..62f73d164 100644 --- a/webui/src/tests/message-bubble.test.tsx +++ b/webui/src/tests/message-bubble.test.tsx @@ -113,6 +113,53 @@ describe("MessageBubble", () => { expect(screen.queryByRole("button", { name: "Fork" })).not.toBeInTheDocument(); }); + it("renders failed delivery details on focus without persistent accepted chrome", async () => { + const message: UIMessage = { + id: "u-delivery", + role: "user", + content: "hello", + createdAt: Date.now(), + deliveryStatus: "sending", + }; + + const { rerender } = render(); + + expect(screen.getByRole("status")).toHaveTextContent("Sending…"); + + rerender(); + expect(screen.queryByRole("status")).not.toBeInTheDocument(); + + rerender( + , + ); + expect(screen.queryByRole("status")).not.toBeInTheDocument(); + const failedStatus = screen.getByRole("button", { + name: "Not sent: Message too large", + }); + expect(failedStatus).toHaveClass( + "text-destructive/80", + "dark:text-red-400/80", + ); + expect(screen.getByText("hello")).not.toHaveClass("ring-1"); + expect(screen.getByText("hello")).not.toHaveClass("ring-destructive/30"); + expect(screen.queryByRole("tooltip")).not.toBeInTheDocument(); + + fireEvent.focus(failedStatus); + + const tooltip = await screen.findByRole("tooltip"); + expect(tooltip).toHaveTextContent("Message too large"); + expect(tooltip).toHaveTextContent( + "The server rejected your last message because it exceeded the size limit.", + ); + expect(screen.getByRole("alert")).toHaveClass("sr-only"); + }); + it("styles only generated quoted context in user messages", () => { const message: UIMessage = { id: "u-quote", diff --git a/webui/src/tests/nanobot-client.test.ts b/webui/src/tests/nanobot-client.test.ts index bdecddc6d..c8e074ee8 100644 --- a/webui/src/tests/nanobot-client.test.ts +++ b/webui/src/tests/nanobot-client.test.ts @@ -90,6 +90,31 @@ describe("NanobotClient", () => { }); }); + it("routes message acceptance acknowledgements to the matching chat handler", () => { + const client = new NanobotClient({ + url: "ws://test", + reconnect: false, + socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket, + }); + const handler = vi.fn(); + client.onChat("chat-ack", handler); + client.connect(); + lastSocket().fakeOpen(); + client.sendMessage("chat-ack", "hello", undefined, { turnId: "turn-ack" }); + + lastSocket().fakeMessage({ + event: "message_accepted", + chat_id: "chat-ack", + turn_id: "turn-ack", + }); + + expect(handler).toHaveBeenCalledWith({ + event: "message_accepted", + chat_id: "chat-ack", + turn_id: "turn-ack", + }); + }); + it("can swap the socket factory when the runtime URL changes", () => { const browserFactory = vi.fn( (url: string) => new FakeSocket(`browser:${url}`) as unknown as WebSocket, diff --git a/webui/src/tests/thread-messages.test.tsx b/webui/src/tests/thread-messages.test.tsx index 341d187a4..95cbb7aec 100644 --- a/webui/src/tests/thread-messages.test.tsx +++ b/webui/src/tests/thread-messages.test.tsx @@ -954,6 +954,34 @@ describe("ThreadMessages", () => { expect(screen.getAllByRole("button", { name: "Copy" })).toHaveLength(2); }); + it("does not count failed optimistic messages in assistant fork indices", () => { + const onForkFromMessage = vi.fn(); + const messages: UIMessage[] = [ + { id: "u1", role: "user", content: "one", createdAt: 1 }, + { id: "a1", role: "assistant", content: "answer one", createdAt: 2 }, + { + id: "u-failed", + role: "user", + content: "not persisted", + deliveryStatus: "failed", + createdAt: 3, + }, + { id: "u2", role: "user", content: "two", createdAt: 4 }, + { id: "a2", role: "assistant", content: "answer two", createdAt: 5 }, + ]; + + render( + , + ); + + fireEvent.click(screen.getAllByRole("button", { name: "Fork" }).at(-1)!); + expect(onForkFromMessage).toHaveBeenCalledWith(2); + }); + it("uses turn ids as activity grouping boundaries when available", () => { const units = buildDisplayUnits([ { id: "u1", role: "user", content: "one", turnId: "turn-1", createdAt: 1 }, diff --git a/webui/src/tests/thread-shell.test.tsx b/webui/src/tests/thread-shell.test.tsx index 6954bb2b3..2d4b489c6 100644 --- a/webui/src/tests/thread-shell.test.tsx +++ b/webui/src/tests/thread-shell.test.tsx @@ -6,7 +6,7 @@ import { preloadMarkdownText } from "@/components/MarkdownText"; import { ThreadCameraController } from "@/components/thread/thread-camera"; import { ThreadShell } from "@/components/thread/ThreadShell"; import { CLI_APPS_CHANGED_EVENT } from "@/lib/cli-app-events"; -import type { CanonicalRunSnapshot } from "@/lib/nanobot-client"; +import type { CanonicalRunSnapshot, StreamError } from "@/lib/nanobot-client"; import { ClientProvider } from "@/providers/ClientProvider"; import type { CliAppsPayload, ConnectionStatus, SettingsPayload, UIMessage } from "@/lib/types"; @@ -14,7 +14,7 @@ const HERO_GREETING_PATTERN = /What should we work on\?|Where should we start\?|What are we building today\?|What should we tackle together\?/; function makeClient() { - const errorHandlers = new Set<(err: { kind: string }) => void>(); + const errorHandlers = new Set<(err: StreamError) => void>(); const statusHandlers = new Set<(status: ConnectionStatus) => void>(); const chatHandlers = new Map void>>(); const runtimeModelHandlers = new Set< @@ -107,6 +107,7 @@ function makeClient() { }; }, getRunStartedAt: (chatId: string) => runStartedAtByChatId.get(chatId) ?? null, + hasUnsettledRun: () => false, getRunGeneration: (chatId: string) => runGenerationByChatId.get(chatId) ?? 0, canReconcileCanonicalCompletion, reconcileCanonicalCompletion, @@ -122,7 +123,7 @@ function makeClient() { handlers?.delete(handler); }; }, - onError: (handler: (err: { kind: string }) => void) => { + onError: (handler: (err: StreamError) => void) => { errorHandlers.add(handler); return () => { errorHandlers.delete(handler); @@ -134,7 +135,7 @@ function makeClient() { sessionUpdateHandlers.delete(handler); }; }, - _emitError(err: { kind: string }) { + _emitError(err: StreamError) { for (const h of errorHandlers) h(err); }, _emitStatus(nextStatus: ConnectionStatus) { @@ -3208,7 +3209,7 @@ describe("ThreadShell", () => { expect(screen.queryByText("Write code")).not.toBeInTheDocument(); }); - it("surfaces a dismissible banner when the stream reports message_too_big", async () => { + it("surfaces a dismissible banner for an uncorrelated message_too_big error", async () => { const client = makeClient(); const onNewChat = vi.fn().mockResolvedValue("chat-a"); @@ -3243,6 +3244,52 @@ describe("ThreadShell", () => { }); }); + it("moves a correlated delivery error from the banner into the failed message tooltip", async () => { + const client = makeClient(); + + render( + wrap( + client, + {}} + onGoHome={() => {}} + onNewChat={() => {}} + />, + ), + ); + + fireEvent.change(screen.getByLabelText("Message input"), { + target: { value: "oversized payload" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Send message" })); + await waitFor(() => expect(client.sendMessage).toHaveBeenCalledTimes(1)); + const turnId = client.sendMessage.mock.calls[0][3]?.turnId; + expect(turnId).toEqual(expect.any(String)); + + await act(async () => { + client._emitError({ + kind: "message_too_big", + chatId: "chat-inline-error", + turnId, + }); + }); + + expect(screen.queryByRole("button", { name: "Dismiss" })).not.toBeInTheDocument(); + const status = screen.getByRole("button", { + name: "Not sent: Message too large", + }); + expect(screen.getByRole("alert")).toHaveClass("sr-only"); + expect(screen.queryByRole("tooltip")).not.toBeInTheDocument(); + + fireEvent.focus(status); + + expect(await screen.findByRole("tooltip")).toHaveTextContent( + "The server rejected your last message because it exceeded the size limit.", + ); + }); + it("clears the stream error banner when the user switches to another chat", async () => { const client = makeClient(); const onNewChat = vi.fn().mockResolvedValue("chat-a"); diff --git a/webui/src/tests/useNanobotStream.test.tsx b/webui/src/tests/useNanobotStream.test.tsx index 8ced5bccf..b320318d8 100644 --- a/webui/src/tests/useNanobotStream.test.tsx +++ b/webui/src/tests/useNanobotStream.test.tsx @@ -1652,6 +1652,7 @@ describe("useNanobotStream", () => { expect(result.current.messages[0].content).toBe("fine"); expect(result.current.messages[0].turnId).toEqual(expect.any(String)); expect(result.current.messages[0].turnPhase).toBe("user"); + expect(result.current.messages[0].deliveryStatus).toBe("sending"); }); it("returns the submitted turn identity used by the optimistic row and wire frame", () => { @@ -1681,7 +1682,34 @@ describe("useNanobotStream", () => { ); }); - it("removes only the optimistic turn named by a correlated rejection", () => { + it("marks an optimistic turn accepted when its acknowledgement arrives", () => { + const fake = fakeClient(); + const { result } = renderHook( + () => useNanobotStream("chat-accept-one", EMPTY_MESSAGES), + { wrapper: wrap(fake.client) }, + ); + let submitted: ReturnType = null; + act(() => { + submitted = result.current.send("hello"); + }); + + act(() => { + fake.emit("chat-accept-one", { + event: "message_accepted", + chat_id: "chat-accept-one", + turn_id: submitted!.turnId, + }); + }); + + expect(result.current.messages).toEqual([ + expect.objectContaining({ + id: submitted!.userMessageId, + deliveryStatus: "accepted", + }), + ]); + }); + + it("marks only the optimistic turn named by a correlated rejection as failed", () => { const fake = fakeClient(); const { result } = renderHook( () => useNanobotStream("chat-reject-one", EMPTY_MESSAGES), @@ -1705,10 +1733,18 @@ describe("useNanobotStream", () => { }); expect(result.current.messages).toEqual([ + expect.objectContaining({ + id: first!.userMessageId, + turnId: first!.turnId, + content: "first", + deliveryStatus: "failed", + deliveryErrorKind: "turn_rejected", + }), expect.objectContaining({ id: second!.userMessageId, turnId: second!.turnId, content: "second", + deliveryStatus: "sending", }), ]); expect(result.current.isStreaming).toBe(true); @@ -1751,6 +1787,12 @@ describe("useNanobotStream", () => { expect.objectContaining({ id: first!.userMessageId, turnId: first!.turnId, + deliveryStatus: "accepted", + }), + expect.objectContaining({ + id: second!.userMessageId, + turnId: second!.turnId, + deliveryStatus: "failed", }), ]); expect(result.current.runStartedAt).toBe(1234); @@ -1784,7 +1826,13 @@ describe("useNanobotStream", () => { }); await flushStreamFrame(); - expect(result.current.messages).toEqual([]); + expect(result.current.messages).toEqual([ + expect.objectContaining({ + id: submitted!.userMessageId, + deliveryStatus: "failed", + deliveryErrorKind: "turn_rejected", + }), + ]); expect(result.current.runStartedAt).toBeNull(); expect(result.current.isStreaming).toBe(false); }); @@ -1810,7 +1858,12 @@ describe("useNanobotStream", () => { }); }); - expect(result.current.messages).toEqual([]); + expect(result.current.messages).toEqual([ + expect.objectContaining({ + id: submitted!.userMessageId, + deliveryStatus: "failed", + }), + ]); expect(result.current.streamError).toMatchObject({ kind: "turn_rejected", chatId: "chat-replayed-reject", @@ -1860,7 +1913,7 @@ describe("useNanobotStream", () => { expect(result.current.streamError).toEqual({ kind: "message_too_big" }); }); - it("removes rejected side-channel guidance without stopping the main run", () => { + it("marks rejected side-channel guidance failed without stopping the main run", () => { const fake = fakeClient(); const { result } = renderHook( () => useNanobotStream("chat-side-reject", EMPTY_MESSAGES), @@ -1893,6 +1946,12 @@ describe("useNanobotStream", () => { expect.objectContaining({ id: main!.userMessageId, turnId: main!.turnId, + deliveryStatus: "accepted", + }), + expect.objectContaining({ + id: side!.userMessageId, + turnId: side!.turnId, + deliveryStatus: "failed", }), ]); expect(result.current.runStartedAt).toBe(9876);