test: add a duplicate-tool-names test server for the #1957 repro

Nothing in the repo could serve the shape this fix is about: every preset
registers a unique name, and the SDK's `registerTool` rejects a repeat. So the
only reproduction was a unit test — there was no way to smoke the real app
against a server that returns duplicates.

Adds `ServerConfig.duplicateToolNames` (a list of registered tool names) to the
composable test server, plumbed through the JSON config, and a showcase config
`duplicate-tool-names-http.json`. The named tools are emitted a second time in
`tools/list` with the same `name` and a "(duplicate)" title.

The repeats are appended after the whole list rather than placed beside their
twin. That is both the realistic shape (two tool sources concatenated) and what
makes the defect observable: React matches a leading run of same-key children
first, so a head-adjacent duplicate happens to line up and the bug hides. Only
a separated pair collides in the keyed map and orphans a row — verified both
ways against the prod bundle.

The `tools/list` override is shared with the existing pagination handler (which
already hand-builds the list); with no `maxPageSize` it returns a single
unbounded page. Duplication happens before pagination, so a duplicated pair can
straddle a page boundary the way a real server's would.

Also adds a ToolControls test for this appended ordering, distinct from the
reporter's interleaved fixture, and documents the config in the README showcase
table.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SW1p8E2uiyyLx4RKwrwSrt
This commit is contained in:
cliffhall 2026-08-10 20:22:47 -04:00
commit 4340283f70

View file

@ -258,6 +258,44 @@ describe("ToolControls", () => {
}
});
// The shape `test-servers/configs/duplicate-tool-names-http.json` serves: the
// repeats are appended after the whole list rather than sitting beside their
// twin. Distinct from the fixture above and worth its own case — React
// matches a leading run of same-key children first, so where the duplicates
// sit decides which row gets orphaned (#1957).
const appendedDuplicateTools: Tool[] = [
{ name: "get_weather", inputSchema: { type: "object" } },
{ name: "get_temp", inputSchema: { type: "object" } },
{ name: "echo", inputSchema: { type: "object" } },
{ name: "add", inputSchema: { type: "object" } },
{
name: "get_weather",
title: "get_weather (duplicate)",
inputSchema: { type: "object" },
},
{
name: "echo",
title: "echo (duplicate)",
inputSchema: { type: "object" },
},
];
it("removes every non-matching row when repeats are appended (#1957)", async () => {
const user = userEvent.setup();
renderWithMantine(
<ControlledToolControls tools={appendedDuplicateTools} />,
);
await user.type(screen.getByPlaceholderText("Search tools..."), "get");
expect(screen.getByText("get_temp")).toBeInTheDocument();
expect(screen.getByText("get_weather (duplicate)")).toBeInTheDocument();
expect(screen.queryByText("add")).not.toBeInTheDocument();
// The row the collision orphaned on the broken build.
expect(screen.queryByText("echo")).not.toBeInTheDocument();
expect(screen.queryByText("echo (duplicate)")).not.toBeInTheDocument();
});
it("removes every non-matching excluded row when names repeat (#1957)", async () => {
const user = userEvent.setup();
const duplicateExcluded = [