From 5abe289caaff1f9f993fad7e82e0ecf5b6071354 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Thu, 27 Aug 2026 03:37:03 -0400 Subject: [PATCH] fix(web): seed Edit-and-replay from what replay sends, name each JSON editor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot's fourth pass, one inline plus two suppressed. All three were right. - The modal presented the whole captured frame as editable, but replay does not re-send a frame: it dispatches through the typed InspectorClient methods, whose signatures have no room for `_meta` (a captured `tools/call` carries `_meta.progressToken`) or for any list-request key but `cursor`. So editing `_meta` and pressing Send transmitted something other than what was on screen. `replayableParams` now projects the frame down to what the dispatcher actually reads, the modal seeds from that, and it names what was left out rather than leaving the difference to be discovered. The projection lives beside the dispatch switch because the two are halves of one contract. - `ContentViewer` gains `jsonLabel`, so a read-only JSON editor can say what it holds. Every one of them announced "JSON content", and an expanded Protocol entry has two while the list has many pairs — leaving a screen reader with indistinguishable textboxes and only the visible headings to tell them apart. Protocol entries now name theirs per method and direction, Network entries per request/response body. - `isJsonDocument` became `formatJsonDocument`, returning the formatted text instead of a boolean. The heuristic path parsed the payload to decide and then parsed it again to render; Protocol and Network hand over serialized bodies as untyped text and are not virtualized, so a long history paid twice per payload per render. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01UL9retfvAXRgvi6EWk4SY4 Signed-off-by: cliffhall --- .../ContentViewer/ContentViewer.test.tsx | 23 +++++ .../elements/ContentViewer/ContentViewer.tsx | 89 +++++++++++++++++-- .../ContentViewer/contentViewerUtils.test.ts | 20 ++--- .../ContentViewer/contentViewerUtils.ts | 17 ++-- .../EditReplayModal/EditReplayModal.test.tsx | 38 ++++++++ .../EditReplayModal/EditReplayModal.tsx | 41 ++++++++- .../groups/NetworkEntry/NetworkEntry.tsx | 23 ++++- .../ProtocolEntry/ProtocolEntry.test.tsx | 40 +++++++++ .../groups/ProtocolEntry/ProtocolEntry.tsx | 16 +++- clients/web/src/lib/protocolReplay.test.ts | 74 +++++++++++++++ clients/web/src/lib/protocolReplay.ts | 63 +++++++++++++ 11 files changed, 413 insertions(+), 31 deletions(-) diff --git a/clients/web/src/components/elements/ContentViewer/ContentViewer.test.tsx b/clients/web/src/components/elements/ContentViewer/ContentViewer.test.tsx index 53366fdf..bfd65add 100644 --- a/clients/web/src/components/elements/ContentViewer/ContentViewer.test.tsx +++ b/clients/web/src/components/elements/ContentViewer/ContentViewer.test.tsx @@ -232,6 +232,29 @@ describe("ContentViewer", () => { expect(screen.getByText(/"a": 1/)).toBeInTheDocument(); }); + // An expanded Protocol entry holds two of these and a list holds many pairs, + // so an unnamed editor leaves a screen reader tabbing through textboxes that + // all announce the same thing. + it("names the JSON editor from jsonLabel", () => { + const block: ContentBlock = { type: "text", text: '{"a":1}' }; + renderWithMantine( + , + ); + expect( + screen.getByLabelText(/tools\/call response JSON/), + ).toBeInTheDocument(); + }); + + it("falls back to a generic name when no label is given", () => { + const block: ContentBlock = { type: "text", text: '{"a":1}' }; + renderWithMantine(); + expect(screen.getByLabelText(/JSON content/)).toBeInTheDocument(); + }); + it("keeps the copy overlay above the JSON editor", () => { const block: ContentBlock = { type: "text", text: '{"a":1}' }; renderWithMantine( diff --git a/clients/web/src/components/elements/ContentViewer/ContentViewer.tsx b/clients/web/src/components/elements/ContentViewer/ContentViewer.tsx index 7d7fbfc3..59789532 100644 --- a/clients/web/src/components/elements/ContentViewer/ContentViewer.tsx +++ b/clients/web/src/components/elements/ContentViewer/ContentViewer.tsx @@ -15,8 +15,8 @@ import { ResourceLinkInfo } from "../ResourceLinkInfo/ResourceLinkInfo"; import { formatJson, formatXml, + formatJsonDocument, getMimeKind, - isJsonDocument, isSafeHref, isTextualKind, looksLikeJson, @@ -60,6 +60,19 @@ export interface ContentViewerProps { * one line — don't pass it for multi-line content. */ wrap?: boolean; + /** + * Accessible name for the read-only JSON editor a JSON payload renders in. + * + * Say what the payload *is* whenever a screen can hold more than one: an + * expanded Protocol entry carries both its parameters and its response, and + * a list of them carries many such pairs — so the default leaves a screen + * reader tabbing through textboxes that all announce the same thing, with + * only the visible section headings to tell them apart. + * + * Reaches the JSON branch only; every other renderer here is not a control + * and takes no name. + */ + jsonLabel?: string; } /** @@ -185,12 +198,24 @@ function HighlightedContent({ * It copies the *original* text, not the pretty-printed form, matching every * other branch here. */ -function JsonContent({ text, copyable }: { text: string; copyable: boolean }) { +function JsonContent({ + text, + formatted, + copyable, + label, +}: { + /** The original payload — what the copy button copies. */ + text: string; + /** Its pretty-printed form, already parsed once by whoever decided to use it. */ + formatted: string; + copyable: boolean; + label: string; +}) { return ( ; + // + // The formatted text comes back from the same call that decided, so the + // payload is parsed once rather than once to test and again to render. + const asJson = wrap ? formatJsonDocument(text) : null; + if (asJson !== null) { + return ( + + ); } const displayText = looksLikeJson(text) ? formatJson(text) : text; return ( @@ -262,11 +300,13 @@ function TextualContent({ mimeType, copyable, wrap, + jsonLabel, }: { text: string; mimeType: string | undefined; copyable: boolean; wrap: boolean; + jsonLabel: string; }) { const kind = mimeType ? getMimeKind(mimeType) : "text"; switch (kind) { @@ -278,9 +318,23 @@ function TextualContent({ // line is exactly what that prop is documented not to be used for, so // that caller keeps the plain renderer. return wrap ? ( - + ) : ( - + ); case "xml": return ( @@ -313,7 +367,14 @@ function TextualContent({ ); default: - return ; + return ( + + ); } } @@ -341,11 +402,13 @@ function ResourceContent({ mimeType, copyable, wrap, + jsonLabel, }: { contents: TextResourceContents | BlobResourceContents; mimeType: string; copyable: boolean; wrap: boolean; + jsonLabel: string; }) { if ("text" in contents) { return ( @@ -354,6 +417,7 @@ function ResourceContent({ mimeType={mimeType} copyable={copyable} wrap={wrap} + jsonLabel={jsonLabel} /> ); } @@ -382,6 +446,7 @@ function ResourceContent({ mimeType={mimeType} copyable={copyable} wrap={wrap} + jsonLabel={jsonLabel} /> ); } @@ -394,11 +459,13 @@ function BlockContent({ mimeType, copyable, wrap, + jsonLabel, }: { block: ContentBlock; mimeType: string | undefined; copyable: boolean; wrap: boolean; + jsonLabel: string; }) { switch (block.type) { case "text": @@ -408,6 +475,7 @@ function BlockContent({ mimeType={mimeType} copyable={copyable} wrap={wrap} + jsonLabel={jsonLabel} /> ); case "image": @@ -448,6 +516,7 @@ export function ContentViewer({ copyable = false, mimeType, wrap = true, + jsonLabel = "JSON content", }: ContentViewerProps) { if (contents) { const effective = @@ -458,6 +527,7 @@ export function ContentViewer({ mimeType={effective} copyable={copyable} wrap={wrap} + jsonLabel={jsonLabel} /> ); } @@ -468,6 +538,7 @@ export function ContentViewer({ mimeType={mimeType} copyable={copyable} wrap={wrap} + jsonLabel={jsonLabel} /> ); } diff --git a/clients/web/src/components/elements/ContentViewer/contentViewerUtils.test.ts b/clients/web/src/components/elements/ContentViewer/contentViewerUtils.test.ts index d7bc4340..731c15c4 100644 --- a/clients/web/src/components/elements/ContentViewer/contentViewerUtils.test.ts +++ b/clients/web/src/components/elements/ContentViewer/contentViewerUtils.test.ts @@ -7,7 +7,7 @@ import { getMimeKind, isSafeHref, isTextualKind, - isJsonDocument, + formatJsonDocument, looksLikeJson, PREVIEW_HTML_CSP, tryDecodeBase64ToBytes, @@ -113,26 +113,26 @@ describe("looksLikeJson", () => { }); }); -describe("isJsonDocument", () => { - it("accepts text that really parses as JSON", () => { - expect(isJsonDocument(' {"a":1}')).toBe(true); - expect(isJsonDocument("[1,2]")).toBe(true); +describe("formatJsonDocument", () => { + it("pretty-prints text that really parses as JSON", () => { + expect(formatJsonDocument(' {"a":1}')).toBe('{\n "a": 1\n}'); + expect(formatJsonDocument("[1,2]")).toBe("[\n 1,\n 2\n]"); }); // The stricter half of the heuristic (#2151). Text that merely *starts* like // JSON must not be handed to the JSON editor: a note beginning `{` would then // be presented as a document, in a JSON gutter, framed as malformed. - it("rejects text that only looks like JSON", () => { - expect(isJsonDocument("{ broken")).toBe(false); - expect(isJsonDocument("[unclosed")).toBe(false); - expect(isJsonDocument("plain")).toBe(false); + it("reports text that only looks like JSON as not JSON", () => { + expect(formatJsonDocument("{ broken")).toBeNull(); + expect(formatJsonDocument("[unclosed")).toBeNull(); + expect(formatJsonDocument("plain")).toBeNull(); }); // A bare scalar is valid JSON but never looked like a document, so it stays // on the plain renderer — otherwise ordinary prose that happens to be a // number would open an editor. it("rejects a scalar that never looked like a document", () => { - expect(isJsonDocument("42")).toBe(false); + expect(formatJsonDocument("42")).toBeNull(); }); }); diff --git a/clients/web/src/components/elements/ContentViewer/contentViewerUtils.ts b/clients/web/src/components/elements/ContentViewer/contentViewerUtils.ts index 32a425f9..5d73fe83 100644 --- a/clients/web/src/components/elements/ContentViewer/contentViewerUtils.ts +++ b/clients/web/src/components/elements/ContentViewer/contentViewerUtils.ts @@ -143,7 +143,8 @@ export function looksLikeJson(text: string): boolean { } /** - * Whether untyped text really is a JSON document, not merely shaped like one. + * Pretty-print untyped text if it really is a JSON document, or report that it + * is not. * * {@link looksLikeJson} only tests the first character, which is enough to * decide whether to *try* pretty-printing — a failed parse falls back to the @@ -152,14 +153,18 @@ export function looksLikeJson(text: string): boolean { * JSON document, in a JSON gutter, with the server's plain text framed as * malformed. A declared `application/json` is different — there the server said * what it sent, so its own broken payload is shown as JSON on purpose. + * + * Returns the formatted text rather than a boolean so the caller does not parse + * a second time to render what this already parsed to decide. Protocol and + * Network entries hand their serialized bodies over as untyped text and are not + * virtualized, so a long history pays for every parse on every render. */ -export function isJsonDocument(text: string): boolean { - if (!looksLikeJson(text)) return false; +export function formatJsonDocument(text: string): string | null { + if (!looksLikeJson(text)) return null; try { - JSON.parse(text); - return true; + return JSON.stringify(JSON.parse(text), null, 2); } catch { - return false; + return null; } } diff --git a/clients/web/src/components/groups/EditReplayModal/EditReplayModal.test.tsx b/clients/web/src/components/groups/EditReplayModal/EditReplayModal.test.tsx index 5ba5d9d6..81cde850 100644 --- a/clients/web/src/components/groups/EditReplayModal/EditReplayModal.test.tsx +++ b/clients/web/src/components/groups/EditReplayModal/EditReplayModal.test.tsx @@ -98,6 +98,44 @@ describe("EditReplayModal", () => { expect(screen.getByText(/Numbers must be finite/)).toBeInTheDocument(); }); + // Replay dispatches through the typed client methods, so a param they have no + // room for is dropped. Saying which, rather than seeding it into the editor, + // is what keeps the modal from inviting an edit that Send discards. + it("names the params replay cannot re-send", () => { + renderWithMantine( + , + ); + expect(screen.getByText(/`_meta` is not re-sent/)).toBeInTheDocument(); + }); + + it("says nothing when every param survives", () => { + renderWithMantine( + , + ); + expect(screen.queryByText(/not re-sent/)).toBeNull(); + }); + + it("agrees with its verb for several dropped params", () => { + renderWithMantine( + , + ); + expect( + screen.getByText(/`_meta`, `extra` are not re-sent/), + ).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 784db9b7..be1dd877 100644 --- a/clients/web/src/components/groups/EditReplayModal/EditReplayModal.tsx +++ b/clients/web/src/components/groups/EditReplayModal/EditReplayModal.tsx @@ -1,5 +1,5 @@ import { useState } from "react"; -import { Button, Group, Modal, Stack, Text } from "@mantine/core"; +import { Alert, Button, Group, Modal, Stack, Text } from "@mantine/core"; import { JsonEditor } from "../../elements/JsonEditor/JsonEditor"; import { isJsonObject } from "../../../utils/jsonObjectDraft"; import { isSerializableJson } from "@inspector/core/json/jsonUtils.js"; @@ -8,8 +8,22 @@ export interface EditReplayModalProps { opened: boolean; /** The method being replayed, shown so the editor is unambiguous. */ method: string; - /** The originating entry's params, seeded into the editor. */ + /** + * The originating entry's params, seeded into the editor — already reduced to + * the ones replay will actually read. See {@link droppedParamKeys}. + */ params?: Record; + /** + * Param names the entry carried that replay cannot re-send, so the modal can + * say so rather than leave the difference to be discovered. + * + * Replay dispatches through the typed `InspectorClient` methods rather than + * re-sending the captured frame, and those signatures have no room for + * `_meta` (or for anything but `cursor` on a list request). Seeding the + * editor with them would invite an edit that Send silently discards, which is + * the one thing an inspector must not do. + */ + droppedParamKeys?: string[]; /** * Re-issue the request with these params. `null` means "send no params", * which is a request an empty editor can legitimately express — see @@ -27,6 +41,17 @@ const MethodText = Text.withProps({ const Footer = Group.withProps({ justify: "flex-end", gap: "sm" }); +// Names the params replay cannot carry. `light` and gray rather than a warning +// colour: nothing is wrong and there is nothing for the user to fix — it is a +// property of how replay dispatches. +const DroppedNote = Alert.withProps({ variant: "light", color: "gray" }); + +/** "`a`, `b` are" / "`a` is" — the list, agreeing with its verb. */ +function formatDroppedKeys(keys: string[]): string { + const names = keys.map((key) => `\`${key}\``).join(", "); + return `${names} ${keys.length === 1 ? "is" : "are"}`; +} + /** * What the editor's text currently means: the params to send, or why they * cannot be sent. @@ -75,6 +100,7 @@ function parseParamsDraft( function EditReplayForm({ method, params, + droppedParamKeys, onSend, onClose, }: Omit) { @@ -86,6 +112,15 @@ function EditReplayForm({ return ( {method} + {droppedParamKeys && droppedParamKeys.length > 0 && ( + + + {`Replay re-issues this request through the client's own ${method} + method, which carries only the fields below, so + ${formatDroppedKeys(droppedParamKeys)} not re-sent.`} + + + )} diff --git a/clients/web/src/components/groups/NetworkEntry/NetworkEntry.tsx b/clients/web/src/components/groups/NetworkEntry/NetworkEntry.tsx index 7ef790bc..399cdfe4 100644 --- a/clients/web/src/components/groups/NetworkEntry/NetworkEntry.tsx +++ b/clients/web/src/components/groups/NetworkEntry/NetworkEntry.tsx @@ -371,9 +371,16 @@ const RevealButton = Button.withProps({ function BodyPreview({ body, contentType, + label, }: { body: string; contentType?: string; + /** + * What this body *is* ("Request body", "Response body"), so a JSON payload's + * editor is named. An expanded entry holds both, and the list holds many + * pairs, so unnamed they all announce identically to a screen reader. + */ + label: string; }) { // Reveal state for masked secrets. Hooks run before any early return so the // order stays stable across the too-large / has-secrets branches. The reveal @@ -413,7 +420,13 @@ function BodyPreview({ } if (!hasSecrets) { - return ; + return ( + + ); } const shown = revealed ? body : masked; @@ -432,7 +445,11 @@ function BodyPreview({ {revealed ? "Hide" : "Reveal"} - + ); } @@ -587,6 +604,7 @@ export function NetworkEntry({ key={`${entry.requestHeaders["content-type"] ?? ""}|${entry.requestBody}`} body={entry.requestBody} contentType={entry.requestHeaders["content-type"]} + label="Request body" /> )} @@ -604,6 +622,7 @@ export function NetworkEntry({ key={`${entry.responseHeaders?.["content-type"] ?? ""}|${entry.responseBody}`} body={entry.responseBody} contentType={entry.responseHeaders?.["content-type"]} + label="Response body" /> ) : ( diff --git a/clients/web/src/components/groups/ProtocolEntry/ProtocolEntry.test.tsx b/clients/web/src/components/groups/ProtocolEntry/ProtocolEntry.test.tsx index f86c6940..c7f19756 100644 --- a/clients/web/src/components/groups/ProtocolEntry/ProtocolEntry.test.tsx +++ b/clients/web/src/components/groups/ProtocolEntry/ProtocolEntry.test.tsx @@ -221,6 +221,28 @@ describe("ProtocolEntry", () => { }); }); + // The entry's frame carries `_meta.progressToken`, which replay cannot + // re-send — so it must not appear in the editor as though it could. + it("seeds only the params replay will actually read", async () => { + const user = userEvent.setup(); + const withMeta: MessageEntry = { + ...successEntry, + message: { + ...successEntry.message, + params: { + name: "get_weather", + arguments: { city: "San Francisco" }, + _meta: { progressToken: 2 }, + }, + }, + } as MessageEntry; + renderWithMantine(); + await openEditor(user); + + expect(getAceText()).not.toContain("_meta"); + expect(screen.getByText(/`_meta` is not re-sent/)).toBeInTheDocument(); + }); + it("replays with the edited params", async () => { const user = userEvent.setup(); const onReplay = vi.fn(); @@ -419,6 +441,24 @@ describe("ProtocolEntry", () => { ).toBeInTheDocument(); }); + // Both payloads render the same kind of control, so without distinct names a + // screen reader announces them identically. + it("names the parameters and response editors distinctly", () => { + renderWithMantine( + , + ); + expect( + screen.getByLabelText(/tools\/call request parameters JSON/), + ).toBeInTheDocument(); + expect( + screen.getByLabelText(/tools\/call response JSON/), + ).toBeInTheDocument(); + }); + it("does not render the Parameters section when message has no params", () => { renderWithMantine( setIsEditingReplay(false)} /> @@ -513,6 +520,10 @@ export function ProtocolEntry({ text: serializeMessage(requestParams), }} copyable + // Named, because an expanded entry holds two of these and a + // list holds many pairs — unnamed they all announce the same + // thing, leaving only the visible headings to tell them apart. + jsonLabel={`${method} request parameters JSON`} /> )} @@ -525,6 +536,7 @@ export function ProtocolEntry({ text: serializeMessage(entry.response), }} copyable + jsonLabel={`${method} response JSON`} /> )} diff --git a/clients/web/src/lib/protocolReplay.test.ts b/clients/web/src/lib/protocolReplay.test.ts index a3b90fb2..b60b0174 100644 --- a/clients/web/src/lib/protocolReplay.test.ts +++ b/clients/web/src/lib/protocolReplay.test.ts @@ -4,6 +4,7 @@ import type { MessageEntry } from "@inspector/core/mcp/types.js"; import { messagesToLogEntries, replayProtocolRequest, + replayableParams, type ReplayClient, } from "./protocolReplay"; @@ -61,6 +62,79 @@ describe("messagesToLogEntries", () => { }); }); +// The projection the Edit-and-replay editor seeds from. It is the other half +// of the dispatch switch: replay re-issues through the typed client methods +// rather than re-sending the frame, so anything those signatures have no room +// for is dropped — and an editor that offered it would invite an edit that Send +// silently discards. +describe("replayableParams", () => { + it("keeps the name and arguments of a tool call, and drops _meta", () => { + expect( + replayableParams("tools/call", { + name: "get_weather", + arguments: { city: "Boston" }, + _meta: { progressToken: 2 }, + }), + ).toEqual({ + params: { name: "get_weather", arguments: { city: "Boston" } }, + dropped: ["_meta"], + }); + }); + + it("keeps the name and arguments of a prompt get", () => { + expect( + replayableParams("prompts/get", { name: "greet", arguments: { a: 1 } }), + ).toEqual({ + params: { name: "greet", arguments: { a: 1 } }, + dropped: [], + }); + }); + + it("keeps only the uri of a resource read", () => { + expect( + replayableParams("resources/read", { uri: "file:///a", _meta: {} }), + ).toEqual({ params: { uri: "file:///a" }, dropped: ["_meta"] }); + }); + + it("keeps only a string cursor on a list request", () => { + expect( + replayableParams("tools/list", { cursor: "abc", _meta: {} }), + ).toEqual({ params: { cursor: "abc" }, dropped: ["_meta"] }); + }); + + // The dispatcher ignores a non-string cursor, so keeping it would put a value + // in the editor that changes nothing about what is sent. + it("drops a cursor that is not a string", () => { + expect(replayableParams("resources/list", { cursor: 7 })).toEqual({ + params: undefined, + dropped: ["cursor"], + }); + }); + + it("keeps nothing for ping", () => { + expect(replayableParams("ping", { _meta: {} })).toEqual({ + params: undefined, + dropped: ["_meta"], + }); + }); + + it("reports no params for a request that carried none", () => { + expect(replayableParams("tools/list", undefined)).toEqual({ + params: undefined, + dropped: [], + }); + }); + + // A name only counts as dropped if it was there: an absent `arguments` is not + // something the user is being told they will lose. + it("does not report absent names as dropped", () => { + expect(replayableParams("tools/call", { name: "echo" })).toEqual({ + params: { name: "echo" }, + dropped: [], + }); + }); +}); + describe("replayProtocolRequest", () => { const tool: Tool = { name: "echo", inputSchema: { type: "object" } }; diff --git a/clients/web/src/lib/protocolReplay.ts b/clients/web/src/lib/protocolReplay.ts index 609ea430..5bee9026 100644 --- a/clients/web/src/lib/protocolReplay.ts +++ b/clients/web/src/lib/protocolReplay.ts @@ -70,6 +70,69 @@ export type ReplayClient = Pick< */ export type ReplayParamsOverride = Record | null | undefined; +/** + * The params of a replayed request that `replayProtocolRequest` actually reads, + * per method. + * + * This is the other half of the switch below, and the two must move together. + * Replay does not re-send a captured frame verbatim — it dispatches through the + * typed `InspectorClient` methods, which take named arguments. Everything those + * signatures have no room for is dropped: `_meta` on any method (a captured + * `tools/call` carries `_meta.progressToken`), and every key of a list request + * other than `cursor`. + * + * That is invisible and harmless for one-click Replay, which re-sends what it + * was given. It is neither once the params are put in front of the user to + * edit (#2151): an editor showing `_meta` invites them to change it, and Send + * would then transmit something other than what they were looking at. So the + * Edit-and-replay modal seeds from *this* rather than from the raw frame, and + * names what it left out. + */ +export function replayableParams( + method: string, + params: Record | undefined, +): { params: Record | undefined; dropped: string[] } { + if (!params) return { params: undefined, dropped: [] }; + + const keep = (...names: string[]): string[] => + names.filter((name) => Object.hasOwn(params, name)); + + let kept: string[]; + switch (method) { + case "tools/call": + case "prompts/get": + kept = keep("name", "arguments"); + break; + case "resources/read": + kept = keep("uri"); + break; + case "tools/list": + case "prompts/list": + case "resources/list": + case "resources/templates/list": + case "tasks/list": + // Only a *string* cursor survives: the dispatcher ignores any other type, + // so keeping it would put a value in the editor that changes nothing. + kept = typeof params.cursor === "string" ? ["cursor"] : []; + break; + default: + // `ping` takes nothing, and an unreplayable method never reaches here. + kept = []; + break; + } + + const dropped = Object.keys(params).filter((name) => !kept.includes(name)); + // `fromEntries`, never an assignment loop: an argument legitimately named + // `__proto__` would otherwise reach the prototype setter. + return { + params: + kept.length > 0 + ? Object.fromEntries(kept.map((name) => [name, params[name]])) + : undefined, + dropped, + }; +} + export async function replayProtocolRequest( client: ReplayClient, method: string,