diff --git a/clients/web/src/components/groups/EditReplayModal/EditReplayModal.test.tsx b/clients/web/src/components/groups/EditReplayModal/EditReplayModal.test.tsx
index 81cde850..ad49c770 100644
--- a/clients/web/src/components/groups/EditReplayModal/EditReplayModal.test.tsx
+++ b/clients/web/src/components/groups/EditReplayModal/EditReplayModal.test.tsx
@@ -136,6 +136,48 @@ describe("EditReplayModal", () => {
).toBeInTheDocument();
});
+ // Seeding from the projection is only half of it: a key the dispatcher does
+ // not read can be *added* to the draft, and Send would then discard an edit
+ // the user was looking at.
+ it("disables Send for a key this method's dispatch would not read", async () => {
+ renderWithMantine(
+ ,
+ );
+ await setAceText('{"cursor":"abc","_meta":{"progressToken":1}}');
+ expect(screen.getByRole("button", { name: "Send" })).toBeDisabled();
+ expect(
+ screen.getByText(/`_meta` is not carried by tools\/list/),
+ ).toBeInTheDocument();
+ });
+
+ it("re-enables Send once the unsupported key is removed", async () => {
+ renderWithMantine(
+ ,
+ );
+ await setAceText('{"cursor":"abc","_meta":{}}');
+ expect(screen.getByRole("button", { name: "Send" })).toBeDisabled();
+
+ await setAceText('{"cursor":"abc"}');
+ expect(screen.getByRole("button", { name: "Send" })).not.toBeDisabled();
+ expect(screen.queryByText(/not carried by/)).toBeNull();
+ });
+
+ // The check is per method, not a fixed key list: `arguments` is read by a
+ // tool call and by nothing on a list request.
+ it("judges a key against the method it would be sent to", async () => {
+ const { rerender } = renderWithMantine(
+ ,
+ );
+ await setAceText('{"name":"echo","arguments":{"a":1}}');
+ expect(screen.getByRole("button", { name: "Send" })).not.toBeDisabled();
+
+ rerender();
+ expect(screen.getByRole("button", { name: "Send" })).toBeDisabled();
+ expect(
+ screen.getByText(/`name`, `arguments` are not carried by tools\/list/),
+ ).toBeInTheDocument();
+ });
+
it("closes without sending when cancelled", async () => {
const user = userEvent.setup();
const onSend = vi.fn();
diff --git a/clients/web/src/components/groups/EditReplayModal/EditReplayModal.tsx b/clients/web/src/components/groups/EditReplayModal/EditReplayModal.tsx
index be1dd877..d8fcfb83 100644
--- a/clients/web/src/components/groups/EditReplayModal/EditReplayModal.tsx
+++ b/clients/web/src/components/groups/EditReplayModal/EditReplayModal.tsx
@@ -2,6 +2,7 @@ import { useState } from "react";
import { Alert, Button, Group, Modal, Stack, Text } from "@mantine/core";
import { JsonEditor } from "../../elements/JsonEditor/JsonEditor";
import { isJsonObject } from "../../../utils/jsonObjectDraft";
+import { replayableParams } from "../../../lib/protocolReplay";
import { isSerializableJson } from "@inspector/core/json/jsonUtils.js";
export interface EditReplayModalProps {
@@ -60,10 +61,12 @@ function formatDroppedKeys(keys: string[]): string {
* genuinely carries none, and an empty editor wearing a red error would read as
* broken. A non-object is reported separately from text that is not JSON at
* all: JSON-RPC `params` must be a structured value, so `42` is a shape that
- * cannot be sent however finished it is. A number that overflows is a third
- * case, and the subtlest — see the note on that branch.
+ * cannot be sent however finished it is. A number that overflows, and a key
+ * this method's dispatch does not read, are the two subtle cases — see the
+ * notes on those branches.
*/
function parseParamsDraft(
+ method: string,
text: string,
):
| { ok: true; value: Record | null }
@@ -89,6 +92,20 @@ function parseParamsDraft(
"Numbers must be finite — a value like `1e400` overflows and would be sent as null",
};
}
+ // Seeding from the projection is only half of it: a key the dispatcher does
+ // not read can also be *added* to the draft, and Send would then discard an
+ // edit the user was looking at. Checked with the same function the seed is
+ // built from, so the editor and the dispatcher cannot disagree about which
+ // keys survive.
+ const { dropped } = replayableParams(method, parsed);
+ if (dropped.length > 0) {
+ return {
+ ok: false,
+ error: `${formatDroppedKeys(dropped)} not carried by ${method} — remove ${
+ dropped.length === 1 ? "it" : "them"
+ } so this matches what is sent`,
+ };
+ }
return { ok: true, value: parsed };
}
@@ -107,7 +124,7 @@ function EditReplayForm({
const [draft, setDraft] = useState(() =>
params === undefined ? "" : JSON.stringify(params, null, 2),
);
- const parsed = parseParamsDraft(draft);
+ const parsed = parseParamsDraft(method, draft);
return (