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
23 changed files with 431 additions and 21 deletions
+47
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",
+25
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,
+28
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 },
+52 -5
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");
+63 -4
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);