/** @jsxImportSource react */
import { describe, expect, test } from "bun:test";
import { renderToStaticMarkup } from "react-dom/server";
import type { DynamicToolUIPart } from "ai";
import { ToolAggregateGroup, buildAggregateRows } from "../src/components/chat/tool-aggregate-group";
import { CurrentToolLifecycleProvider } from "../src/components/chat/current-tool-lifecycle-context";
import { getToolAggregateLifecycle } from "../src/lib/tool-aggregate";
const runningCommand: DynamicToolUIPart = {
type: "dynamic-tool",
toolName: "bash",
toolCallId: "running-command",
state: "input-available",
input: { command: "git status", description: "Check repository state" },
};
const completedCommand: DynamicToolUIPart = {
...runningCommand,
toolCallId: "completed-command",
state: "output-available",
output: "clean",
};
const failedCommand: DynamicToolUIPart = {
...runningCommand,
toolCallId: "failed-command",
state: "output-error",
errorText: "Process exited with code 2",
};
describe("tool aggregate running feedback", () => {
test("classifies only lifecycle facts the aggregate can prove", () => {
expect(getToolAggregateLifecycle([runningCommand], "running")).toBe("running");
expect(getToolAggregateLifecycle([runningCommand], "waiting")).toBe("waiting");
expect(getToolAggregateLifecycle([runningCommand], "interrupted")).toBe("unknown");
expect(getToolAggregateLifecycle([completedCommand], null)).toBe("completed");
expect(getToolAggregateLifecycle([failedCommand], null)).toBe("failed");
});
test("uses a quiet shimmer instead of a spinner for the current action", () => {
const markup = renderToStaticMarkup(
,
);
expect(markup).toContain("Running command");
expect(markup).not.toContain("Running 1 command");
expect(markup).not.toContain("Now:");
expect(markup).toContain("ow-text-shimmer");
expect(markup).not.toContain("animate-spin");
});
test("shimmers the whole active action without a Now prefix", () => {
const settledRead: DynamicToolUIPart = {
type: "dynamic-tool",
toolName: "read",
toolCallId: "settled-read",
state: "output-available",
input: { filePath: "/repo/other.tsx" },
output: "contents",
};
const runningEdit: DynamicToolUIPart = {
type: "dynamic-tool",
toolName: "edit",
toolCallId: "running-edit",
state: "input-available",
input: { filePath: "/repo/message-list.tsx" },
};
const markup = renderToStaticMarkup(
,
);
expect(markup).not.toContain("Now:");
expect(markup).toContain("ow-text-shimmer");
expect(markup).toContain("Editing message-list.tsx");
});
test("a single file action renders as its row, not under a count header", () => {
const settledEdit: DynamicToolUIPart = {
type: "dynamic-tool",
toolName: "edit",
toolCallId: "settled-edit",
state: "output-available",
input: { filePath: "/repo/file-chip.tsx" },
output: "ok",
};
const markup = renderToStaticMarkup();
expect(markup).not.toContain("Edited 1 file");
expect(markup).not.toContain("aria-expanded");
expect(markup).toContain("Edited");
expect(markup).toContain("file-chip.tsx");
});
test("a single running file action shimmers its verb in the solo row", () => {
const runningEdit: DynamicToolUIPart = {
type: "dynamic-tool",
toolName: "edit",
toolCallId: "running-edit",
state: "input-available",
input: { filePath: "/repo/message-list.tsx" },
};
const markup = renderToStaticMarkup();
expect(markup).not.toContain("Editing 1 file");
expect(markup).toContain("ow-text-shimmer");
expect(markup).toContain("Editing");
expect(markup).toContain("message-list.tsx");
});
});
describe("tool aggregate row merging", () => {
const readOf = (toolCallId: string, filePath: string): DynamicToolUIPart => ({
type: "dynamic-tool",
toolName: "read",
toolCallId,
state: "output-available",
input: { filePath },
output: "contents",
});
test("collapses repeated settled reads of the same file into one ×N row", () => {
const rows = buildAggregateRows(
[readOf("read-1", "/repo/message-list.tsx"), readOf("read-2", "/repo/message-list.tsx")],
[],
);
expect(rows).toHaveLength(1);
expect(rows[0]?.repeat).toBe(2);
expect(rows[0]?.index).toBe(0);
expect(rows[0]?.lastIndex).toBe(1);
});
test("keeps reads of different files as separate rows", () => {
const rows = buildAggregateRows(
[readOf("read-1", "/repo/a.tsx"), readOf("read-2", "/repo/b.tsx")],
[],
);
expect(rows).toHaveLength(2);
expect(rows.every((row) => row.repeat === 1)).toBe(true);
});
test("a thought anchored between two identical reads keeps them apart", () => {
const rows = buildAggregateRows(
[readOf("read-1", "/repo/a.tsx"), readOf("read-2", "/repo/a.tsx")],
[{ afterIndex: 1, text: "Checking the export shape.", isStreaming: false }],
);
expect(rows).toHaveLength(2);
});
});