diff --git a/clients/web/src/components/screens/AppsScreen/AppsScreen.test.tsx b/clients/web/src/components/screens/AppsScreen/AppsScreen.test.tsx index 1525d508..78502392 100644 --- a/clients/web/src/components/screens/AppsScreen/AppsScreen.test.tsx +++ b/clients/web/src/components/screens/AppsScreen/AppsScreen.test.tsx @@ -10,6 +10,7 @@ import { screen, within, } from "../../../test/renderWithMantine"; +import { setAceTextByLabel } from "../../../test/aceEditor"; import { AppsScreen, type AppsScreenProps, @@ -320,6 +321,35 @@ describe("AppsScreen", () => { expect(screen.getByTitle("Weather Widget")).toBeInTheDocument(); }); + // The Apps counterpart of the same wire claim (#2171): an App's arguments are + // a `tools/call` like any other, so a refused draft must not open the app. + it("dispatches nothing for a draft the schema would retype", async () => { + const user = userEvent.setup(); + const onOpenApp = vi.fn(); + const numericApp: Tool = { + name: "chart", + title: "Chart Widget", + inputSchema: { + type: "object", + properties: { count: { type: "number" } }, + }, + _meta: { ui: { resourceUri: "ui://apps/chart" } }, + }; + renderWithMantine( + , + ); + await user.click(screen.getByText("Chart Widget")); + await user.click(screen.getByLabelText("Edit as JSON")); + await setAceTextByLabel(/Arguments JSON/, '{"count":"01"}'); + await user.click(screen.getByRole("button", { name: /Open App/ })); + + expect(onOpenApp).not.toHaveBeenCalled(); + + await setAceTextByLabel(/Arguments JSON/, '{"count":1}'); + await user.click(screen.getByRole("button", { name: /Open App/ })); + expect(onOpenApp).toHaveBeenCalledWith("chart", { count: 1 }); + }); + it("seeds schema defaults so untouched fields are sent on Open App", async () => { const user = userEvent.setup(); const onOpenApp = vi.fn(); diff --git a/clients/web/src/components/screens/ToolsScreen/ToolsScreen.test.tsx b/clients/web/src/components/screens/ToolsScreen/ToolsScreen.test.tsx index 225b80fc..608e84c9 100644 --- a/clients/web/src/components/screens/ToolsScreen/ToolsScreen.test.tsx +++ b/clients/web/src/components/screens/ToolsScreen/ToolsScreen.test.tsx @@ -3,6 +3,7 @@ import { describe, it, expect, vi } from "vitest"; import userEvent from "@testing-library/user-event"; import type { Tool } from "@modelcontextprotocol/client"; import { renderWithMantine, screen } from "../../../test/renderWithMantine"; +import { setAceTextByLabel } from "../../../test/aceEditor"; import { noopPagination } from "../../../test/fixtures/pagination"; import { ToolsScreen, @@ -157,6 +158,43 @@ describe("ToolsScreen", () => { ); }); + // #2171's acceptance asks for a test that pins the WIRE rather than the form + // state. Under the refusal answer the wire claim is that nothing is sent, and + // `onCallTool` is where a dispatch would begin — so a screen that flagged the + // draft but still fired the callback would satisfy every other test here. + // + // The plain and "Run as task" paths need no separate case: the split between + // `callTool` and `callToolStream` happens in `App.tsx`, downstream of this + // callback, so a gate that stops the callback stops both. + it("dispatches nothing for a draft the schema would retype", async () => { + const user = userEvent.setup(); + const onCallTool = vi.fn(); + const numeric: Tool[] = [ + { + name: "add", + inputSchema: { + type: "object", + properties: { count: { type: "number" } }, + }, + }, + ]; + renderWithMantine( + , + ); + await user.click(screen.getByText("add")); + await user.click(screen.getByLabelText("Edit as JSON")); + await setAceTextByLabel(/Arguments JSON/, '{"count":"01"}'); + await user.click(screen.getByRole("button", { name: /Execute/ })); + + expect(onCallTool).not.toHaveBeenCalled(); + + // And it is the draft that blocks it, not the screen: rewritten with the + // declared type, the very same click dispatches. + await setAceTextByLabel(/Arguments JSON/, '{"count":1}'); + await user.click(screen.getByRole("button", { name: /Execute/ })); + expect(onCallTool).toHaveBeenCalledWith("add", { count: 1 }, false); + }); + it("filters the sidebar list as the search text changes", async () => { const user = userEvent.setup(); renderWithMantine();