Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
ca2d67c
fix(web): don't show a permanent PENDING badge on history notificatio…
cliffhall Jun 6, 2026
55c7f9e
fix(core): track outgoing responses so server→client requests resolve…
cliffhall Jun 6, 2026
c6b499f
feat(web): wire up History Pin and Replay (#1438)
cliffhall Jun 6, 2026
e20b938
feat(web): make history Replay support list/discovery requests
cliffhall Jun 6, 2026
6e85ef9
feat(web): replay tasks/list; reorder + conditionally show History bu…
cliffhall Jun 6, 2026
a154aba
feat(web): add client↔server direction badge to History and Network (…
cliffhall Jun 6, 2026
f90f744
style(web): use purple (not yellow) for the client ← server direction…
cliffhall Jun 6, 2026
39b9b61
feat(web): make History Pinned/History sections collapsible toggles
cliffhall Jun 6, 2026
eda5322
feat(web): replace Expand/Collapse text button with an icon toggle
cliffhall Jun 7, 2026
2a5ad32
feat(web): pin icon toggle on HistoryEntry, moved to the right
cliffhall Jun 7, 2026
563ce79
feat(web): per-section Clear/Export on the Pinned and History headers
cliffhall Jun 7, 2026
9606038
feat(web): Filter by Message Direction on the History controls
cliffhall Jun 7, 2026
f899691
style(web): section Clear/Export as subtle buttons, not link anchors
cliffhall Jun 7, 2026
3ac270b
style(web): color the direction filter labels to match the badge
cliffhall Jun 7, 2026
96471f8
feat(web): track outgoing client notifications in history too
cliffhall Jun 7, 2026
626aa72
feat(web): Filter by Message Direction on the Network screen
cliffhall Jun 7, 2026
0424f48
revert(web): drop message direction from the Network tab
cliffhall Jun 7, 2026
fc04a69
fix(web): a lone History section isn't a collapsible accordion
cliffhall Jun 7, 2026
2c675c3
style(web): put section Clear/Export on the header pleat itself
cliffhall Jun 7, 2026
a11e6b2
style(web): entry expand toggle uses the same icons as ListToggle
cliffhall Jun 7, 2026
c1ca557
fix(web): panel Clear keeps pinned entries (history-only)
cliffhall Jun 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
273 changes: 271 additions & 2 deletions clients/web/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ vi.mock("@mantine/notifications", () => ({
notifications: notificationsMock,
}));

// Shared spy for MessageLogState.clearMessages so a test can inspect the
// predicate the panel Clear passes (keep-pinned vs clear-all).
const { messageLogClear } = vi.hoisted(() => ({ messageLogClear: vi.fn() }));

// App is a wiring component: it owns session-scoped UI state (the per-call
// result panels and the optimistic log level) and resets it when the active
// InspectorClient emits `disconnect`. These tests exercise that reset in
Expand Down Expand Up @@ -52,6 +56,14 @@ vi.mock("@inspector/core/mcp/index.js", () => {
.fn()
.mockResolvedValue({ result: { contents: [] }, timestamp: 1 });
setLoggingLevel = vi.fn().mockResolvedValue(undefined);
listTools = vi.fn().mockResolvedValue({ tools: [] });
listPrompts = vi.fn().mockResolvedValue({ prompts: [] });
listResources = vi.fn().mockResolvedValue({ resources: [] });
listResourceTemplates = vi
.fn()
.mockResolvedValue({ resourceTemplates: [] });
listRequestorTasks = vi.fn().mockResolvedValue({ tasks: [] });
ping = vi.fn().mockResolvedValue(undefined);
getOAuthState = vi.fn().mockReturnValue(undefined);
getPendingSamples = vi.fn().mockReturnValue([]);
getPendingElicitations = vi.fn().mockReturnValue([]);
Expand Down Expand Up @@ -104,7 +116,7 @@ vi.mock("@inspector/core/mcp/state/resourceSubscriptionsState.js", () => ({
}));
vi.mock("@inspector/core/mcp/state/messageLogState.js", () => ({
MessageLogState: vi.fn(function () {
return { destroy: vi.fn() };
return { destroy: vi.fn(), clearMessages: messageLogClear };
}),
}));
vi.mock("@inspector/core/mcp/state/fetchRequestLogState.js", () => ({
Expand Down Expand Up @@ -250,6 +262,10 @@ vi.mock("./components/views/InspectorView/InspectorView", () => ({
onClearCompletedTasks: () => void;
onRefreshTasks: () => void;
onServerSettings: (id: string) => void;
onClearHistory: () => void;
onReplayHistory: (id: string) => void;
onTogglePinHistory: (id: string) => void;
pinnedHistoryIds?: Set<string>;
}) => (
<div>
<span data-testid="tool-status">
Expand Down Expand Up @@ -347,16 +363,30 @@ vi.mock("./components/views/InspectorView/InspectorView", () => ({
</button>
<button onClick={() => props.onSetLogLevel("debug")}>set-level</button>
<button onClick={() => props.onServerSettings("A")}>open-settings</button>
<span data-testid="pinned-history">
{Array.from(props.pinnedHistoryIds ?? []).join(",")}
</span>
<button onClick={() => props.onTogglePinHistory("hist-1")}>
toggle-pin
</button>
<button onClick={() => props.onReplayHistory("hist-1")}>
replay-history
</button>
<button onClick={() => props.onClearHistory()}>clear-history</button>
</div>
),
}));

import App from "./App";
import * as McpIndex from "@inspector/core/mcp/index.js";
import { useManagedRequestorTasks } from "@inspector/core/react/useManagedRequestorTasks.js";
import { useMessageLog } from "@inspector/core/react/useMessageLog.js";
import { useInspectorClient } from "@inspector/core/react/useInspectorClient.js";
import { useSettingsDraft } from "@inspector/core/react/useSettingsDraft.js";
import type { InspectorServerSettings } from "@inspector/core/mcp/types.js";
import type {
InspectorServerSettings,
MessageEntry,
} from "@inspector/core/mcp/types.js";

// Default useInspectorClient return — capabilities empty (no task tool calls).
// Individual tests override via vi.mocked(...).mockReturnValue(...).
Expand Down Expand Up @@ -1007,3 +1037,242 @@ describe("App roots live-apply on settings-dialog close", () => {
expect(client.setRoots).not.toHaveBeenCalled();
});
});

describe("App history pin/replay", () => {
const replayableEntry: MessageEntry = {
id: "hist-1",
timestamp: new Date("2026-06-06T22:00:00Z"),
direction: "request",
message: {
jsonrpc: "2.0",
id: 1,
method: "tools/call",
params: { name: "get_acts", arguments: { city: "SF" } },
},
};

beforeEach(() => {
clientInstances.length = 0;
notificationsMock.show.mockClear();
vi.mocked(useInspectorClient).mockReturnValue(DEFAULT_USE_INSPECTOR_CLIENT);
vi.mocked(useMessageLog).mockReturnValue({ messages: [] });
});

it("toggles a pinned history id and passes the set down to the view", async () => {
const user = userEvent.setup();
renderWithMantine(<App />);
await user.click(screen.getByText("connect"));
await waitFor(() => expect(clientInstances).toHaveLength(1));

expect(screen.getByTestId("pinned-history")).toHaveTextContent("");
await user.click(screen.getByText("toggle-pin"));
expect(screen.getByTestId("pinned-history")).toHaveTextContent("hist-1");
await user.click(screen.getByText("toggle-pin"));
expect(screen.getByTestId("pinned-history")).toHaveTextContent("");
});

it("panel Clear removes unpinned history but keeps pinned entries", async () => {
const user = userEvent.setup();
renderWithMantine(<App />);
await user.click(screen.getByText("connect"));
await waitFor(() => expect(clientInstances).toHaveLength(1));

await user.click(screen.getByText("toggle-pin"));
expect(screen.getByTestId("pinned-history")).toHaveTextContent("hist-1");

messageLogClear.mockClear();
await user.click(screen.getByText("clear-history"));

expect(messageLogClear).toHaveBeenCalledTimes(1);
const predicate = messageLogClear.mock.calls[0][0] as (m: {
id: string;
}) => boolean;
// The predicate is "should remove?" — pinned survives (false), unpinned is
// removed (true).
expect(predicate({ id: "hist-1" })).toBe(false);
expect(predicate({ id: "other" })).toBe(true);
});

it("replays a tools/call entry by re-issuing callTool with the recorded args", async () => {
vi.mocked(useMessageLog).mockReturnValue({ messages: [replayableEntry] });
const user = userEvent.setup();
renderWithMantine(<App />);
await user.click(screen.getByText("connect"));
await waitFor(() => expect(clientInstances).toHaveLength(1));

await user.click(screen.getByText("replay-history"));

const client = clientInstances[0] as unknown as {
callTool: ReturnType<typeof vi.fn>;
};
await waitFor(() => expect(client.callTool).toHaveBeenCalledTimes(1));
expect(client.callTool.mock.calls[0][1]).toEqual({ city: "SF" });
});

it("replays a tools/list entry via listTools, preserving the cursor", async () => {
vi.mocked(useMessageLog).mockReturnValue({
messages: [
{
...replayableEntry,
message: {
jsonrpc: "2.0",
id: 6,
method: "tools/list",
params: { cursor: "page-2" },
},
},
],
});
const user = userEvent.setup();
renderWithMantine(<App />);
await user.click(screen.getByText("connect"));
await waitFor(() => expect(clientInstances).toHaveLength(1));

await user.click(screen.getByText("replay-history"));

const client = clientInstances[0] as unknown as {
listTools: ReturnType<typeof vi.fn>;
};
await waitFor(() =>
expect(client.listTools).toHaveBeenCalledWith("page-2"),
);
});

it("replays a tasks/list entry via listRequestorTasks", async () => {
vi.mocked(useMessageLog).mockReturnValue({
messages: [
{
...replayableEntry,
message: { jsonrpc: "2.0", id: 7, method: "tasks/list" },
},
],
});
const user = userEvent.setup();
renderWithMantine(<App />);
await user.click(screen.getByText("connect"));
await waitFor(() => expect(clientInstances).toHaveLength(1));

await user.click(screen.getByText("replay-history"));

const client = clientInstances[0] as unknown as {
listRequestorTasks: ReturnType<typeof vi.fn>;
};
await waitFor(() =>
expect(client.listRequestorTasks).toHaveBeenCalledTimes(1),
);
});

it("toasts when replaying an unsupported method", async () => {
vi.mocked(useMessageLog).mockReturnValue({
messages: [
{
...replayableEntry,
message: {
jsonrpc: "2.0",
id: 2,
method: "logging/setLevel",
params: { level: "debug" },
},
},
],
});
const user = userEvent.setup();
renderWithMantine(<App />);
await user.click(screen.getByText("connect"));
await waitFor(() => expect(clientInstances).toHaveLength(1));

await user.click(screen.getByText("replay-history"));

await waitFor(() =>
expect(notificationsMock.show).toHaveBeenCalledWith(
expect.objectContaining({ title: "Can't replay", color: "yellow" }),
),
);
});

it("replays a prompts/get entry via getPrompt", async () => {
vi.mocked(useMessageLog).mockReturnValue({
messages: [
{
...replayableEntry,
message: {
jsonrpc: "2.0",
id: 3,
method: "prompts/get",
params: { name: "greet", arguments: { who: "x" } },
},
},
],
});
const user = userEvent.setup();
renderWithMantine(<App />);
await user.click(screen.getByText("connect"));
await waitFor(() => expect(clientInstances).toHaveLength(1));

await user.click(screen.getByText("replay-history"));

const client = clientInstances[0] as unknown as {
getPrompt: ReturnType<typeof vi.fn>;
};
await waitFor(() =>
expect(client.getPrompt).toHaveBeenCalledWith("greet", { who: "x" }),
);
});

it("replays a resources/read entry via readResource", async () => {
vi.mocked(useMessageLog).mockReturnValue({
messages: [
{
...replayableEntry,
message: {
jsonrpc: "2.0",
id: 4,
method: "resources/read",
params: { uri: "res://x" },
},
},
],
});
const user = userEvent.setup();
renderWithMantine(<App />);
await user.click(screen.getByText("connect"));
await waitFor(() => expect(clientInstances).toHaveLength(1));

await user.click(screen.getByText("replay-history"));

const client = clientInstances[0] as unknown as {
readResource: ReturnType<typeof vi.fn>;
};
await waitFor(() =>
expect(client.readResource).toHaveBeenCalledWith("res://x"),
);
});

it("toasts when the replayed tool is no longer available", async () => {
vi.mocked(useMessageLog).mockReturnValue({
messages: [
{
...replayableEntry,
message: {
jsonrpc: "2.0",
id: 5,
method: "tools/call",
params: { name: "gone", arguments: {} },
},
},
],
});
const user = userEvent.setup();
renderWithMantine(<App />);
await user.click(screen.getByText("connect"));
await waitFor(() => expect(clientInstances).toHaveLength(1));

await user.click(screen.getByText("replay-history"));

await waitFor(() =>
expect(notificationsMock.show).toHaveBeenCalledWith(
expect.objectContaining({ title: "Can't replay", color: "yellow" }),
),
);
});
});
Loading
Loading