feat(webui): track optimistic message delivery status (#5162)
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user