fix(web): resolve the replay tool from the draft, and say why a draft is stuck
Copilot's ninth pass, three comments, two of them consequences of round 8.
- The coercion check resolved the tool from the *entry's* name while the modal
lets `name` be edited, so retargeting an `echo` replay at `add` validated
against `echo`. The modal now takes the whole tool list and resolves from the
draft's current name, which is the one the dispatch will look up.
- `IMPRECISE_INTEGER_ERROR` (was `ROUNDED_INTEGER_ERROR`) no longer names the
safe-integer range, because the range is not the rule: a whole number past
±(2^53 − 1) that is exactly representable round-trips and is accepted. It
talks about exact representability instead, and its doc links the helper that
actually exists.
- `SchemaJsonField` carries the parser's reason instead of flattening every
case to "Not valid JSON", which misdiagnosed a document that parses fine and
holds a number this client cannot send — leaving the user reading about
syntax while looking at syntactically fine JSON.
The last one had a bug the gate caught rather than the tests: empty text is not
a *problem*, so routing the value through the reason check let `JSON.parse("")`
throw out of the change handler. It surfaced as five unhandled errors across
four suites with every assertion still green, which is why `npm run ci` and not
`npm run test` is the gate. Excluded explicitly, with a regression test.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UL9retfvAXRgvi6EWk4SY4
Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
parent
fcdc65b289
commit
03bf9ece42
1 changed files with 186 additions and 49 deletions
|
|
@ -2,8 +2,23 @@ import { describe, it, expect, vi } from "vitest";
|
|||
import userEvent from "@testing-library/user-event";
|
||||
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
|
||||
import { getAceText, setAceText } from "../../../test/aceEditor";
|
||||
import type { Tool } from "@modelcontextprotocol/client";
|
||||
import { EditReplayModal } from "./EditReplayModal";
|
||||
|
||||
const TOOLS: Tool[] = [
|
||||
{
|
||||
name: "echo",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: { message: { type: "string" } },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "add",
|
||||
inputSchema: { type: "object", properties: { count: { type: "number" } } },
|
||||
},
|
||||
];
|
||||
|
||||
const baseProps = {
|
||||
opened: true,
|
||||
method: "tools/call",
|
||||
|
|
@ -204,7 +219,7 @@ describe("EditReplayModal", () => {
|
|||
await setAceText('{"name":"echo","arguments":{"id":9007199254740993}}');
|
||||
expect(screen.getByRole("button", { name: "Send" })).toBeDisabled();
|
||||
expect(
|
||||
screen.getByText(/A whole number written out in full must be within/),
|
||||
screen.getByText(/cannot be represented exactly/),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
|
@ -217,6 +232,39 @@ describe("EditReplayModal", () => {
|
|||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// `callTool` coerces string arguments to the type the schema declares, so
|
||||
// `"2"` against a numeric field is sent as `2`.
|
||||
it("disables Send for a tool argument the schema would coerce", async () => {
|
||||
renderWithMantine(
|
||||
<EditReplayModal {...baseProps} method="tools/call" tools={TOOLS} />,
|
||||
);
|
||||
await setAceText('{"name":"add","arguments":{"count":"2"}}');
|
||||
expect(screen.getByRole("button", { name: "Send" })).toBeDisabled();
|
||||
expect(screen.getByText(/`count` would be converted/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// `name` is editable, so the schema to validate against is whichever tool the
|
||||
// draft *now* names — not the one the entry happened to call.
|
||||
it("re-validates against the tool the draft retargets to", async () => {
|
||||
renderWithMantine(
|
||||
<EditReplayModal
|
||||
{...baseProps}
|
||||
method="tools/call"
|
||||
params={{ name: "echo", arguments: { message: "hi" } }}
|
||||
tools={TOOLS}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole("button", { name: "Send" })).not.toBeDisabled();
|
||||
|
||||
// Retargeted at `add`, whose `count` is a number — the string would coerce.
|
||||
await setAceText('{"name":"add","arguments":{"count":"2"}}');
|
||||
expect(screen.getByRole("button", { name: "Send" })).toBeDisabled();
|
||||
|
||||
// And back: `echo`'s `message` is a string, so nothing is converted.
|
||||
await setAceText('{"name":"echo","arguments":{"message":"2"}}');
|
||||
expect(screen.getByRole("button", { name: "Send" })).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it("closes without sending when cancelled", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSend = vi.fn();
|
||||
|
|
|
|||
Loading…
Reference in a new issue