Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,9 @@ vi.mock("@/stores/widget-debug-store", () => ({
clearCspViolations: stableStoreFns.clearCspViolations,
setWidgetModelContext: stableStoreFns.setWidgetModelContext,
setWidgetHtml: stableStoreFns.setWidgetHtml,
setStreamingHistoryCount: vi.fn(),
setStreamingPlaybackActive: vi.fn(),
widgets: new Map(),
}),
}));

Expand All @@ -156,9 +159,13 @@ vi.mock("@/lib/mcp-ui/mcp-apps-utils", () => ({
isVisibleToModelOnly: () => false,
}));

vi.mock("lucide-react", () => ({
X: (props: any) => <div {...props} />,
}));
vi.mock("lucide-react", async (importOriginal) => {
const actual = await importOriginal<typeof import("lucide-react")>();
return {
...actual,
X: (props: any) => <div {...props} />,
};
});

vi.mock("../mcp-apps-modal", () => ({
McpAppsModal: () => null,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { StreamingPlaybackBar } from "../streaming-playback-bar";
import type { PartialHistoryEntry } from "../useToolInputStreaming";

function createHistory(count: number): PartialHistoryEntry[] {
return Array.from({ length: count }, (_, i) => ({
timestamp: 1000 + i * 100,
elapsedFromStart: i * 100,
input: { code: "x".repeat(i + 1) },
isFinal: i === count - 1,
}));
}

describe("StreamingPlaybackBar", () => {
const defaultProps = {
replayToPosition: vi.fn(),
exitReplay: vi.fn(),
isReplayActive: false,
toolCallId: "call-1",
};

beforeEach(() => {
vi.useFakeTimers();
vi.clearAllMocks();
});

afterEach(() => {
vi.useRealTimers();
});

it("renders all transport control buttons", () => {
const history = createHistory(4);
render(<StreamingPlaybackBar {...defaultProps} partialHistory={history} />);

expect(screen.getByLabelText("Previous")).toBeInTheDocument();
expect(screen.getByLabelText("Play")).toBeInTheDocument();
expect(screen.getByLabelText("Next")).toBeInTheDocument();
});

it("displays position label", () => {
const history = createHistory(4);
render(<StreamingPlaybackBar {...defaultProps} partialHistory={history} />);

// Initially at last position: "4/4"
expect(screen.getByText(/4\/4/)).toBeInTheDocument();
});

it("Previous button calls replayToPosition with correct index", () => {
const history = createHistory(4);
const replayToPosition = vi.fn();
render(
<StreamingPlaybackBar
{...defaultProps}
partialHistory={history}
replayToPosition={replayToPosition}
/>,
);

// First click Previous to move from position 3 to 2
fireEvent.click(screen.getByLabelText("Previous"));

expect(replayToPosition).toHaveBeenCalledWith(2);
});

it("Next button is disabled at last position", () => {
const history = createHistory(4);
render(<StreamingPlaybackBar {...defaultProps} partialHistory={history} />);

const nextButton = screen.getByLabelText("Next");
expect(nextButton).toBeDisabled();
});

it("renders speed selector with default value", () => {
const history = createHistory(4);
render(<StreamingPlaybackBar {...defaultProps} partialHistory={history} />);

expect(screen.getByLabelText("Playback speed")).toBeInTheDocument();
});

it("renders timeline slider", () => {
const history = createHistory(4);
render(<StreamingPlaybackBar {...defaultProps} partialHistory={history} />);
expect(screen.getByLabelText("Streaming timeline")).toBeInTheDocument();
});

it("renders Raw JSON collapsible trigger", () => {
const history = createHistory(4);
render(<StreamingPlaybackBar {...defaultProps} partialHistory={history} />);

expect(screen.getByText("Raw JSON")).toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -444,4 +444,221 @@ describe("useToolInputStreaming", () => {

expect(bridge.sendToolCancelled).toHaveBeenCalledTimes(1);
});

// ── Partial history tests ───────────────────────────────────────────────

describe("partialHistory", () => {
it("is empty during streaming, populated after transition to input-available", () => {
const props = createDefaultProps(bridge);
props.toolState = "input-streaming";
props.toolInput = { code: "he" };

const { result, rerender } = renderHook(() =>
useToolInputStreaming(props),
);

// During streaming, partialHistory state has not been snapshotted yet
expect(result.current.partialHistory).toEqual([]);

// Transition to input-available
props.toolState = "input-available";
props.toolInput = { code: "hello" };
rerender();

// Now partialHistory should be populated (includes the streaming partial + final)
expect(result.current.partialHistory.length).toBeGreaterThan(0);
});

it("final entry has isFinal: true", () => {
const props = createDefaultProps(bridge);
props.toolState = "input-streaming";
props.toolInput = { code: "he" };

const { result, rerender } = renderHook(() =>
useToolInputStreaming(props),
);

// Transition to input-available
props.toolState = "input-available";
props.toolInput = { code: "hello" };
rerender();

const history = result.current.partialHistory;
expect(history.length).toBeGreaterThan(0);
expect(history[history.length - 1].isFinal).toBe(true);
});

it("elapsedFromStart is relative to first entry", () => {
const props = createDefaultProps(bridge);
props.toolState = "input-streaming";
props.toolInput = { code: "h" };

const { result, rerender } = renderHook(() =>
useToolInputStreaming(props),
);

// Advance time, change input
act(() => {
vi.advanceTimersByTime(PARTIAL_INPUT_THROTTLE_MS + 10);
});
props.toolInput = { code: "he" };
rerender();

// Transition to complete
props.toolState = "input-available";
props.toolInput = { code: "hello" };
rerender();

const history = result.current.partialHistory;
expect(history.length).toBeGreaterThanOrEqual(2);
expect(history[0].elapsedFromStart).toBe(0);
// Later entries should have positive elapsed time
for (let i = 1; i < history.length; i++) {
expect(history[i].elapsedFromStart).toBeGreaterThanOrEqual(0);
}
});

it("history resets on toolCallId change", () => {
const props = createDefaultProps(bridge);
props.toolState = "input-streaming";
props.toolInput = { code: "hello" };

const { result, rerender } = renderHook(() =>
useToolInputStreaming(props),
);

// Complete to populate history
props.toolState = "input-available";
rerender();

expect(result.current.partialHistory.length).toBeGreaterThan(0);

// Change toolCallId — history should reset
props.toolCallId = "call-2";
props.toolState = "input-streaming";
props.toolInput = { code: "new" };
rerender();

expect(result.current.partialHistory).toEqual([]);
});

it("history keeps growing past 200 entries", () => {
const props = createDefaultProps(bridge);
props.toolState = "input-streaming";

const { result, rerender } = renderHook(() =>
useToolInputStreaming(props),
);

// Send many distinct partials
const partialCount = 250;
for (let i = 0; i < partialCount; i++) {
act(() => {
vi.advanceTimersByTime(PARTIAL_INPUT_THROTTLE_MS + 10);
});
props.toolInput = { code: `input-${i}` };
rerender();
}

// Complete to snapshot history
props.toolState = "input-available";
props.toolInput = { code: "final" };
rerender();

// Includes all unique partials plus the final full input entry.
expect(result.current.partialHistory.length).toBeGreaterThan(
partialCount,
);
});
});

// ── Replay tests ────────────────────────────────────────────────────────

describe("replay", () => {
function setupWithHistory(b: ReturnType<typeof createMockBridge>) {
const props = createDefaultProps(b);
props.toolState = "input-streaming";
props.toolInput = { code: "h" };

const hookResult = renderHook(() => useToolInputStreaming(props));

// Advance and add another partial
act(() => {
vi.advanceTimersByTime(PARTIAL_INPUT_THROTTLE_MS + 10);
});
props.toolInput = { code: "he" };
hookResult.rerender();

// Complete
props.toolState = "input-available";
props.toolInput = { code: "hello" };
hookResult.rerender();

// Clear mock call history for cleaner assertions
b.sendToolInputPartial.mockClear();
b.sendToolInput.mockClear();

return { hookResult, props };
}

it("replayToPosition(0) calls bridge.sendToolInputPartial", () => {
const { hookResult } = setupWithHistory(bridge);
// The first entry in history is { code: "he" } — the mount-time entry
// { code: "h" } gets cleared by the reset effect that runs in the same cycle.
const firstEntry = hookResult.result.current.partialHistory[0];

act(() => {
hookResult.result.current.replayToPosition(0);
});

expect(bridge.sendToolInputPartial).toHaveBeenCalledWith({
arguments: firstEntry.input,
});
});

it("replayToPosition(lastIndex) calls bridge.sendToolInput for final entry", () => {
const { hookResult } = setupWithHistory(bridge);
const lastIndex = hookResult.result.current.partialHistory.length - 1;

act(() => {
hookResult.result.current.replayToPosition(lastIndex);
});

expect(bridge.sendToolInput).toHaveBeenCalledWith({
arguments: { code: "hello" },
});
});

it("replayToPosition sets isReplayActive = true", () => {
const { hookResult } = setupWithHistory(bridge);

expect(hookResult.result.current.isReplayActive).toBe(false);

act(() => {
hookResult.result.current.replayToPosition(0);
});

expect(hookResult.result.current.isReplayActive).toBe(true);
});

it("exitReplay calls bridge.sendToolInput with final args and sets isReplayActive = false", () => {
const { hookResult } = setupWithHistory(bridge);

act(() => {
hookResult.result.current.replayToPosition(0);
});
expect(hookResult.result.current.isReplayActive).toBe(true);

bridge.sendToolInput.mockClear();

act(() => {
hookResult.result.current.exitReplay();
});

expect(hookResult.result.current.isReplayActive).toBe(false);
expect(bridge.sendToolInput).toHaveBeenCalledWith({
arguments: { code: "hello" },
});
});
});
});
Loading