summaryrefslogtreecommitdiffhomepage
path: root/src/features/chat/ui.test.ts
diff options
context:
space:
mode:
Diffstat (limited to 'src/features/chat/ui.test.ts')
-rw-r--r--src/features/chat/ui.test.ts818
1 files changed, 772 insertions, 46 deletions
diff --git a/src/features/chat/ui.test.ts b/src/features/chat/ui.test.ts
index 5f8067d..f98fdbb 100644
--- a/src/features/chat/ui.test.ts
+++ b/src/features/chat/ui.test.ts
@@ -3,12 +3,26 @@ import { render, screen, within } from "@testing-library/svelte";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import type { RenderedChunk } from "../../core/chunks";
-import type { TurnMetricsEntry } from "../../core/metrics";
+import {
+ type CacheTimerState,
+ initialCacheTimerState,
+ type TurnMetricsEntry,
+} from "../../core/metrics";
+import type { ConvertedJpeg } from "./adapter/image-encode";
+import CacheTimerBadge from "./ui/CacheTimerBadge.svelte";
import ChatView from "./ui/ChatView.svelte";
import Composer from "./ui/Composer.svelte";
import ModelSelector from "./ui/ModelSelector.svelte";
import ReasoningEffortSelector from "./ui/ReasoningEffortSelector.svelte";
+/** Collapsed card bodies render lazily — open every card so its body mounts. */
+async function openAllCollapses(container: HTMLElement): Promise<void> {
+ const boxes = container.querySelectorAll<HTMLInputElement>('.collapse > input[type="checkbox"]');
+ for (const box of boxes) {
+ if (!box.checked) await userEvent.click(box);
+ }
+}
+
describe("ChatView", () => {
it("renders a message's text chunk", () => {
const chunks: RenderedChunk[] = [
@@ -42,6 +56,25 @@ describe("ChatView", () => {
expect(screen.getByText("Hello!")).toBeInTheDocument();
});
+ it("renders local-path Markdown images through the backend image route", () => {
+ const chunks: RenderedChunk[] = [
+ {
+ seq: 1,
+ role: "assistant",
+ chunk: { type: "text", text: "![pic](/home/u/pic.png)" },
+ provisional: false,
+ },
+ ];
+
+ const { container } = render(ChatView, {
+ props: { chunks, apiBaseUrl: "http://localhost:24203", cwd: "/w/foo" },
+ });
+
+ expect(container.querySelector("img")?.getAttribute("src")).toBe(
+ "http://localhost:24203/local-image?path=%2Fhome%2Fu%2Fpic.png",
+ );
+ });
+
it("shows the show-earlier button only when earlier history is unloaded, and pages it in", async () => {
const chunks: RenderedChunk[] = [
{ seq: 26, role: "user", chunk: { type: "text", text: "later" }, provisional: false },
@@ -81,7 +114,7 @@ describe("ChatView", () => {
expect(screen.queryByRole("button", { name: /show earlier/i })).not.toBeInTheDocument();
});
- it("renders tool-call chunks", () => {
+ it("renders tool-call chunks", async () => {
const chunks: RenderedChunk[] = [
{
seq: 1,
@@ -96,16 +129,17 @@ describe("ChatView", () => {
},
];
- render(ChatView, { props: { chunks } });
+ const { container } = render(ChatView, { props: { chunks } });
+ await openAllCollapses(container);
- expect(screen.getByText("read_file")).toBeInTheDocument();
+ expect(screen.getByText("Read file")).toBeInTheDocument();
const pre = screen.getByText((content, element) => {
return element?.tagName === "PRE" && content.includes("/tmp/test.txt");
});
expect(pre).toBeInTheDocument();
});
- it("renders tool-result chunks", () => {
+ it("renders tool-result chunks", async () => {
const chunks: RenderedChunk[] = [
{
seq: 1,
@@ -121,9 +155,10 @@ describe("ChatView", () => {
},
];
- render(ChatView, { props: { chunks } });
+ const { container } = render(ChatView, { props: { chunks } });
+ await openAllCollapses(container);
- expect(screen.getByText("read_file")).toBeInTheDocument();
+ expect(screen.getByText("Read file")).toBeInTheDocument();
expect(screen.getByText("file contents here")).toBeInTheDocument();
});
@@ -199,7 +234,7 @@ describe("ChatView", () => {
expect(log.children).toHaveLength(0);
});
- it("groups batched tool calls (shared stepId) into one DaisyUI list", () => {
+ it("renders parallel tool calls (shared stepId) as separate bubbles", async () => {
const chunks: RenderedChunk[] = [
{
seq: 1,
@@ -241,15 +276,18 @@ describe("ChatView", () => {
];
const { container } = render(ChatView, { props: { chunks } });
+ await openAllCollapses(container);
- // Batched calls render as collapsible cards (one per call), not a list.
+ // Parallel calls render as independent collapsible cards (one per bubble).
const collapses = container.querySelectorAll(".collapse");
expect(collapses).toHaveLength(2);
+ expect(container.querySelectorAll(".chat-bubble")).toHaveLength(2);
+
// Both call names + the available result are shown; the result is absorbed
// (no standalone tool-result card).
- expect(screen.getByText("read_file")).toBeInTheDocument();
- expect(screen.getByText("list_dir")).toBeInTheDocument();
+ expect(screen.getByText("Read file")).toBeInTheDocument();
+ expect(screen.getByText("List dir")).toBeInTheDocument();
expect(screen.getByText("contents-of-a")).toBeInTheDocument();
});
@@ -271,8 +309,7 @@ describe("ChatView", () => {
expect(collapse).not.toHaveClass("collapse-arrow"); // no indicator icon
expect(collapse).not.toHaveClass("collapse-plus");
// Visible bubble, like tool cards.
- expect(collapse).toHaveClass("bg-base-200");
- expect(collapse).toHaveClass("rounded-box");
+ expect(collapse).toHaveClass("inset-well"); // shared recessed panel (app.css)
expect(screen.getByRole("checkbox", { name: "Toggle thoughts" })).toBeInTheDocument();
});
@@ -372,11 +409,39 @@ describe("ChatView", () => {
expect(screen.getByText("Hi")).toBeInTheDocument();
expect(screen.getByText("Hello!")).toBeInTheDocument();
expect(screen.getByText(/step 1/)).toBeInTheDocument();
- expect(screen.getAllByText(/150 tok/)).toHaveLength(2);
- expect(screen.getByText(/turn 1 · 150 tok \(100 in \/ 50 out\)/)).toBeInTheDocument();
+ expect(screen.getByText(/step 1 · 100 in \/ 50 out/)).toBeInTheDocument();
+ expect(screen.getByText(/turn 1 · 50 tok out · 100 in/)).toBeInTheDocument();
expect(screen.getByText(/1\.2s/)).toBeInTheDocument();
});
+ it("renders a per-step cache hit badge coloured by level", () => {
+ const step = {
+ stepId: "t1#0" as StepId,
+ usage: { inputTokens: 1000, outputTokens: 10, cacheReadTokens: 750 },
+ decodeMs: 100,
+ };
+ const chunks: RenderedChunk[] = [
+ { seq: 1, role: "user", chunk: { type: "text", text: "Hi" }, provisional: false },
+ {
+ seq: 2,
+ role: "assistant",
+ chunk: {
+ type: "tool-call",
+ toolCallId: "tc1",
+ toolName: "t",
+ input: {},
+ stepId: step.stepId,
+ },
+ provisional: false,
+ },
+ ];
+ const turnMetrics: TurnMetricsEntry[] = [{ turnId: "t1", steps: [step], total: null }];
+ render(ChatView, { props: { chunks, turnMetrics } });
+ const badge = screen.getByTestId("step-cache-badge");
+ expect(badge).toHaveTextContent("75%");
+ expect(badge).toHaveClass("badge-success");
+ });
+
it("renders cache hit-rate badges (Last turn + Chat Total) coloured by level", () => {
const chunks: RenderedChunk[] = [
{ seq: 1, role: "user", chunk: { type: "text", text: "Hi" }, provisional: false },
@@ -477,11 +542,11 @@ describe("ChatView", () => {
// Both step-metrics and turn-metrics render
expect(screen.getByText(/step 1/)).toBeInTheDocument();
- expect(screen.getByText(/turn 1 · 100 tok/)).toBeInTheDocument();
+ expect(screen.getByText(/turn 1 · 20 tok out/)).toBeInTheDocument();
// They are in separate elements (different rows)
- const stepEl = screen.getByText(/step 1 · 100 tok/).closest("div");
- const turnEl = screen.getByText(/turn 1 · 100 tok/).closest("div");
+ const stepEl = screen.getByText(/step 1 · 80 in \/ 20 out/).closest("div");
+ const turnEl = screen.getByText(/turn 1 · 20 tok out/).closest("div");
expect(stepEl).not.toBe(turnEl);
});
@@ -553,9 +618,9 @@ describe("ChatView", () => {
// Step metrics rendered
expect(screen.getByText(/step 1/)).toBeInTheDocument();
- expect(screen.getAllByText(/15 tok/)).toHaveLength(2);
+ expect(screen.getByText(/step 1 · 10 in \/ 5 out/)).toBeInTheDocument();
// Turn metrics rendered
- expect(screen.getByText(/turn 1 · 15 tok \(10 in \/ 5 out\)/)).toBeInTheDocument();
+ expect(screen.getByText(/turn 1 · 5 tok out · 10 in/)).toBeInTheDocument();
// No "null" or "undefined" in the DOM
expect(screen.queryByText("null")).toBeNull();
expect(screen.queryByText("undefined")).toBeNull();
@@ -601,8 +666,7 @@ describe("ChatView", () => {
render(ChatView, { props: { chunks, turnMetrics } });
// Step metrics should render
- expect(screen.getByText(/step 1/)).toBeInTheDocument();
- expect(screen.getByText(/150 tok/)).toBeInTheDocument();
+ expect(screen.getByText(/step 1 · 100 in \/ 50 out/)).toBeInTheDocument();
// Turn total should NOT render (total is null — turn still in progress)
expect(screen.queryByText(/^turn/)).toBeNull();
@@ -707,7 +771,7 @@ describe("ChatView", () => {
expect(container.querySelector("img")?.getAttribute("src")).toBe(url);
});
- it("renders a consult_vision tool call/result like any other tool", () => {
+ it("renders a consult_vision tool call/result like any other tool", async () => {
// read_image is GONE — replaced by consult_vision (opens a vision-model
// conversation, attaches the image + question, returns the answer). It is
// a normal tool call: rendered generically by toolName.
@@ -737,9 +801,10 @@ describe("ChatView", () => {
},
];
- render(ChatView, { props: { chunks } });
+ const { container } = render(ChatView, { props: { chunks } });
+ await openAllCollapses(container);
- expect(screen.getAllByText("consult_vision").length).toBeGreaterThan(0);
+ expect(screen.getAllByText("Ask vision model").length).toBeGreaterThan(0);
expect(screen.getByText("a red square on a white background")).toBeInTheDocument();
});
@@ -784,6 +849,71 @@ describe("ChatView", () => {
});
describe("Composer", () => {
+ it.each([
+ { status: "idle", queuedMessageCount: 0, text: "", label: "Send" },
+ { status: "running", queuedMessageCount: 0, text: "next", label: "Queue" },
+ { status: "running", queuedMessageCount: 0, text: "", label: "Stop generation" },
+ { status: "running", queuedMessageCount: 1, text: "", label: "Flush" },
+ ] as const)("keeps $label at the same fixed width", async ({ label, text, ...props }) => {
+ const user = userEvent.setup();
+ render(Composer, { props: { ...props, onSend: vi.fn(), onQueue: vi.fn(), onStop: vi.fn() } });
+ if (text) await user.type(screen.getByRole("textbox", { name: "Message input" }), text);
+ const button = screen.getByRole("button", { name: label });
+ expect(button).toHaveClass("w-20", "shrink-0");
+ expect(button).not.toHaveClass("w-36");
+ });
+
+ it.each([
+ "running",
+ "queued",
+ ] as const)("relabels Stop only with queued input while %s and preserves its action", async (status) => {
+ const onStop = vi.fn();
+ const onSend = vi.fn();
+ const onQueue = vi.fn();
+ const user = userEvent.setup();
+ const { rerender } = render(Composer, { props: { onSend, onQueue, onStop, status } });
+ const expectPlainStop = () => {
+ const button = screen.getByRole("button", { name: "Stop generation" });
+ expect(button).toHaveTextContent(/^Stop$/);
+ expect(button).not.toHaveAttribute("title");
+ };
+ expectPlainStop(); // No queue snapshot available.
+ await rerender({ queuedMessageCount: 0 });
+ expectPlainStop();
+ await user.click(screen.getByRole("button", { name: "Stop generation" }));
+ expect(onStop).toHaveBeenCalledTimes(1);
+
+ for (const queuedMessageCount of [1, 2]) {
+ await rerender({ queuedMessageCount });
+ const button = screen.getByRole("button", { name: "Flush" });
+ expect(button).toHaveTextContent(/^Flush$/);
+ expect(button).toHaveAttribute(
+ "title",
+ "Stop the current response and start the queued message now",
+ );
+ }
+ await user.click(screen.getByRole("button", { name: "Flush" }));
+ expect(onStop).toHaveBeenCalledTimes(2);
+ expect(onSend).not.toHaveBeenCalled();
+ expect(onQueue).not.toHaveBeenCalled();
+
+ const input = screen.getByRole("textbox", { name: "Message input" });
+ await user.type(input, "next");
+ expect(screen.getByRole("button", { name: "Queue" })).toBeInTheDocument();
+ expect(screen.queryByRole("button", { name: "Flush" })).toBeNull();
+ await user.click(screen.getByRole("button", { name: "Queue" }));
+ expect(onQueue).toHaveBeenCalledExactlyOnceWith("next", undefined);
+ expect(screen.getByRole("button", { name: "Flush" })).toBeInTheDocument();
+ await rerender({ queuedMessageCount: 0 });
+ expectPlainStop(); // Queue drained or cancelled.
+ await rerender({ queuedMessageCount: 1, status: "idle" });
+ expect(screen.getByRole("button", { name: "Send" })).toBeDisabled();
+ await rerender({ status: "error" });
+ expect(screen.getByRole("button", { name: "Send" })).toBeDisabled();
+ await rerender({ status, queuedMessageCount: undefined });
+ expectPlainStop();
+ });
+
it("calls onSend with the typed text and clears", async () => {
const onSend = vi.fn();
const user = userEvent.setup();
@@ -964,17 +1094,28 @@ describe("Composer", () => {
expect(screen.queryByRole("button", { name: "Remove image" })).not.toBeInTheDocument();
});
- it("queues (steers) text-only and never forwards images", async () => {
- // While running, the Send button becomes "Queue"; steering is text-only.
+ it("still queues text-only with images omitted", async () => {
const onQueue = vi.fn();
const onSend = vi.fn();
const user = userEvent.setup();
- const { container } = render(Composer, { props: { onSend, onQueue, status: "running" } });
+ render(Composer, { props: { onSend, onQueue, status: "running" } });
+ await user.type(screen.getByRole("textbox", { name: "Message input" }), "steer{Enter}");
+ expect(onQueue).toHaveBeenCalledExactlyOnceWith("steer", undefined);
+ expect(onSend).not.toHaveBeenCalled();
+ });
+
+ it("queues text and images while running and clears the composer", async () => {
+ const onQueue = vi.fn();
+ const onSend = vi.fn();
+ const user = userEvent.setup();
+ const { container } = render(Composer, {
+ props: { onSend, onQueue, status: "running", encodeJpeg: async () => null },
+ });
const textarea = screen.getByRole("textbox", { name: "Message input" });
await user.type(textarea, "steer here");
- // Also stage an image — it must NOT be forwarded on a queue.
+ // Stage an image along with the steering text.
const file = new File(["PNG"], "shot.png", { type: "image/png" });
const input = container.querySelector('input[type="file"]') as HTMLInputElement;
Object.defineProperty(input, "files", { value: [file], writable: false });
@@ -984,8 +1125,187 @@ describe("Composer", () => {
});
await user.click(screen.getByRole("button", { name: "Queue" }));
- expect(onQueue).toHaveBeenCalledWith("steer here");
+ expect(onQueue).toHaveBeenCalledExactlyOnceWith("steer here", [
+ { url: "data:image/png;base64,UE5H", mimeType: "image/png" },
+ ]);
expect(onSend).not.toHaveBeenCalled();
+ expect(textarea).toHaveValue("");
+ expect(screen.queryByRole("button", { name: "Remove image" })).not.toBeInTheDocument();
+ });
+
+ it.each(["running", "queued"] as const)("queues images-only while %s", async (status) => {
+ const onQueue = vi.fn();
+ const onSend = vi.fn();
+ const user = userEvent.setup();
+ const { container } = render(Composer, { props: { onSend, onQueue, status } });
+ const input = container.querySelector('input[type="file"]') as HTMLInputElement;
+ Object.defineProperty(input, "files", {
+ value: [new File(["GIF"], "shot.gif", { type: "image/gif" })],
+ });
+ input.dispatchEvent(new Event("change", { bubbles: true }));
+ await screen.findByRole("button", { name: "Remove image" });
+
+ await user.click(screen.getByRole("button", { name: "Queue" }));
+
+ expect(onQueue).toHaveBeenCalledExactlyOnceWith("", [
+ { url: "data:image/gif;base64,R0lG", mimeType: "image/gif" },
+ ]);
+ expect(onSend).not.toHaveBeenCalled();
+ expect(screen.queryByRole("button", { name: "Remove image" })).not.toBeInTheDocument();
+ });
+
+ it.each([
+ "running",
+ "queued",
+ ] as const)("keeps images and text staged while %s without onQueue", async (status) => {
+ const onSend = vi.fn();
+ const user = userEvent.setup();
+ const { container, rerender } = render(Composer, { props: { onSend, status } });
+ const input = container.querySelector('input[type="file"]') as HTMLInputElement;
+ Object.defineProperty(input, "files", {
+ value: [new File(["GIF"], "shot.gif", { type: "image/gif" })],
+ });
+ input.dispatchEvent(new Event("change", { bubbles: true }));
+ await screen.findByRole("button", { name: "Remove image" });
+ const textarea = screen.getByRole("textbox", { name: "Message input" });
+ await user.type(textarea, "{Enter}"); // image-only: no queue callback
+ await user.type(textarea, "keep this{Enter}");
+ container
+ .querySelector("form")
+ ?.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
+
+ expect(onSend).not.toHaveBeenCalled();
+ expect(textarea).toHaveValue("keep this");
+ expect(screen.getByRole("button", { name: "Remove image" })).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Queue" })).toBeDisabled();
+ // Once idle, the same staged input can actually be sent.
+ await rerender({ status: "idle" });
+ await user.click(screen.getByRole("button", { name: "Send" }));
+ expect(onSend).toHaveBeenCalledExactlyOnceWith("keep this", [
+ { url: "data:image/gif;base64,R0lG", mimeType: "image/gif" },
+ ]);
+ expect(screen.queryByRole("button", { name: "Remove image" })).not.toBeInTheDocument();
+ });
+});
+
+describe("Composer image compression", () => {
+ /** Stage a PNG through the file picker and wait for the thumbnail. */
+ async function stagePng(container: HTMLElement, content = "PNGDATA"): Promise<void> {
+ const file = new File([content], "shot.png", { type: "image/png" });
+ const input = container.querySelector('input[type="file"]') as HTMLInputElement;
+ Object.defineProperty(input, "files", { value: [file], writable: false });
+ input.dispatchEvent(new Event("change", { bubbles: true }));
+ await vi.waitFor(() => {
+ expect(screen.getByRole("button", { name: "Remove image" })).toBeInTheDocument();
+ });
+ }
+
+ /** A fake converted JPEG (the outermost-edge stub — no canvas involved). */
+ function fakeJpeg(): ConvertedJpeg {
+ return { blob: new Blob(["SMALLJPEG"], { type: "image/jpeg" }), mimeType: "image/jpeg" };
+ }
+
+ it("shows the compress toggle CHECKED by default for a staged PNG", async () => {
+ const { container } = render(Composer, {
+ props: { onSend: vi.fn(), encodeJpeg: vi.fn(async () => null) },
+ });
+ await stagePng(container);
+
+ const toggle = screen.getByRole("checkbox", { name: "Compress image" });
+ expect(toggle).toBeChecked();
+ });
+
+ it("hides the compress toggle for already-lossy formats (JPEG)", async () => {
+ const { container } = render(Composer, {
+ props: { onSend: vi.fn(), encodeJpeg: vi.fn(async () => null) },
+ });
+ const file = new File(["JPG"], "photo.jpg", { type: "image/jpeg" });
+ const input = container.querySelector('input[type="file"]') as HTMLInputElement;
+ Object.defineProperty(input, "files", { value: [file], writable: false });
+ input.dispatchEvent(new Event("change", { bubbles: true }));
+ await vi.waitFor(() => {
+ expect(screen.getByRole("button", { name: "Remove image" })).toBeInTheDocument();
+ });
+
+ expect(screen.queryByRole("checkbox", { name: "Compress image" })).not.toBeInTheDocument();
+ });
+
+ it("sends the converted JPEG (image/jpeg) when compress is on, encoding exactly once", async () => {
+ const encodeJpeg = vi.fn(async () => fakeJpeg());
+ const onSend = vi.fn();
+ const user = userEvent.setup();
+ const { container } = render(Composer, { props: { onSend, encodeJpeg } });
+ await stagePng(container);
+
+ // The async conversion lands and the staged URL reflects what will be sent.
+ await vi.waitFor(() => {
+ expect(encodeJpeg).toHaveBeenCalledTimes(1);
+ });
+ await vi.waitFor(() => {
+ const thumb = container.querySelector("img[alt='image/jpeg']");
+ expect(thumb).not.toBeNull();
+ expect(thumb?.getAttribute("src")).toMatch(/^data:image\/jpeg;base64,/);
+ });
+
+ await user.click(screen.getByRole("button", { name: "Send" }));
+ expect(onSend).toHaveBeenCalledTimes(1);
+ const [, images] = onSend.mock.calls[0] ?? [];
+ expect(images).toHaveLength(1);
+ expect(images[0]?.mimeType).toBe("image/jpeg");
+ expect(images[0]?.url).toMatch(/^data:image\/jpeg;base64,/);
+ });
+
+ it("sends the ORIGINAL untouched bytes when the toggle is unchecked", async () => {
+ const encodeJpeg = vi.fn(async () => fakeJpeg());
+ const onSend = vi.fn();
+ const user = userEvent.setup();
+ const { container } = render(Composer, { props: { onSend, encodeJpeg } });
+ await stagePng(container);
+ await vi.waitFor(() => expect(encodeJpeg).toHaveBeenCalledTimes(1));
+
+ await user.click(screen.getByRole("checkbox", { name: "Compress image" }));
+ expect(screen.getByRole("checkbox", { name: "Compress image" })).not.toBeChecked();
+
+ await user.click(screen.getByRole("button", { name: "Send" }));
+ expect(onSend).toHaveBeenCalledTimes(1);
+ const [, images] = onSend.mock.calls[0] ?? [];
+ expect(images[0]?.mimeType).toBe("image/png");
+ expect(images[0]?.url).toMatch(/^data:image\/png;base64,/);
+ });
+
+ it("re-checking uses the CACHED conversion — no second encode", async () => {
+ const encodeJpeg = vi.fn(async () => fakeJpeg());
+ const onSend = vi.fn();
+ const user = userEvent.setup();
+ const { container } = render(Composer, { props: { onSend, encodeJpeg } });
+ await stagePng(container);
+ await vi.waitFor(() => expect(encodeJpeg).toHaveBeenCalledTimes(1));
+
+ const toggle = screen.getByRole("checkbox", { name: "Compress image" });
+ await user.click(toggle); // OFF → original
+ await user.click(toggle); // ON again → must reuse the cached JPEG
+
+ await user.click(screen.getByRole("button", { name: "Send" }));
+ expect(encodeJpeg).toHaveBeenCalledTimes(1); // still exactly one encode
+ const [, images] = onSend.mock.calls[0] ?? [];
+ expect(images[0]?.mimeType).toBe("image/jpeg");
+ expect(images[0]?.url).toMatch(/^data:image\/jpeg;base64,/);
+ });
+
+ it("falls back to the original image when conversion fails", async () => {
+ const encodeJpeg = vi.fn(async () => {
+ throw new Error("decode exploded");
+ });
+ const onSend = vi.fn();
+ const user = userEvent.setup();
+ const { container } = render(Composer, { props: { onSend, encodeJpeg } });
+ await stagePng(container);
+ await vi.waitFor(() => expect(encodeJpeg).toHaveBeenCalledTimes(1));
+
+ await user.click(screen.getByRole("button", { name: "Send" }));
+ const [, images] = onSend.mock.calls[0] ?? [];
+ expect(images[0]?.mimeType).toBe("image/png");
+ expect(images[0]?.url).toMatch(/^data:image\/png;base64,/);
});
});
@@ -1006,7 +1326,9 @@ describe("ModelSelector", () => {
expect(keySelect).toHaveValue("anthropic");
expect(modelSelect).toHaveValue("claude-3");
- expect(optionValues(keySelect)).toEqual(["openai", "anthropic", "google"]);
+ // The synthetic "Named Agents" provider is ALWAYS first (plan §6), even with
+ // zero configured agents.
+ expect(optionValues(keySelect)).toEqual(["__named_agents__", "openai", "anthropic", "google"]);
// only the models under the selected key
expect(optionValues(modelSelect)).toEqual(["claude-3"]);
});
@@ -1041,6 +1363,133 @@ describe("ModelSelector", () => {
expect(onSelect).toHaveBeenCalledWith("openai/gpt-4o");
});
+ it("lists Named Agents under the synthetic provider and resolves a selection to a snapshot", async () => {
+ const onSelectNamedAgent = vi.fn();
+ const user = userEvent.setup();
+ const models = ["openai/gpt-4"];
+ const namedAgents = [
+ { id: "a1", name: "Coding Agent", model: "opencode-1/claude-sonnet-4.5" },
+ { id: "a2", name: "Research Agent", model: "umans/umans-glm-5.2" },
+ ];
+
+ const { rerender } = render(ModelSelector, {
+ props: {
+ models,
+ selected: "openai/gpt-4",
+ onSelect: vi.fn(),
+ namedAgents,
+ onSelectNamedAgent,
+ },
+ });
+
+ // Switching the provider to "Named Agents" jumps to the first agent (mirrors
+ // a raw provider jumping to its first model).
+ await user.selectOptions(
+ screen.getByRole("combobox", { name: "Key selector" }),
+ "__named_agents__",
+ );
+ expect(onSelectNamedAgent).toHaveBeenCalledWith({
+ namedAgentId: "a1",
+ namedAgentName: "Coding Agent",
+ model: "opencode-1/claude-sonnet-4.5",
+ });
+
+ // The parent (store) applies the selection → re-render with the resolved raw
+ // model + snapshot, exactly as `selectModel(model, snapshot)` would do.
+ const codingAgent = namedAgents[0];
+ await rerender({
+ models,
+ selected: codingAgent?.model ?? "",
+ onSelect: vi.fn(),
+ namedAgents,
+ namedAgentSnapshot: {
+ namedAgentId: "a1",
+ namedAgentName: "Coding Agent",
+ model: "opencode-1/claude-sonnet-4.5",
+ },
+ onSelectNamedAgent,
+ });
+
+ // The model dropdown now lists the named agents; pick a different one.
+ await user.selectOptions(
+ screen.getByRole("combobox", { name: "Model selector" }),
+ "Research Agent",
+ );
+ expect(onSelectNamedAgent).toHaveBeenLastCalledWith({
+ namedAgentId: "a2",
+ namedAgentName: "Research Agent",
+ model: "umans/umans-glm-5.2",
+ });
+ });
+
+ it("shows a disabled placeholder when the synthetic provider has no configured agents", () => {
+ render(ModelSelector, {
+ props: { models: ["openai/gpt-4"], selected: "openai/gpt-4", onSelect: vi.fn() },
+ });
+ const modelSelect = screen.getByRole("combobox", { name: "Model selector" });
+ const options = within(modelSelect).getAllByRole("option");
+ // With the raw openai provider selected the model dropdown lists gpt-4; the
+ // "No named agents configured" placeholder appears only under the synthetic
+ // provider (which we'd select to see it). Assert the disabled/empty handling
+ // by checking the synthetic provider is present in the key selector.
+ const keySelect = screen.getByRole("combobox", { name: "Key selector" });
+ expect(optionValues(keySelect)).toContain("__named_agents__");
+ // Switch to the (empty) synthetic provider → disabled "No named agents configured".
+ expect(() => screen.getByRole("option", { name: /No named agents configured/ })).toThrow();
+ expect(options).toHaveLength(1);
+ });
+
+ it("keeps showing a snapshot of a renamed/removed Named Agent (unavailable)", () => {
+ // A conversation selected via a Named Agent retains its snapshot even after
+ // the agent is gone from the current list (plan §7).
+ const namedAgentSnapshot = {
+ namedAgentId: "gone",
+ namedAgentName: "Old Agent",
+ model: "opencode-1/claude-sonnet-4.5",
+ };
+ render(ModelSelector, {
+ props: {
+ models: [],
+ selected: "opencode-1/claude-sonnet-4.5",
+ onSelect: vi.fn(),
+ namedAgents: [],
+ namedAgentSnapshot,
+ },
+ });
+
+ const keySelect = screen.getByRole("combobox", { name: "Key selector" });
+ expect(keySelect).toHaveValue("__named_agents__");
+ const modelSelect = screen.getByRole("combobox", { name: "Model selector" });
+ expect(modelSelect).toHaveValue("Old Agent");
+ expect(screen.getByRole("option", { name: /Old Agent.*unavailable/ })).toBeInTheDocument();
+ });
+
+ it("selecting a raw model from a Named Agent conversation clears the snapshot via onSelect", async () => {
+ const onSelect = vi.fn();
+ const onSelectNamedAgent = vi.fn();
+ const user = userEvent.setup();
+ const namedAgentSnapshot = {
+ namedAgentId: "a1",
+ namedAgentName: "Coding Agent",
+ model: "opencode-1/claude-sonnet-4.5",
+ };
+ render(ModelSelector, {
+ props: {
+ models: ["openai/gpt-4"],
+ selected: "opencode-1/claude-sonnet-4.5",
+ onSelect,
+ namedAgents: [{ id: "a1", name: "Coding Agent", model: "opencode-1/claude-sonnet-4.5" }],
+ namedAgentSnapshot,
+ onSelectNamedAgent,
+ },
+ });
+
+ // Switch from Named Agents to a raw provider → raw model selection clears the snapshot.
+ await user.selectOptions(screen.getByRole("combobox", { name: "Key selector" }), "openai");
+ expect(onSelect).toHaveBeenCalledWith("openai/gpt-4");
+ expect(onSelectNamedAgent).not.toHaveBeenCalled();
+ });
+
it("marks vision-capable models in the model dropdown", () => {
const models = ["kimi/k2", "kimi/k1.5"];
const modelInfo = {
@@ -1089,33 +1538,95 @@ describe("ModelSelector", () => {
});
expect(screen.getByText(/auto-described/i)).toBeInTheDocument();
});
+
+ it("labels numbered OpenCode keys as OpenCode #N and sorts them numerically", () => {
+ const models = ["opencode-10/m1", "opencode-2/m2", "opencode-1/m3"];
+ const onSelect = vi.fn();
+ render(ModelSelector, {
+ props: { models, selected: "opencode-2/m2", onSelect },
+ });
+
+ const keySelect = screen.getByRole("combobox", { name: "Key selector" });
+ const options = within(keySelect).getAllByRole("option");
+ // Numeric order: #1 < #2 < #10 (after the always-first synthetic provider),
+ // and values stay RAW (opencode-N).
+ expect(options.map((o) => (o as HTMLOptionElement).value)).toEqual([
+ "__named_agents__",
+ "opencode-1",
+ "opencode-2",
+ "opencode-10",
+ ]);
+ expect(options[1]?.textContent).toContain("OpenCode #1");
+ expect(options[2]?.textContent).toContain("OpenCode #2");
+ expect(options[3]?.textContent).toContain("OpenCode #10");
+ });
+
+ it("shows a disappeared saved credential as unavailable without silently falling back", () => {
+ // Saved selection references opencode-5, but the catalog no longer has it.
+ const models = ["opencode-1/deepseek-v4-flash"];
+ const onSelect = vi.fn();
+ render(ModelSelector, {
+ props: { models, selected: "opencode-5/deepseek-v4-flash", onSelect },
+ });
+
+ // The unavailable key is surfaced as an explicit (disabled) option + notice.
+ const keySelect = screen.getByRole("combobox", { name: "Key selector" });
+ const unavailable = within(keySelect).getByText(/OpenCode #5 \(unavailable\)/i);
+ expect(unavailable).toBeInTheDocument();
+ expect(screen.getByText(/Selected model .* unavailable/)).toBeInTheDocument();
+ // No silent switch is attempted.
+ expect(onSelect).not.toHaveBeenCalled();
+ });
});
describe("ReasoningEffortSelector", () => {
- it("renders null (never set) as the default level, marked '(default)'", () => {
- render(ReasoningEffortSelector, { props: { persisted: null, save: vi.fn() } });
+ it("renders null effort + null thinking (never set) as the default level, marked '(default)'", () => {
+ render(ReasoningEffortSelector, {
+ props: { persistedEffort: null, persistedThinking: null, save: vi.fn() },
+ });
const select = screen.getByRole("combobox", { name: "Reasoning effort" });
expect(select).toHaveValue("high");
expect(within(select).getByRole("option", { name: "high (default)" })).toBeInTheDocument();
- // All five ladder levels are offered.
- expect(within(select).getAllByRole("option")).toHaveLength(5);
+ // "Off" first, then the five ladder levels.
+ const options = within(select).getAllByRole("option");
+ expect(options).toHaveLength(6);
+ expect(options[0]).toHaveValue("off");
+ expect(within(select).getByRole("option", { name: "Off" })).toBeInTheDocument();
});
- it("renders a persisted level as selected", () => {
- render(ReasoningEffortSelector, { props: { persisted: "xhigh", save: vi.fn() } });
+ it("renders a persisted level as selected when thinking is on", () => {
+ render(ReasoningEffortSelector, {
+ props: { persistedEffort: "xhigh", persistedThinking: null, save: vi.fn() },
+ });
expect(screen.getByRole("combobox", { name: "Reasoning effort" })).toHaveValue("xhigh");
});
+ it("renders 'off' as selected when thinking is disabled (effort level is preserved but hidden)", () => {
+ // thinking off is a SEPARATE axis: even with a persisted effort level, the
+ // selector shows "off" while thinking is disabled.
+ render(ReasoningEffortSelector, {
+ props: { persistedEffort: "xhigh", persistedThinking: false, save: vi.fn() },
+ });
+
+ expect(screen.getByRole("combobox", { name: "Reasoning effort" })).toHaveValue("off");
+ // the level option is still present (restored on an off→on toggle)
+ expect(
+ within(screen.getByRole("combobox")).getByRole("option", { name: "xhigh" }),
+ ).toBeInTheDocument();
+ });
+
it("selecting a level saves it via the injected port and confirms", async () => {
- const save = vi.fn(async (level: "low" | "medium" | "high" | "xhigh" | "max") => ({
+ const save = vi.fn(async (selection: "off" | "low" | "medium" | "high" | "xhigh" | "max") => ({
ok: true as const,
- reasoningEffort: level,
+ selection,
}));
const user = userEvent.setup();
- render(ReasoningEffortSelector, { props: { persisted: null, save } });
+ render(ReasoningEffortSelector, {
+ props: { persistedEffort: null, persistedThinking: null, save },
+ });
await user.selectOptions(screen.getByRole("combobox", { name: "Reasoning effort" }), "max");
@@ -1127,11 +1638,35 @@ describe("ReasoningEffortSelector", () => {
expect(screen.getByRole("combobox", { name: "Reasoning effort" })).toHaveValue("max");
});
- it("a failed save shows the error and reverts to the persisted value", async () => {
+ it("selecting 'off' saves the separate disable signal (not a level)", async () => {
+ const save = vi.fn(async (selection: "off" | "low" | "medium" | "high" | "xhigh" | "max") => ({
+ ok: true as const,
+ selection,
+ }));
+ const user = userEvent.setup();
+
+ render(ReasoningEffortSelector, {
+ props: { persistedEffort: "high", persistedThinking: null, save },
+ });
+
+ await user.selectOptions(screen.getByRole("combobox", { name: "Reasoning effort" }), "off");
+
+ expect(save).toHaveBeenCalledTimes(1);
+ // "off" is the separate thinking-disable signal — NOT a zero-effort level.
+ expect(save).toHaveBeenCalledWith("off");
+ await vi.waitFor(() => {
+ expect(screen.getByText(/applies from the next turn/i)).toBeInTheDocument();
+ });
+ expect(screen.getByRole("combobox", { name: "Reasoning effort" })).toHaveValue("off");
+ });
+
+ it("a failed save shows the error and reverts to the persisted selection", async () => {
const save = vi.fn(async () => ({ ok: false as const, error: "nope" }));
const user = userEvent.setup();
- render(ReasoningEffortSelector, { props: { persisted: "low", save } });
+ render(ReasoningEffortSelector, {
+ props: { persistedEffort: "low", persistedThinking: null, save },
+ });
await user.selectOptions(screen.getByRole("combobox", { name: "Reasoning effort" }), "max");
@@ -1142,24 +1677,215 @@ describe("ReasoningEffortSelector", () => {
});
it("disables the select while a save is in flight (no double-fire)", async () => {
- let resolveSave: ((r: { ok: true; reasoningEffort: "max" }) => void) | undefined;
+ let resolveSave: ((r: { ok: true; selection: "max" }) => void) | undefined;
const save = vi.fn(
() =>
- new Promise<{ ok: true; reasoningEffort: "max" }>((resolve) => {
+ new Promise<{ ok: true; selection: "max" }>((resolve) => {
resolveSave = resolve;
}),
);
const user = userEvent.setup();
- render(ReasoningEffortSelector, { props: { persisted: null, save } });
+ render(ReasoningEffortSelector, {
+ props: { persistedEffort: null, persistedThinking: null, save },
+ });
await user.selectOptions(screen.getByRole("combobox", { name: "Reasoning effort" }), "max");
expect(screen.getByRole("combobox", { name: "Reasoning effort" })).toBeDisabled();
- resolveSave?.({ ok: true, reasoningEffort: "max" });
+ resolveSave?.({ ok: true, selection: "max" });
await vi.waitFor(() => {
expect(screen.getByRole("combobox", { name: "Reasoning effort" })).toBeEnabled();
});
});
});
+
+describe("ChatView — cache timer", () => {
+ const usage = { inputTokens: 1000, outputTokens: 10, cacheReadTokens: 900 };
+ function step(stepId: string) {
+ return { stepId: stepId as StepId, usage, decodeMs: 100 };
+ }
+ function turn(turnId: string, stepIds: string[] = [`${turnId}#0`]): TurnMetricsEntry {
+ const steps = stepIds.map(step);
+ return { turnId, steps, total: { turnId, usage, steps } };
+ }
+ function exchange(turnId: string, base: number): RenderedChunk[] {
+ return [
+ {
+ seq: base,
+ role: "user",
+ chunk: { type: "text", text: `q-${turnId}` },
+ provisional: false,
+ turnId,
+ },
+ {
+ seq: base + 1,
+ role: "assistant",
+ chunk: { type: "text", text: `a-${turnId}` },
+ provisional: false,
+ turnId,
+ },
+ ];
+ }
+ function toolChunk(turnId: string, stepId: string, seq: number): RenderedChunk {
+ return {
+ seq,
+ role: "assistant",
+ chunk: {
+ type: "tool-call",
+ toolCallId: `call-${stepId}`,
+ toolName: "read_file",
+ input: {},
+ stepId: stepId as StepId,
+ },
+ provisional: false,
+ turnId,
+ };
+ }
+ function state(partial: Partial<CacheTimerState>): CacheTimerState {
+ return { ...initialCacheTimerState(), ...partial };
+ }
+
+ it("renders nothing when the feature is off (cacheTimer null)", () => {
+ render(ChatView, { props: { chunks: exchange("t1", 1), turnMetrics: [turn("t1")] } });
+ expect(screen.queryByTestId("cache-timer-live")).toBeNull();
+ expect(screen.queryByTestId("cache-timer-stamp")).toBeNull();
+ });
+
+ it("shows a live timer under executing tools, counting up to 10+ min", async () => {
+ vi.useFakeTimers();
+ try {
+ const chunks = [exchange("t1", 1)[0] as RenderedChunk, toolChunk("t1", "t1#0", 2)];
+ render(ChatView, {
+ props: {
+ chunks,
+ turnMetrics: [{ turnId: "t1", steps: [step("t1#0")], total: null }],
+ cacheTimer: state({
+ running: {
+ kind: "step",
+ turnId: "t1",
+ stepId: "t1#0",
+ startedAt: Date.now(),
+ toolCount: 1,
+ pending: new Set(["call-t1#0"]),
+ maxToolMs: 0,
+ },
+ }),
+ },
+ });
+ const row = screen.getByTestId("cache-timer-tools");
+ expect(row).toHaveTextContent(/Running tools/);
+ expect(screen.getByTestId("cache-timer-live")).toHaveTextContent("0:00");
+ await vi.advanceTimersByTimeAsync(5 * 60_000 + 30_000);
+ expect(screen.getByTestId("cache-timer-live")).toHaveTextContent("5:30");
+ await vi.advanceTimersByTimeAsync(5 * 60_000);
+ expect(screen.getByTestId("cache-timer-live")).toHaveTextContent("10+ min");
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+
+ it("hides the live tools row for a step with no tool calls", () => {
+ render(ChatView, {
+ props: {
+ chunks: exchange("t1", 1),
+ turnMetrics: [{ turnId: "t1", steps: [step("t1#0")], total: null }],
+ cacheTimer: state({
+ running: {
+ kind: "step",
+ turnId: "t1",
+ stepId: "t1#0",
+ startedAt: Date.now(),
+ toolCount: 0,
+ pending: new Set(),
+ maxToolMs: 0,
+ },
+ }),
+ },
+ });
+ expect(screen.queryByTestId("cache-timer-tools")).toBeNull();
+ });
+
+ it("stamps a finished step gap on that step's line, next to its cache badge", () => {
+ const chunks = [exchange("t1", 1)[0] as RenderedChunk, toolChunk("t1", "t1#0", 2)];
+ render(ChatView, {
+ props: {
+ chunks,
+ turnMetrics: [{ turnId: "t1", steps: [step("t1#0")], total: null }],
+ cacheTimer: state({ stepStamps: new Map([["t1#0", 65_000]]) }),
+ },
+ });
+ const stamp = screen.getByTestId("cache-timer-stamp");
+ expect(stamp).toHaveTextContent("1:05");
+ // Sized to match the step's (xs) cache badge.
+ expect(stamp).toHaveClass("badge-xs");
+ expect(stamp).not.toHaveClass("badge-sm");
+ // Same line as the step's cache badge.
+ expect(stamp.parentElement).toContainElement(screen.getByTestId("step-cache-badge"));
+ expect(screen.queryByTestId("cache-timer-tools")).toBeNull();
+ });
+
+ it("runs a turn gap live on that turn's row, and shows stamps on earlier turns", () => {
+ render(ChatView, {
+ props: {
+ chunks: [...exchange("t1", 1), ...exchange("t2", 3)],
+ turnMetrics: [turn("t1"), turn("t2")],
+ cacheTimer: state({
+ running: { kind: "turn", turnId: "t2", startedAt: Date.now() },
+ turnStamps: new Map([["t1", 11 * 60_000]]),
+ }),
+ },
+ });
+ const live = screen.getByTestId("cache-timer-live");
+ expect(live.closest(".chat-bubble")).toHaveTextContent(/turn 2/);
+ expect(live).toHaveClass("badge-sm");
+ const stamp = screen.getByTestId("cache-timer-stamp");
+ expect(stamp).toHaveTextContent("10+ min");
+ expect(stamp.closest(".chat-bubble")).toHaveTextContent(/turn 1/);
+ });
+
+ it("a live timer mounts without re-running its effect on a moving clock (no render stall)", async () => {
+ // Regression: the badge's effect once READ the `current` state it wrote, so
+ // with a real (always-advancing) clock it re-ran itself until Svelte threw
+ // `effect_update_depth_exceeded` — aborting the flush, so turn data showed late.
+ let calls = 0;
+ const now = () => {
+ calls++;
+ return 1_000_000 + calls;
+ };
+ render(CacheTimerBadge, { props: { since: 1_000_000, now } });
+ await new Promise((r) => setTimeout(r, 20));
+ expect(calls).toBeLessThan(5);
+ expect(screen.getByTestId("cache-timer-live")).toHaveTextContent("0:00");
+ });
+
+ it("renders the turn's metrics alongside a live turn timer", () => {
+ render(ChatView, {
+ props: {
+ chunks: exchange("t1", 1),
+ turnMetrics: [turn("t1")],
+ cacheTimer: state({ running: { kind: "turn", turnId: "t1", startedAt: Date.now() } }),
+ },
+ });
+ expect(screen.getByText(/turn 1 · 10 tok out/)).toBeInTheDocument();
+ expect(screen.getByText("a-t1")).toBeInTheDocument();
+ expect(screen.getByTestId("cache-timer-live")).toHaveTextContent(/^0:00$/);
+ });
+
+ it("puts a SEEDED turn gap (no turnId) on the most recent turn's row", () => {
+ render(ChatView, {
+ props: {
+ chunks: [...exchange("t1", 1), ...exchange("t2", 3)],
+ turnMetrics: [turn("t1"), turn("t2")],
+ cacheTimer: state({
+ running: { kind: "turn", turnId: null, startedAt: Date.now() - 2 * 60_000 },
+ }),
+ },
+ });
+ const badges = screen.getAllByTestId("cache-timer-live");
+ expect(badges).toHaveLength(1);
+ expect(badges[0]).toHaveTextContent("2:00");
+ expect(badges[0]?.closest(".chat-bubble")).toHaveTextContent(/turn 2/);
+ });
+});