feat(webui): track optimistic message delivery status (#5162)

This commit is contained in:
chengyongru 2026-07-29 23:15:45 +08:00 committed by GitHub
parent 5a28a6165c
commit 129b74b4cf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 431 additions and 21 deletions

View File

@ -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 (
<span
role="status"
className="inline-flex items-center gap-1 text-[12px] leading-none text-muted-foreground"
>
<Clock3 className="h-3.5 w-3.5" aria-hidden />
{t("message.delivery.sending")}
</span>
);
}
const label = t("message.delivery.failed");
const { title, body } = deliveryErrorCopy(errorKind, t);
return (
<>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-label={`${label}: ${title}`}
className={cn(
"inline-flex items-center gap-1 rounded-sm text-[12px] leading-none",
"text-destructive/80 transition-colors hover:text-destructive",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
"dark:text-red-400/80 dark:hover:text-red-400",
)}
>
<CircleAlert className="h-3.5 w-3.5" aria-hidden />
{label}
</button>
</TooltipTrigger>
<TooltipContent
side="top"
align="end"
className="max-w-72 px-3 py-2.5 text-left"
>
<p className="font-medium text-popover-foreground">{title}</p>
<p className="mt-1 leading-relaxed text-muted-foreground">{body}</p>
</TooltipContent>
</Tooltip>
<span role="alert" aria-live="assertive" className="sr-only">
{title}. {body}
</span>
</>
);
}
/** 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}
</p>
) : null}
{hasText && showCopyAction ? (
{showDeliveryStatus || (hasText && showCopyAction) ? (
<TooltipProvider delayDuration={220} skipDelayDuration={80}>
<div className="flex min-h-8 items-center justify-end text-muted-foreground">
<MessageCopyButton content={message.content} />
<div className="flex min-h-8 items-center justify-end gap-1.5 text-muted-foreground">
<UserDeliveryStatus
status={message.deliveryStatus}
errorKind={message.deliveryErrorKind}
/>
{hasText && showCopyAction ? <MessageCopyButton content={message.content} /> : null}
</div>
</TooltipProvider>
) : null}

View File

@ -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) {

View File

@ -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 (
<ThreadDisplayUnit

View File

@ -31,7 +31,7 @@ import {
installedMcpPresetsFromPayload,
isMcpPresetsPayload,
} from "@/lib/mcp-preset-events";
import type { CanonicalRunSnapshot } from "@/lib/nanobot-client";
import type { CanonicalRunSnapshot, StreamError } from "@/lib/nanobot-client";
import { inferProviderFromModelName, providerDisplayLabel } from "@/lib/provider-brand";
import type {
ChatSummary,
@ -222,11 +222,28 @@ function latestActiveTurnId(messages: UIMessage[]): string | null {
}
for (let index = messages.length - 1; index >= 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) ? (
<StreamErrorNotice
error={streamError}
onDismiss={dismissStreamError}

View File

@ -250,7 +250,9 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
const hiddenUserMessageCount =
userMessageOffset
+ (hiddenMessageCount > 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

View File

@ -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 } : {}),

View File

@ -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",

View File

@ -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",

View File

@ -1158,6 +1158,10 @@
},
"message": {
"streaming": "en cours de génération",
"delivery": {
"sending": "Envoi…",
"failed": "Non envoyé"
},
"assistantTyping": "Lassistant est en train décrire",
"toolSingle": "Utilisation dun outil",
"toolMany": "{{count}} outils utilisés",

View File

@ -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",

View File

@ -1158,6 +1158,10 @@
},
"message": {
"streaming": "生成中",
"delivery": {
"sending": "送信中…",
"failed": "未送信"
},
"assistantTyping": "アシスタントが入力中",
"toolSingle": "ツールを使用中",
"toolMany": "{{count}} 個のツールを使用",

View File

@ -1158,6 +1158,10 @@
},
"message": {
"streaming": "생성 중",
"delivery": {
"sending": "전송 중…",
"failed": "전송되지 않음"
},
"assistantTyping": "도우미가 입력 중",
"toolSingle": "도구 사용 중",
"toolMany": "도구 {{count}}개 사용됨",

View File

@ -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",

View File

@ -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ụ",

View File

@ -1172,6 +1172,10 @@
},
"message": {
"streaming": "流式输出中",
"delivery": {
"sending": "发送中…",
"failed": "未发送"
},
"assistantTyping": "助手正在输入",
"toolSingle": "正在使用工具",
"toolMany": "已使用 {{count}} 个工具",

View File

@ -1158,6 +1158,10 @@
},
"message": {
"streaming": "串流輸出中",
"delivery": {
"sending": "傳送中…",
"failed": "未傳送"
},
"assistantTyping": "助理正在輸入",
"toolSingle": "正在使用工具",
"toolMany": "已使用 {{count}} 個工具",

View File

@ -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)) {

View File

@ -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 {

View File

@ -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(<MessageBubble message={message} />);
expect(screen.getByRole("status")).toHaveTextContent("Sending…");
rerender(<MessageBubble message={{ ...message, deliveryStatus: "accepted" }} />);
expect(screen.queryByRole("status")).not.toBeInTheDocument();
rerender(
<MessageBubble
message={{
...message,
deliveryStatus: "failed",
deliveryErrorKind: "message_too_big",
}}
/>,
);
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",

View File

@ -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,

View File

@ -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(
<ThreadMessages
messages={messages}
isStreaming={false}
onForkFromMessage={onForkFromMessage}
/>,
);
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 },

View File

@ -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<string, Set<(ev: import("@/lib/types").InboundEvent) => 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,
<ThreadShell
session={session("chat-inline-error")}
title="Chat inline error"
onToggleSidebar={() => {}}
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");

View File

@ -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<typeof result.current.send> = 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);