feat(webui): unify turn observability

This commit is contained in:
Xubin Ren
2026-08-22 20:51:24 +08:00
parent dbc1801d3c
commit 48eea29313
44 changed files with 1871 additions and 930 deletions
@@ -2,6 +2,7 @@ import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"
import { describe, expect, it, vi } from "vitest";
import { AgentActivityCluster } from "@/components/thread/AgentActivityCluster";
import { preloadMarkdownText } from "@/components/MarkdownText";
import { DEFAULT_LOCAL_PREFS, writeLocalPreferences } from "@/lib/local-preferences";
import type { CliAppInfo, McpPresetInfo, UIMessage } from "@/lib/types";
@@ -139,6 +140,34 @@ function installReducedMotion() {
}
describe("AgentActivityCluster", () => {
it("keeps intermediate assistant output as normal Markdown inside live activity", async () => {
await act(async () => {
await preloadMarkdownText();
});
render(
<AgentActivityCluster
messages={[
{
id: "model-activity",
role: "assistant",
content: "**partial answer**",
activityKind: "model",
isStreaming: true,
createdAt: 1,
},
]}
isTurnStreaming
hasBodyBelow={false}
/>,
);
const block = screen.getByTestId("activity-model-message");
await waitFor(() => expect(block.querySelector("strong")).not.toBeNull());
expect(block.querySelector("strong")).toHaveTextContent("partial answer");
expect(screen.queryByTestId("activity-step")).not.toBeInTheDocument();
});
it("jumps to the latest activity when opened", () => {
const raf = installAnimationFrameQueue();
try {
@@ -398,7 +427,7 @@ describe("AgentActivityCluster", () => {
vi.advanceTimersByTime(301);
});
expect(screen.queryByTestId("agent-activity-scroll")).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Thought" })).toHaveAttribute(
expect(screen.getByRole("button", { name: "Worked" })).toHaveAttribute(
"aria-expanded",
"false",
);
@@ -422,7 +451,7 @@ describe("AgentActivityCluster", () => {
/>,
);
const button = screen.getByRole("button", { name: "Thought" });
const button = screen.getByRole("button", { name: "Worked" });
expect(button).toHaveAttribute("data-thread-disclosure");
const chevron = button.querySelector("svg");
expect(chevron).toBeInTheDocument();
@@ -449,7 +478,7 @@ describe("AgentActivityCluster", () => {
/>,
);
expect(screen.getByText("Thought for 12s")).toBeInTheDocument();
expect(screen.getByText("Worked for 12s")).toBeInTheDocument();
});
it("labels mixed tool activity as work instead of thought", () => {
@@ -481,8 +510,8 @@ describe("AgentActivityCluster", () => {
/>,
);
expect(screen.getByText("Thought")).toBeInTheDocument();
expect(screen.queryByText("Thought for 0s")).not.toBeInTheDocument();
expect(screen.getByText("Worked")).toBeInTheDocument();
expect(screen.queryByText("Worked for 0s")).not.toBeInTheDocument();
});
it("renders file edits as one-line activity rows", async () => {
+47
View File
@@ -245,6 +245,7 @@ describe("MessageBubble", () => {
const quote = screen.getByLabelText("Quoted context");
expect(quote).toHaveTextContent("selected assistant excerpt");
expect(quote).not.toHaveAttribute("title");
expect(screen.queryByText("Quoted context")).not.toBeInTheDocument();
expect(screen.getByText("What about this?")).toBeInTheDocument();
@@ -1011,4 +1012,50 @@ describe("MessageBubble", () => {
expect(container.querySelector('img[src="/api/media/sig/svg"]')).toBeInTheDocument();
expect(screen.queryByLabelText("File attachment")).not.toBeInTheDocument();
});
it("keeps turn usage focused on the completed reply", () => {
const message: UIMessage = {
id: "a-usage",
role: "assistant",
content: "done",
createdAt: Date.now(),
latencyMs: 18_200,
contextWindowTokens: 128_000,
usage: {
prompt_tokens: 12_400,
completion_tokens: 823,
cached_tokens: 9_672,
context_tokens: 8_100,
request_count: 3,
},
};
render(<MessageBubble message={message} />);
const usage = screen.getByText("12.4K in · 823 out · 78% cached · 18s");
expect(usage).toHaveAttribute("data-turn-usage");
expect(usage).not.toHaveAttribute("tabindex");
expect(screen.queryByRole("tooltip")).not.toBeInTheDocument();
});
it("marks estimated usage and omits cache when the provider did not report it", () => {
const message: UIMessage = {
id: "a-estimated-usage",
role: "assistant",
content: "done",
createdAt: Date.now(),
usage: {
prompt_tokens: 1_250,
completion_tokens: 90,
estimated_tokens: 1_340,
},
};
render(<MessageBubble message={message} />);
const usage = screen.getByText("~1.3K in · ~90 out");
expect(usage).not.toHaveTextContent("cached");
fireEvent.focus(usage);
expect(screen.getByRole("tooltip")).toHaveTextContent("Includes estimated usage");
});
});
+2
View File
@@ -1724,6 +1724,7 @@ describe("NanobotClient", () => {
chat_id: "chat-a",
model_name: "deepseek/deepseek-chat",
model_preset: "Deep Research",
fallback: true,
});
expect(chatHandler).toHaveBeenCalledWith({
@@ -1731,6 +1732,7 @@ describe("NanobotClient", () => {
chat_id: "chat-a",
model_name: "deepseek/deepseek-chat",
model_preset: "Deep Research",
fallback: true,
});
});
+77 -150
View File
@@ -317,9 +317,9 @@ function ascii(bytes: Uint8Array, offset: number, length: number): string {
}
const MODEL_PRESETS = [
{ name: "kimi", provider: "moonshot" },
{ name: "dflash", provider: "deepseek" },
{ name: "dspro", provider: "deepseek" },
{ name: "kimi", model: "moonshot/kimi-k2.5", provider: "moonshot" },
{ name: "dflash", model: "deepseek/deepseek-v4-flash", provider: "deepseek" },
{ name: "dspro", model: "deepseek/deepseek-v4-pro", provider: "deepseek" },
];
function renderPresetComposer(variant: "thread" | "hero" = "thread") {
@@ -337,28 +337,11 @@ function renderPresetComposer(variant: "thread" | "hero" = "thread") {
/>,
);
return {
badge: screen.getByRole("spinbutton", { name: "kimi" }),
badge: screen.getByRole("button", { name: "kimi" }),
onPresetChange,
};
}
function pointerDown(badge: HTMLElement, pointerId = 7, clientY = 100, button = 0) {
fireEvent.pointerDown(badge, {
button,
clientY,
isPrimary: true,
pointerId,
pointerType: "mouse",
});
}
function longPress(badge: HTMLElement, pointerId = 7) {
pointerDown(badge, pointerId);
act(() => {
vi.advanceTimersByTime(400);
});
}
describe("ThreadComposer", () => {
it("locks an async send and keeps the draft when it is rejected", async () => {
let resolveSend!: (accepted: boolean) => void;
@@ -538,12 +521,42 @@ describe("ThreadComposer", () => {
/>,
);
const badge = screen.getByRole("spinbutton", { name: "gpt-5.6-sol" });
const badge = screen.getByRole("button", { name: "gpt-5.6-sol" });
expect(badge).toHaveClass("w-fit", "max-w-[min(18rem,44vw)]");
expect(badge).not.toHaveClass("w-[5.75rem]");
expect(screen.getByText("gpt-5.6-sol")).toBeInTheDocument();
});
it("shows a compact context meter beside the model selector", async () => {
render(
<ThreadComposer
onSend={vi.fn()}
modelLabel="gpt-5.6-sol"
modelPreset="gpt-5-6-sol"
modelProvider="openai_codex"
contextUsage={{
contextTokens: 74_900,
contextWindowTokens: 1_000_000,
}}
placeholder="Ask anything..."
/>,
);
const context = screen.getByTestId("composer-context-usage");
expect(context).toHaveClass("size-5", "rounded-full");
expect(context).not.toHaveTextContent("Context 74.9K / 1M");
expect(screen.getByTestId("composer-context-meter")).toBeInTheDocument();
expect(context).toHaveAccessibleName(
"Context · 74.9K / 1M. 7% used.",
);
fireEvent.focus(context);
const tooltip = await screen.findByRole("tooltip");
expect(tooltip).toHaveTextContent("Context · 74.9K / 1M");
expect(tooltip.parentElement).toHaveClass("rounded-full", "px-2.5", "py-1");
expect(tooltip.parentElement).not.toHaveTextContent("Available");
});
it("keeps the thread composer compact while matching the hero style", () => {
render(
<ThreadComposer
@@ -559,7 +572,9 @@ describe("ThreadComposer", () => {
const modelPill = screen.getByText("gpt-4o").closest(".composer-model-pill");
expect(modelPill).toHaveClass("font-medium", "text-foreground/70");
expect(modelPill).not.toHaveClass("font-semibold");
expect(screen.getByTestId("composer-model-logo-openai")).toBeInTheDocument();
const providerLogo = screen.getByTestId("composer-model-logo-openai");
expect(providerLogo).toBeInTheDocument();
expect(providerLogo).not.toHaveClass("border", "bg-background");
const input = screen.getByPlaceholderText("Type your message...");
expect(input.className).toContain("min-h-[50px]");
expect(input.className).toContain("text-[16px]");
@@ -571,141 +586,53 @@ describe("ThreadComposer", () => {
expect(screen.queryByText(/Enter to send/)).not.toBeInTheDocument();
});
it("shows model details in the shared tooltip without a native title", async () => {
render(
<ThreadComposer
onSend={vi.fn()}
modelLabel="gpt-4o"
modelDetail="gpt-4o"
modelProvider="openai"
modelProviderLabel="OpenAI"
placeholder="Type your message..."
/>,
);
const badge = screen.getByLabelText("gpt-4o");
expect(badge).not.toHaveAttribute("title");
fireEvent.focus(badge);
expect(await screen.findByRole("tooltip")).toHaveTextContent("gpt-4o · OpenAI");
});
it("smoothly cycles to the next preset on click", () => {
vi.useFakeTimers();
let runFrame: FrameRequestCallback | null = null;
vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => {
runFrame = callback;
return 1;
});
vi.spyOn(window, "cancelAnimationFrame").mockImplementation(() => undefined);
const { badge, onPresetChange } = renderPresetComposer();
fireEvent.click(badge);
expect(badge).toHaveAttribute("data-switching", "true");
const track = screen.getByTestId("composer-model-pill-track");
expect(track).not.toHaveAttribute("data-settling");
expect(track).toHaveStyle({ transform: "translate3d(0, -40px, 0)" });
act(() => runFrame?.(16));
expect(onPresetChange).toHaveBeenCalledWith("dflash");
expect(badge).toHaveAttribute("data-settling", "true");
expect(track).toHaveAttribute("data-settling", "true");
expect(track).toHaveStyle({ transform: "translate3d(0, -80px, 0)" });
act(() => vi.advanceTimersByTime(260));
expect(badge).not.toHaveAttribute("data-switching");
});
it("scrolls complete preset pills after a left-button long press and wraps", () => {
vi.useFakeTimers();
it("opens a model picker and switches presets with one click", async () => {
const { badge, onPresetChange } = renderPresetComposer();
expect(badge).toHaveClass("h-9");
expect(badge).toHaveStyle({ touchAction: "manipulation" });
const idleTouchMove = new Event("touchmove", {
bubbles: true,
cancelable: true,
});
badge.dispatchEvent(idleTouchMove);
expect(idleTouchMove.defaultPrevented).toBe(false);
pointerDown(badge);
fireEvent.pointerMove(badge, { clientY: 80, pointerId: 7, pointerType: "mouse" });
act(() => vi.advanceTimersByTime(500));
fireEvent.pointerUp(badge, { clientY: 80, pointerId: 7, pointerType: "mouse" });
expect(onPresetChange).not.toHaveBeenCalled();
longPress(badge);
expect(badge).toHaveAttribute("data-switching", "true");
const viewport = screen.getByTestId("composer-model-pill-viewport");
expect(viewport).toHaveClass(
"right-0",
"w-max",
"max-w-[calc(44vw+0.5rem)]",
"overflow-hidden",
"-top-3",
"-bottom-3",
);
const track = screen.getByTestId("composer-model-pill-track");
expect(track).toHaveClass("w-max", "max-w-full", "items-end", "gap-1");
const activeTouchMove = new Event("touchmove", {
bubbles: true,
cancelable: true,
});
badge.dispatchEvent(activeTouchMove);
expect(activeTouchMove.defaultPrevented).toBe(true);
const pills = track.querySelectorAll<HTMLElement>(".composer-model-pill");
expect(pills).toHaveLength(5);
expect(Array.from(pills).every((pill) => pill.classList.contains("w-fit"))).toBe(true);
expect(Array.from(pills).every((pill) => pill.querySelector("img"))).toBe(true);
expect(Array.from(badge.querySelectorAll("img")).every((image) => !image.draggable)).toBe(true);
const centeredPill = track.querySelector<HTMLElement>("[data-preset-offset='0']");
expect(centeredPill).toHaveTextContent("kimi");
expect(centeredPill).toHaveStyle({ transform: "scale(1.0800)" });
expect(
track.querySelector<HTMLElement>("[data-preset-offset='1']"),
).toHaveStyle({ transform: "scale(1.0200)" });
fireEvent.pointerMove(badge, {
clientY: 122,
pointerId: 7,
pointerType: "mouse",
});
expect(track.querySelector("[data-preset-offset='0']")).toHaveTextContent("kimi");
fireEvent.pointerMove(badge, {
clientY: 123,
pointerId: 7,
pointerType: "mouse",
});
expect(track.querySelector("[data-preset-offset='0']")).toHaveTextContent("dspro");
fireEvent.pointerUp(badge, {
clientY: 123,
pointerId: 7,
pointerType: "mouse",
});
expect(onPresetChange).toHaveBeenCalledWith("dspro");
expect(badge).toHaveClass("w-fit");
fireEvent.click(badge);
expect(onPresetChange).toHaveBeenCalledTimes(1);
expect(badge).toHaveAttribute("data-settling", "true");
expect(track).toHaveAttribute("data-settling", "true");
act(() => {
vi.advanceTimersByTime(260);
});
expect(badge).not.toHaveAttribute("data-switching");
expect(badge).not.toHaveAttribute("data-settling");
const picker = screen.getByRole("dialog", { name: "Switch model for this chat" });
expect(picker).toHaveClass("w-[min(18rem,calc(100vw-2rem))]");
expect(badge).toHaveClass("w-fit");
expect(badge.querySelector(".composer-model-pill")).not.toHaveClass("w-full");
expect(within(picker).getAllByRole("option")).toHaveLength(3);
expect(within(picker).getByRole("option", { name: "dflash" })).toHaveTextContent(
/dflash\s*deepseek-v4-flash/,
);
expect(within(picker).getByRole("option", { name: "kimi" })).toHaveAttribute(
"aria-selected",
"true",
);
expect(document.activeElement).toBe(within(picker).getByRole("option", { name: "kimi" }));
fireEvent.click(within(picker).getByRole("option", { name: "dspro" }));
expect(onPresetChange).toHaveBeenCalledWith("dspro");
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
expect(badge).toHaveClass("w-fit");
});
it("supports the same long-press switcher in hero mode and cancels pointercancel", () => {
it("keeps long-press drag switching alongside the click picker", () => {
vi.useFakeTimers();
const { badge, onPresetChange } = renderPresetComposer();
fireEvent.pointerDown(badge, { pointerId: 1, pointerType: "touch", clientY: 100 });
act(() => vi.advanceTimersByTime(400));
expect(screen.getByTestId("composer-model-pill-viewport")).toBeInTheDocument();
expect(screen.getByTestId("composer-model-pill-layout")).toHaveClass("invisible");
expect(screen.getByTestId("composer-model-pill-track")).not.toHaveClass("transition-transform");
fireEvent.pointerMove(badge, { pointerId: 1, pointerType: "touch", clientY: 56 });
fireEvent.pointerUp(badge, { pointerId: 1, pointerType: "touch", clientY: 56 });
expect(onPresetChange).toHaveBeenCalledWith("dflash");
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
vi.useRealTimers();
});
it("uses the same click picker in hero mode", () => {
const { badge, onPresetChange } = renderPresetComposer("hero");
expect(badge).toHaveClass("h-8");
longPress(badge, 9);
expect(badge).toHaveAttribute("data-switching", "true");
fireEvent.pointerMove(badge, { clientY: 75, pointerId: 9, pointerType: "mouse" });
fireEvent.pointerCancel(badge, { clientY: 75, pointerId: 9, pointerType: "mouse" });
expect(badge).not.toHaveAttribute("data-switching");
expect(onPresetChange).not.toHaveBeenCalled();
fireEvent.click(badge);
fireEvent.click(screen.getByRole("option", { name: "dflash" }));
expect(onPresetChange).toHaveBeenCalledWith("dflash");
});
it("transcribes voice input into the composer without sending", async () => {
+102 -43
View File
@@ -38,7 +38,7 @@ describe("ThreadMessages", () => {
/>,
);
expect(screen.getByRole("status", { name: "Thinking for 5s" })).toBeInTheDocument();
expect(screen.getByRole("status", { name: "Working for 5s" })).toBeInTheDocument();
rerender(
<ThreadMessages
@@ -178,6 +178,66 @@ describe("ThreadMessages", () => {
expect(screen.getByText("stable final answer").closest("p")).toBe(paragraph);
});
it("keeps live Markdown mounted when a later tool activity arrives", async () => {
await act(async () => {
await preloadMarkdownText();
});
const turnId = "turn-live-order";
const prompt: UIMessage = {
id: "u-live",
role: "user",
content: "research this",
createdAt: 1,
turnId,
turnPhase: "prompt",
turnSeq: 0,
};
const commentary: UIMessage = {
id: "a-commentary",
role: "assistant",
content: "**I will check that.**",
createdAt: 2,
isStreaming: false,
turnId,
turnPhase: "answer",
turnSeq: 1,
};
const { rerender } = render(
<ThreadMessages
messages={[prompt, commentary]}
isStreaming
activeTurnId={turnId}
/>,
);
const paragraph = await screen.findByText("I will check that.");
expect(paragraph.closest("[data-testid='activity-model-message']")).toBeNull();
rerender(
<ThreadMessages
messages={[
prompt,
commentary,
{
id: "tool-live",
role: "tool",
kind: "trace",
content: "web_search()",
traces: ["web_search()"],
createdAt: 3,
turnId,
turnPhase: "activity",
turnSeq: 2,
},
]}
isStreaming
activeTurnId={turnId}
/>,
);
expect(screen.getByText("I will check that.")).toBe(paragraph);
expect(screen.getByText(/working/i)).toBeInTheDocument();
});
it("offers a follow-up action for text selected within one completed answer", async () => {
const onQuoteSelection = vi.fn();
render(
@@ -319,12 +379,12 @@ describe("ThreadMessages", () => {
expect(unitKeysForDisplay(liveUnits)).toEqual(unitKeysForDisplay(replayUnits));
expect(unitKeysForDisplay(liveUnits)).toEqual([
"turn-turn-1-user",
"turn-turn-1-activity-1",
"turn-turn-1-answer-1",
"turn-turn-1-answer-2",
]);
});
it("keeps file edits as their own activity row inside a turn", () => {
it("keeps file edits inside the single activity surface for a turn", () => {
const messages: UIMessage[] = [
{
id: "r1",
@@ -364,14 +424,16 @@ describe("ThreadMessages", () => {
const units = buildDisplayUnits(messages);
expect(units).toHaveLength(3);
expect(units.map((unit) => unit.type)).toEqual(["activity", "activity", "activity"]);
expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual(["r1"]);
expect(units[1].type === "activity" ? units[1].messages.map((m) => m.id) : []).toEqual(["t1"]);
expect(units[2].type === "activity" ? units[2].messages.map((m) => m.id) : []).toEqual(["r2"]);
expect(units).toHaveLength(1);
expect(units[0].type).toBe("activity");
expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual([
"r1",
"t1",
"r2",
]);
});
it("keeps ordinary tool activity in one Thought block across segment ids", () => {
it("keeps ordinary tool activity in one activity block across segment ids", () => {
const messages: UIMessage[] = [
{
id: "r1",
@@ -449,10 +511,12 @@ describe("ThreadMessages", () => {
const units = buildDisplayUnits(messages);
expect(units).toHaveLength(3);
expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual(["r1"]);
expect(units[1].type === "activity" ? units[1].messages.map((m) => m.id) : []).toEqual(["t1"]);
expect(units[2]).toMatchObject({
expect(units).toHaveLength(2);
expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual([
"r1",
"t1",
]);
expect(units[1]).toMatchObject({
type: "message",
message: {
id: "a1",
@@ -504,8 +568,8 @@ describe("ThreadMessages", () => {
render(<ThreadMessages messages={messages} isStreaming />);
expect(screen.getByLabelText(/edited foo\.txt/i)).toBeInTheDocument();
expect(screen.queryByLabelText(/editing foo\.txt/i)).not.toBeInTheDocument();
expect(screen.getByLabelText(/editing foo\.txt/i)).toBeInTheDocument();
expect(screen.queryByLabelText(/edited foo\.txt/i)).not.toBeInTheDocument();
});
it("times live activity from the user turn start", () => {
@@ -731,22 +795,18 @@ describe("ThreadMessages", () => {
const units = buildDisplayUnits(messages, true);
expect(units).toHaveLength(3);
expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual(["t0"]);
expect(units[1]).toMatchObject({
type: "message",
message: {
id: "a1",
content: "partial answer",
},
});
expect(units[2].type === "activity" ? units[2].messages.map((m) => m.id) : []).toEqual(["t1"]);
expect(units).toHaveLength(1);
expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual([
"t0",
"a1-activity",
"t1",
]);
render(<ThreadMessages messages={messages} isStreaming />);
const answer = screen.getByText("partial answer");
const liveActivity = screen.getByRole("button", { name: /working/i });
expect(answer.compareDocumentPosition(liveActivity) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(liveActivity.compareDocumentPosition(answer) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
});
it("moves late activity before a completed assistant answer", () => {
@@ -779,10 +839,9 @@ describe("ThreadMessages", () => {
const units = buildDisplayUnits(messages);
expect(units).toHaveLength(3);
expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual(["r1"]);
expect(units[1].type === "activity" ? units[1].messages.map((m) => m.id) : []).toEqual(["t1"]);
expect(units[2]).toMatchObject({
expect(units).toHaveLength(2);
expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual(["r1", "t1"]);
expect(units[1]).toMatchObject({
type: "message",
message: {
id: "a1",
@@ -793,7 +852,7 @@ describe("ThreadMessages", () => {
render(<ThreadMessages messages={messages} isStreaming={false} />);
const answer = screen.getByText("Hong Kong is hot today.");
const laterActivity = screen.getAllByText(/thought/i).at(-1);
const laterActivity = screen.getByRole("button", { name: /worked/i });
expect(laterActivity).toBeTruthy();
expect(laterActivity!.compareDocumentPosition(answer) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
});
@@ -834,7 +893,7 @@ describe("ThreadMessages", () => {
render(<ThreadMessages messages={messages} isStreaming={false} />);
const thought = screen.getAllByText(/thought/i).at(-1);
const thought = screen.getByRole("button", { name: /worked/i });
const answer = screen.getByText("知道,IEM Cologne Major 2026 今天开打了。");
expect(thought).toBeTruthy();
expect(thought!.compareDocumentPosition(answer) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
@@ -876,18 +935,16 @@ describe("ThreadMessages", () => {
const units = buildDisplayUnits(messages, true);
expect(units).toHaveLength(4);
expect(units).toHaveLength(3);
expect(units[0].type === "activity" ? units[0].messages.map((m) => m.id) : []).toEqual([
"thought",
]);
expect(units[1].type === "activity" ? units[1].messages.map((m) => m.id) : []).toEqual([
"web",
]);
expect(units[2]).toMatchObject({
expect(units[1]).toMatchObject({
type: "message",
message: { id: "answer" },
});
expect(units[3]).toMatchObject({
expect(units[2]).toMatchObject({
type: "message",
message: { id: "next-user" },
});
@@ -1018,7 +1075,7 @@ describe("ThreadMessages", () => {
expect(screen.queryByText("Worked for 0s")).not.toBeInTheDocument();
});
it("shows copy on every assistant slice while keeping fork on the last slice", () => {
it("projects assistant slices into one answer with one action set", () => {
const messages: UIMessage[] = [
{
id: "early",
@@ -1050,8 +1107,9 @@ describe("ThreadMessages", () => {
/>,
);
expect(screen.getAllByRole("button", { name: "Copy" })).toHaveLength(2);
expect(screen.getAllByRole("button", { name: "Copy" })).toHaveLength(1);
expect(screen.getAllByRole("button", { name: "Fork" })).toHaveLength(1);
expect(screen.getByText("starting…")).toBeInTheDocument();
expect(screen.getByText("final reply")).toBeInTheDocument();
});
@@ -1095,7 +1153,7 @@ describe("ThreadMessages", () => {
rerender(<ThreadMessages {...props} isStreaming={false} activeTurnId={null} />);
expect(container.querySelectorAll('[data-assistant-footer] [aria-label="Copy"]')).toHaveLength(3);
expect(container.querySelectorAll('[data-assistant-footer] [aria-label="Copy"]')).toHaveLength(2);
expect(container.querySelectorAll('[data-assistant-footer] [aria-label="Fork"]')).toHaveLength(2);
});
@@ -1192,13 +1250,15 @@ describe("ThreadMessages", () => {
.toHaveLength(1);
});
it("shows copy on adjacent assistant text slices", () => {
it("projects adjacent assistant text slices into one answer", () => {
const messages: UIMessage[] = [
{ id: "a1", role: "assistant", content: "part one", createdAt: 1 },
{ id: "a2", role: "assistant", content: "part two", createdAt: 2 },
];
render(<ThreadMessages messages={messages} isStreaming={false} />);
expect(screen.getAllByRole("button", { name: "Copy" })).toHaveLength(2);
expect(screen.getAllByRole("button", { name: "Copy" })).toHaveLength(1);
expect(screen.getByText("part one")).toBeInTheDocument();
expect(screen.getByText("part two")).toBeInTheDocument();
});
it("does not count failed optimistic messages in assistant fork indices", () => {
@@ -1280,7 +1340,6 @@ describe("ThreadMessages", () => {
.filter(Boolean);
expect(assistantFlags).toEqual([
["a1", false],
["a2", true],
["a3", true],
]);
+49 -36
View File
@@ -609,12 +609,33 @@ describe("ThreadShell", () => {
),
);
const badge = await screen.findByLabelText("fast");
expect(badge).not.toHaveAttribute("title");
fireEvent.focus(badge);
expect(await screen.findByRole("tooltip")).toHaveTextContent(
"fast · gpt-5.5 · OpenAI Codex",
expect(await screen.findByTitle("fast · gpt-5.5 · OpenAI Codex")).toBeInTheDocument();
expect(screen.queryByTitle("Default · deepseek-v4-pro · DeepSeek")).not.toBeInTheDocument();
});
it("falls back to the current preset while a renamed session reference is stale", async () => {
const client = makeClient();
const settings = settingsWithFastPreset();
settings.agent.model_preset = "fast";
settings.model_presets = settings.model_presets.map((preset) => ({
...preset,
active: preset.name === "fast",
}));
render(
wrap(
client,
<ThreadShell
session={session("renamed-preset", "old-fast")}
title="Renamed preset"
onToggleSidebar={() => {}}
settingsSnapshot={settings}
/>,
"openai-codex/gpt-5.5",
),
);
expect(await screen.findByTitle("fast · gpt-5.5 · OpenAI Codex")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Model not configured" })).not.toBeInTheDocument();
});
it("switches through every named preset while preserving call-order priority", async () => {
@@ -641,19 +662,18 @@ describe("ThreadShell", () => {
));
const { rerender } = render(view("default"));
const badge = await screen.findByRole("spinbutton", { name: "Default" });
const badge = await screen.findByRole("button", { name: "Default" });
expect(badge).toHaveTextContent("Default");
fireEvent.keyDown(badge, { key: "ArrowDown" });
fireEvent.click(badge);
fireEvent.click(await screen.findByRole("option", { name: /^fast\b/i }));
expect(client.sendSystemCommand).toHaveBeenCalledWith(
"preset-order",
"/model fast",
);
expect(await screen.findByText("fast")).toBeInTheDocument();
fireEvent.keyDown(
screen.getByRole("spinbutton", { name: "fast" }),
{ key: "End" },
);
fireEvent.click(screen.getByRole("button", { name: "fast" }));
fireEvent.click(await screen.findByRole("option", { name: /^extra\b/i }));
expect(client.sendSystemCommand).toHaveBeenLastCalledWith(
"preset-order",
"/model extra",
@@ -696,15 +716,11 @@ describe("ThreadShell", () => {
),
);
const badge = await screen.findByLabelText("fast");
fireEvent.focus(badge);
expect(await screen.findByRole("tooltip")).toHaveTextContent(
"fast · gpt-4 · Company Proxy",
);
expect(await screen.findByTitle("fast · gpt-4 · Company Proxy")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Model not configured" })).not.toBeInTheDocument();
});
it("only highlights fallback model updates without replacing the preset label", async () => {
it("shows the effective fallback model in the composer badge", async () => {
const client = makeClient();
render(wrap(
client,
@@ -718,7 +734,8 @@ describe("ThreadShell", () => {
));
expect(await screen.findByText("Default")).toBeInTheDocument();
const configuredBadge = screen.getByTestId("composer-model-logo-openai_codex").parentElement;
const configuredLogo = await screen.findByTestId("composer-model-logo-openai_codex");
const configuredBadge = configuredLogo.parentElement;
expect(configuredBadge).not.toBeNull();
expect(configuredBadge).toHaveClass("composer-model-badge");
expect(configuredBadge).not.toHaveAttribute("data-fallback");
@@ -728,31 +745,34 @@ describe("ThreadShell", () => {
event: "turn_model_updated",
chat_id: "fallback-model",
model_name: "openai-codex/gpt-5.5",
model_preset: "Default",
});
});
expect(configuredBadge).not.toHaveAttribute("data-fallback");
expect(screen.getByText("Default")).toBeInTheDocument();
act(() => {
client._emitChat("fallback-model", {
event: "turn_model_updated",
chat_id: "fallback-model",
model_name: "deepseek/deepseek-chat",
fallback: true,
});
});
const logo = screen.getByTestId("composer-model-logo-openai_codex");
const logo = await screen.findByTestId("composer-model-logo-deepseek");
const badge = logo.parentElement;
expect(badge).not.toBeNull();
expect(badge).toBe(configuredBadge);
expect(screen.getByText("Default")).toBeInTheDocument();
expect(screen.queryByText("deepseek-chat")).not.toBeInTheDocument();
expect(screen.queryByText("Default")).not.toBeInTheDocument();
expect(screen.getByText("deepseek-chat")).toBeInTheDocument();
expect(badge).toHaveAttribute("data-fallback", "true");
expect(badge).not.toHaveAttribute("title");
expect(logo).not.toHaveAttribute("data-fallback");
const trigger = screen.getByLabelText("Default");
fireEvent.focus(trigger);
expect(await screen.findByRole("tooltip")).toHaveTextContent("deepseek/deepseek-chat");
expect(badge).toHaveAttribute(
"title",
"Default · using deepseek/deepseek-chat",
);
expect(logo).toBeInTheDocument();
act(() => {
client._emitChat("fallback-model", {
@@ -766,12 +786,7 @@ describe("ThreadShell", () => {
screen.getByTestId("composer-model-logo-openai_codex").parentElement,
).not.toHaveAttribute("data-fallback");
});
expect(screen.getByRole("tooltip")).toHaveTextContent(
"Default · gpt-5.5 · OpenAI Codex",
);
expect(
screen.getByTestId("composer-model-logo-openai_codex").parentElement,
).toBe(badge);
expect(screen.getByText("Default")).toBeInTheDocument();
});
it("opens model settings from the unconfigured model badge", async () => {
@@ -1084,10 +1099,8 @@ describe("ThreadShell", () => {
));
const { rerender } = render(view(null));
fireEvent.keyDown(
await screen.findByRole("spinbutton", { name: "Default" }),
{ key: "ArrowDown" },
);
fireEvent.click(await screen.findByRole("button", { name: "Default" }));
fireEvent.click(await screen.findByRole("option", { name: /^fast\b/i }));
expect(await screen.findByText("fast")).toBeInTheDocument();
expect(client.sendSystemCommand).not.toHaveBeenCalled();
+1 -1
View File
@@ -234,7 +234,7 @@ describe("ThreadViewport", () => {
/>,
);
const disclosure = screen.getByRole("button", { name: "Thought" });
const disclosure = screen.getByRole("button", { name: "Worked" });
fireEvent.pointerDown(disclosure, { button: 0 });
expect(takeUserControl).toHaveBeenCalledTimes(1);
+60 -2
View File
@@ -350,6 +350,51 @@ describe("useNanobotStream", () => {
expect(result.current.isStreaming).toBe(false);
});
it("stamps provider usage and latest context on the completed answer", () => {
const fake = fakeClient();
const { result } = renderHook(() => useNanobotStream("chat-usage", EMPTY_MESSAGES), {
wrapper: wrap(fake.client),
});
act(() => {
fake.emit("chat-usage", {
event: "delta",
chat_id: "chat-usage",
text: "done",
turn_id: "turn-usage",
});
fake.emit("chat-usage", {
event: "turn_end",
chat_id: "chat-usage",
turn_id: "turn-usage",
latency_ms: 18_200,
context_window_tokens: 128_000,
usage: {
prompt_tokens: 12_400,
completion_tokens: 823,
cached_tokens: 9_672,
context_tokens: 8_100,
request_count: 3,
},
});
});
expect(result.current.messages).toHaveLength(1);
expect(result.current.messages[0]).toMatchObject({
content: "done",
isStreaming: false,
latencyMs: 18_200,
contextWindowTokens: 128_000,
usage: {
prompt_tokens: 12_400,
completion_tokens: 823,
cached_tokens: 9_672,
context_tokens: 8_100,
request_count: 3,
},
});
});
it("preserves proactive automation source metadata on complete assistant messages", () => {
const fake = fakeClient();
const { result } = renderHook(() => useNanobotStream("chat-cron", EMPTY_MESSAGES), {
@@ -2488,7 +2533,7 @@ describe("useNanobotStream", () => {
]);
});
it("lets stream_end finish streaming while side-channel status replies arrive", () => {
it("keeps the turn active after stream_end while side-channel replies arrive", () => {
vi.useFakeTimers();
try {
const fake = fakeClient();
@@ -2530,6 +2575,19 @@ describe("useNanobotStream", () => {
vi.advanceTimersByTime(1000);
});
expect(result.current.isStreaming).toBe(true);
expect(result.current.messages.find((message) => message.content === "done")).toMatchObject({
isStreaming: true,
});
act(() => {
fake.emit("chat-status-loop", {
event: "turn_end",
chat_id: "chat-status-loop",
turn_id: promptTurnId,
});
});
expect(result.current.isStreaming).toBe(false);
expect(result.current.messages.find((message) => message.content === "done")).toMatchObject({
isStreaming: false,
@@ -2589,7 +2647,7 @@ describe("useNanobotStream", () => {
expect(result.current.messages).toHaveLength(3);
expect(result.current.messages[1]).toMatchObject({
content: "Initial findings",
isStreaming: false,
isStreaming: true,
});
act(() => {