feat(webui): unify turn observability
This commit is contained in:
@@ -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 () => {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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],
|
||||
]);
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
Reference in New Issue
Block a user