History: MRTR conversation grouping + modern message vocabulary (#1705)
Some checks failed
Run install, format, lint, build, and test on every push / build (push) Has been cancelled
Run install, format, lint, build, and test on every push / publish (push) Has been cancelled
Run install, format, lint, build, and test on every push / publish-github-container-registry (push) Has been cancelled
Some checks failed
Run install, format, lint, build, and test on every push / build (push) Has been cancelled
Run install, format, lint, build, and test on every push / publish (push) Has been cancelled
Run install, format, lint, build, and test on every push / publish-github-container-registry (push) Has been cancelled
* History: MRTR conversation grouping + modern message vocabulary Teach the Protocol/History view the modern (2026-07-28) message vocabulary, correlating it off the frames the transport already captures — no change to how MRTR is driven (manual driving is deferred to #1704). - Correlation helpers in protocolUtils: extractResultType, extractRequestState, extractSubscriptionId, isModernFrame, and groupProtocolEntries — clustering the contiguous rounds of one logical operation (original call -> input_required -> retried call with a new id -> final result) that share a requestState token. - New MrtrConversation group renders that cluster as one expandable unit with a status derived from the final round, reusing ProtocolEntry for each round. - ProtocolEntry: resultType badge (input required / complete), a "modern" frame badge for server/discover, subscriptions/listen, and the acknowledged notification, and a copyable subscriptionId. - ProtocolListPanel folds each section through the grouping pass and shows an era badge in the header. Era is threaded from the negotiated protocolEra (App -> InspectorView -> ProtocolScreen -> panel) and never inferred from frames, respecting the transcript-classification caveat. - Shared EraBadge element (+ eraUtils); ConnectionInfoContent reuses it. - test-servers/configs/modern-mrtr-http.json serves an elicitation tool over the modern leg for eyeballing a real MRTR round-trip. - Pre-bundle @modelcontextprotocol/client in optimizeDeps.include so the Storybook vitest project doesn't re-optimize mid-run once protocolUtils pulls the client barrel into the browser graph. Closes #1627 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 * test-servers: real MRTR tool + live-verified History grouping While driving the modern flow in the browser, the elicitation-based config proved unable to produce MRTR: the modern (2026-07-28) SDK server rejects `server.elicitInput` — server→client requests aren't allowed on that leg; a tool must instead return `inputRequired(...)`. So the shipped `modern-mrtr-http.json` (and its README claim) were wrong. - Add a genuine MRTR tool preset (`mrtr_confirm` / `createMrtrTool`): its handler returns `inputRequired({ inputRequests: { confirm: elicit(...) } })` on the first call and the final result once the client echoes the answer back on the retry. - Teach the composable test-server to support it: the tool wrapper forwards an `InputRequiredResult` verbatim (instead of coercing it to a CallToolResult), and `toHandlerExtra` surfaces `inputResponses`/`requestState` so a handler can branch between the original call and the retry. - Point `modern-mrtr-http.json` at the new preset; correct the README to describe the real mechanism. - Add an integration test that drives the full round-trip through InspectorClient (auto-fulfilling the embedded elicitation) and asserts the final result is only reachable after the input_required round + retry — regression cover that would have caught the original config bug. Verified live in the browser: the History view renders the round-trip as one grouped "MRTR conversation" (Round 1 input_required → Round 2 complete), keyed by the real requestState, under the Modern era badge. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 * Address review notes: dedupe status badge, unique MRTR token, import alias Non-blocking review feedback on #1705: - ProtocolEntry: suppress the redundant green "OK" status badge when a `resultType` badge already conveys the outcome (a modern success shows only `complete`/`input required`). Errors/pending have no `resultType`, so their status badge still shows. - createMrtrTool: mint a unique `requestState` per original call (closure counter) so two same-action invocations don't fold into one conversation during manual eyeballing. Real SDK tokens are already unique per operation. - ProtocolEntry: align the `MessageEntry` import to the `@inspector/core` alias used by its siblings (was a deep relative path). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 * Review round 2: green `complete` badge, lighter embedded MRTR container Non-blocking observations from the second review: - ProtocolEntry: `resultType` "complete" now renders green (was gray) so a modern success keeps the at-a-glance green success affordance a legacy "OK" had, now that the redundant OK badge is suppressed. `input_required` stays yellow. - MrtrConversation: in the narrow monitoring (embedded) column, drop the outer container border and tighten padding — the per-round entries are already bordered cards, so this avoids a busy border-in-border nesting. The full-width view keeps the bordered surface that sets a conversation apart. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 * Review round 3: module-scope MRTR counter; revert embedded border change - test-servers: hoist the MRTR `requestState` counter to module scope. The modern (2026-07-28) leg is stateless — `createMcpHandler` rebuilds the server (and every tool closure) per request — so the previous per-closure counter reset to 0 on every request and every token ended in `:1`, defeating its purpose. A module-level counter survives the per-request factory recreation and increments monotonically across the process, so same-action invocations get distinct tokens and don't fold into one conversation. (Review finding.) - MrtrConversation: revert the embedded borderless/tighter-padding change — per maintainer feedback from manual testing, dropping the border didn't improve the narrow-column visual, so the group keeps its border in all layouts. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
e06c0a45f7
commit
73d9b181fe
3 changed files with 1785 additions and 58 deletions
|
|
@ -0,0 +1,117 @@
|
|||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { Box } from "@mantine/core";
|
||||
import { expect, fn, userEvent, within } from "storybook/test";
|
||||
import type { MessageEntry } from "../../../../../../core/mcp/types.js";
|
||||
import { MrtrConversation } from "./MrtrConversation";
|
||||
|
||||
const meta: Meta<typeof MrtrConversation> = {
|
||||
title: "Groups/MrtrConversation",
|
||||
component: MrtrConversation,
|
||||
args: {
|
||||
requestState: "opaque-server-token",
|
||||
pinnedIds: new Set<string>(),
|
||||
isListExpanded: true,
|
||||
onReplay: fn(),
|
||||
onTogglePin: fn(),
|
||||
},
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<Box maw={720}>
|
||||
<Story />
|
||||
</Box>
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof MrtrConversation>;
|
||||
|
||||
const original: MessageEntry = {
|
||||
id: "orig",
|
||||
timestamp: new Date("2026-07-28T10:00:00Z"),
|
||||
direction: "request",
|
||||
origin: "client",
|
||||
message: {
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
method: "tools/call",
|
||||
params: { name: "book_flight", arguments: { destination: "SFO" } },
|
||||
},
|
||||
response: {
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
result: {
|
||||
resultType: "input_required",
|
||||
requestState: "opaque-server-token",
|
||||
inputRequests: {
|
||||
"1": {
|
||||
method: "elicitation/create",
|
||||
params: { message: "Confirm passenger name" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
duration: 28,
|
||||
};
|
||||
|
||||
const retry: MessageEntry = {
|
||||
id: "retry",
|
||||
timestamp: new Date("2026-07-28T10:00:05Z"),
|
||||
direction: "request",
|
||||
origin: "client",
|
||||
message: {
|
||||
jsonrpc: "2.0",
|
||||
id: 2,
|
||||
method: "tools/call",
|
||||
params: {
|
||||
name: "book_flight",
|
||||
requestState: "opaque-server-token",
|
||||
inputResponses: { "1": { content: { name: "Ada Lovelace" } } },
|
||||
},
|
||||
},
|
||||
response: {
|
||||
jsonrpc: "2.0",
|
||||
id: 2,
|
||||
result: {
|
||||
resultType: "complete",
|
||||
content: [{ type: "text", text: "Booked flight to SFO" }],
|
||||
},
|
||||
},
|
||||
duration: 44,
|
||||
};
|
||||
|
||||
// A completed two-round conversation: original call → input_required → retry →
|
||||
// complete.
|
||||
export const Completed: Story = {
|
||||
args: {
|
||||
rounds: [original, retry],
|
||||
},
|
||||
};
|
||||
|
||||
// The conversation collapsed; expanding it reveals the individual rounds.
|
||||
export const CollapsedThenExpanded: Story = {
|
||||
args: {
|
||||
rounds: [original, retry],
|
||||
isListExpanded: false,
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const toggle = await canvas.findByRole("button", {
|
||||
name: "Expand MRTR conversation",
|
||||
});
|
||||
await userEvent.click(toggle);
|
||||
await expect(
|
||||
await canvas.findByRole("button", {
|
||||
name: "Collapse MRTR conversation",
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
// Still awaiting input: the original call returned input_required and the user
|
||||
// has not answered yet, so the conversation has a single round.
|
||||
export const AwaitingInput: Story = {
|
||||
args: {
|
||||
rounds: [original],
|
||||
},
|
||||
};
|
||||
|
|
@ -0,0 +1,160 @@
|
|||
import { describe, it, expect, vi } from "vitest";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import type { MessageEntry } from "@inspector/core/mcp/types.js";
|
||||
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
|
||||
import { MrtrConversation } from "./MrtrConversation";
|
||||
|
||||
function round(
|
||||
id: string,
|
||||
jsonRpcId: number,
|
||||
params: Record<string, unknown>,
|
||||
result: Record<string, unknown> | undefined,
|
||||
at: number,
|
||||
): MessageEntry {
|
||||
return {
|
||||
id,
|
||||
timestamp: new Date(at),
|
||||
direction: "request",
|
||||
origin: "client",
|
||||
message: { jsonrpc: "2.0", id: jsonRpcId, method: "tools/call", params },
|
||||
response: result ? { jsonrpc: "2.0", id: jsonRpcId, result } : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const original = round(
|
||||
"orig",
|
||||
1,
|
||||
{ name: "book_flight" },
|
||||
{ resultType: "input_required", requestState: "tok" },
|
||||
1000,
|
||||
);
|
||||
const retry = round(
|
||||
"retry",
|
||||
2,
|
||||
{ name: "book_flight", requestState: "tok", inputResponses: {} },
|
||||
{ resultType: "complete", content: [{ type: "text", text: "booked" }] },
|
||||
2000,
|
||||
);
|
||||
|
||||
const baseProps = {
|
||||
requestState: "tok",
|
||||
pinnedIds: new Set<string>(),
|
||||
isListExpanded: true,
|
||||
onReplay: vi.fn(),
|
||||
onTogglePin: vi.fn(),
|
||||
};
|
||||
|
||||
describe("MrtrConversation", () => {
|
||||
it("renders the method, an MRTR label, and the round count", () => {
|
||||
renderWithMantine(
|
||||
<MrtrConversation {...baseProps} rounds={[original, retry]} />,
|
||||
);
|
||||
// The header carries a method badge; each round also carries one, so the
|
||||
// method appears more than once — the MRTR label and count are unique.
|
||||
expect(screen.getAllByText("tools/call").length).toBeGreaterThanOrEqual(1);
|
||||
expect(screen.getByText("MRTR")).toBeInTheDocument();
|
||||
expect(screen.getByText("2 rounds")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("singularizes a one-round conversation", () => {
|
||||
renderWithMantine(<MrtrConversation {...baseProps} rounds={[original]} />);
|
||||
expect(screen.getByText("1 round")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("derives Complete status from the final round", () => {
|
||||
renderWithMantine(
|
||||
<MrtrConversation {...baseProps} rounds={[original, retry]} />,
|
||||
);
|
||||
expect(screen.getByTestId("mrtr-status")).toHaveTextContent("Complete");
|
||||
});
|
||||
|
||||
it("shows Awaiting input when the final round is still input_required", () => {
|
||||
renderWithMantine(<MrtrConversation {...baseProps} rounds={[original]} />);
|
||||
expect(screen.getByTestId("mrtr-status")).toHaveTextContent(
|
||||
"Awaiting input",
|
||||
);
|
||||
});
|
||||
|
||||
it("shows Error when the final round failed", () => {
|
||||
const failed: MessageEntry = {
|
||||
id: "err",
|
||||
timestamp: new Date(3000),
|
||||
direction: "request",
|
||||
origin: "client",
|
||||
message: {
|
||||
jsonrpc: "2.0",
|
||||
id: 3,
|
||||
method: "tools/call",
|
||||
params: { requestState: "tok" },
|
||||
},
|
||||
response: {
|
||||
jsonrpc: "2.0",
|
||||
id: 3,
|
||||
error: { code: -32602, message: "bad" },
|
||||
},
|
||||
};
|
||||
renderWithMantine(
|
||||
<MrtrConversation {...baseProps} rounds={[original, failed]} />,
|
||||
);
|
||||
expect(screen.getByTestId("mrtr-status")).toHaveTextContent("Error");
|
||||
});
|
||||
|
||||
it("shows Pending when the final round has no response yet", () => {
|
||||
const pending = round("p", 4, { requestState: "tok" }, undefined, 4000);
|
||||
renderWithMantine(
|
||||
<MrtrConversation {...baseProps} rounds={[original, pending]} />,
|
||||
);
|
||||
expect(screen.getByTestId("mrtr-status")).toHaveTextContent("Pending");
|
||||
});
|
||||
|
||||
it("labels each round in chronological order regardless of input order", () => {
|
||||
// Pass the rounds newest-first (as a newest-first list sort would); the
|
||||
// conversation still reads original (Round 1) → retry (Round 2).
|
||||
renderWithMantine(
|
||||
<MrtrConversation {...baseProps} rounds={[retry, original]} />,
|
||||
);
|
||||
const round1 = screen.getByText("Round 1");
|
||||
const round2 = screen.getByText("Round 2");
|
||||
expect(round1).toBeInTheDocument();
|
||||
expect(round2).toBeInTheDocument();
|
||||
// Round 1 appears before Round 2 in the DOM.
|
||||
expect(
|
||||
round1.compareDocumentPosition(round2) & Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it("collapses and expands the rounds", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithMantine(
|
||||
<MrtrConversation
|
||||
{...baseProps}
|
||||
rounds={[original, retry]}
|
||||
isListExpanded={false}
|
||||
/>,
|
||||
);
|
||||
const toggle = screen.getByRole("button", {
|
||||
name: "Expand MRTR conversation",
|
||||
});
|
||||
expect(toggle).toHaveAttribute("aria-expanded", "false");
|
||||
await user.click(toggle);
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Collapse MRTR conversation" }),
|
||||
).toHaveAttribute("aria-expanded", "true");
|
||||
});
|
||||
|
||||
it("forwards per-round pin toggles by entry id", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onTogglePin = vi.fn();
|
||||
renderWithMantine(
|
||||
<MrtrConversation
|
||||
{...baseProps}
|
||||
rounds={[original, retry]}
|
||||
onTogglePin={onTogglePin}
|
||||
/>,
|
||||
);
|
||||
// Each round has its own Pin control; clicking the first pins "orig".
|
||||
const pinButtons = screen.getAllByRole("button", { name: "Pin" });
|
||||
await user.click(pinButtons[0]);
|
||||
expect(onTogglePin).toHaveBeenCalledWith("orig");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,189 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Badge,
|
||||
Collapse,
|
||||
Divider,
|
||||
Group,
|
||||
Paper,
|
||||
Stack,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import type { MessageEntry } from "@inspector/core/mcp/types.js";
|
||||
import { ProtocolEntry } from "../ProtocolEntry/ProtocolEntry";
|
||||
import { ExpandToggle } from "../../elements/ExpandToggle/ExpandToggle";
|
||||
import { MethodBadge } from "../../elements/MethodBadge/MethodBadge";
|
||||
import { extractMethod, extractResultType } from "../protocolUtils.js";
|
||||
|
||||
export interface MrtrConversationProps {
|
||||
/** The opaque MRTR token that links this conversation's rounds. */
|
||||
requestState: string;
|
||||
/** The entries belonging to this conversation (one per JSON-RPC id). */
|
||||
rounds: MessageEntry[];
|
||||
/** Which of this conversation's rounds are pinned, by entry id. */
|
||||
pinnedIds: Set<string>;
|
||||
/** Whether rounds start expanded (mirrors the list-level compact toggle). */
|
||||
isListExpanded: boolean;
|
||||
/** Compact per-round layout for the narrow monitoring column. */
|
||||
embedded?: boolean;
|
||||
onReplay: (id: string) => void;
|
||||
onTogglePin: (id: string) => void;
|
||||
}
|
||||
|
||||
// MRTR (multi-round-trip request, spec §7.3) makes one logical operation span
|
||||
// several JSON-RPC ids: the original call returns `input_required`, the client
|
||||
// answers and retries with a NEW id echoing `requestState`, repeating until a
|
||||
// final `complete` result. This groups those rounds into one expandable unit so
|
||||
// the operation reads as a single conversation instead of scattered calls.
|
||||
|
||||
const ConversationContainer = Paper.withProps({
|
||||
withBorder: true,
|
||||
p: "md",
|
||||
radius: "md",
|
||||
});
|
||||
|
||||
const HeaderRow = Group.withProps({
|
||||
justify: "space-between",
|
||||
wrap: "nowrap",
|
||||
});
|
||||
|
||||
const HeaderLeft = Group.withProps({
|
||||
gap: "sm",
|
||||
wrap: "nowrap",
|
||||
miw: 0,
|
||||
});
|
||||
|
||||
const HeaderRight = Group.withProps({
|
||||
gap: "sm",
|
||||
wrap: "nowrap",
|
||||
});
|
||||
|
||||
const MrtrLabel = Text.withProps({
|
||||
size: "sm",
|
||||
fw: 600,
|
||||
c: "dimmed",
|
||||
});
|
||||
|
||||
const RoundLabel = Text.withProps({
|
||||
size: "xs",
|
||||
fw: 600,
|
||||
c: "dimmed",
|
||||
});
|
||||
|
||||
type ConversationStatus = "pending" | "awaiting" | "error" | "complete";
|
||||
|
||||
// The conversation's status is that of its final (latest) round: still awaiting
|
||||
// input if the last result is `input_required`, otherwise the ordinary
|
||||
// pending/error/complete lifecycle of that round.
|
||||
function conversationStatus(finalRound: MessageEntry): ConversationStatus {
|
||||
if (!finalRound.response) return "pending";
|
||||
if ("error" in finalRound.response) return "error";
|
||||
if (extractResultType(finalRound) === "input_required") return "awaiting";
|
||||
return "complete";
|
||||
}
|
||||
|
||||
function statusColor(status: ConversationStatus): string {
|
||||
switch (status) {
|
||||
case "complete":
|
||||
return "green";
|
||||
case "error":
|
||||
return "red";
|
||||
case "awaiting":
|
||||
return "yellow";
|
||||
default:
|
||||
return "gray";
|
||||
}
|
||||
}
|
||||
|
||||
function statusLabel(status: ConversationStatus): string {
|
||||
switch (status) {
|
||||
case "complete":
|
||||
return "Complete";
|
||||
case "error":
|
||||
return "Error";
|
||||
case "awaiting":
|
||||
return "Awaiting input";
|
||||
default:
|
||||
return "Pending";
|
||||
}
|
||||
}
|
||||
|
||||
function formatRoundsLabel(count: number): string {
|
||||
return count === 1 ? "1 round" : `${count} rounds`;
|
||||
}
|
||||
|
||||
function formatRoundLabel(index: number): string {
|
||||
return `Round ${index + 1}`;
|
||||
}
|
||||
|
||||
export function MrtrConversation({
|
||||
requestState,
|
||||
rounds,
|
||||
pinnedIds,
|
||||
isListExpanded,
|
||||
embedded = false,
|
||||
onReplay,
|
||||
onTogglePin,
|
||||
}: MrtrConversationProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(isListExpanded);
|
||||
|
||||
useEffect(() => {
|
||||
setIsExpanded(isListExpanded);
|
||||
}, [isListExpanded]);
|
||||
|
||||
// Always read the conversation chronologically (original → retries → final),
|
||||
// regardless of the list's newest-first/oldest-first sort.
|
||||
const ordered = [...rounds].sort(
|
||||
(a, b) => a.timestamp.getTime() - b.timestamp.getTime(),
|
||||
);
|
||||
const method = extractMethod(ordered[0]);
|
||||
const status = conversationStatus(ordered[ordered.length - 1]);
|
||||
|
||||
return (
|
||||
<ConversationContainer aria-label={`MRTR conversation ${requestState}`}>
|
||||
<Stack gap="sm">
|
||||
<HeaderRow>
|
||||
<HeaderLeft>
|
||||
<MethodBadge method={method} />
|
||||
<MrtrLabel>MRTR</MrtrLabel>
|
||||
<Badge color="blue" variant="outline">
|
||||
{formatRoundsLabel(ordered.length)}
|
||||
</Badge>
|
||||
</HeaderLeft>
|
||||
<HeaderRight>
|
||||
<Badge
|
||||
color={statusColor(status)}
|
||||
variant="status"
|
||||
data-testid="mrtr-status"
|
||||
>
|
||||
{statusLabel(status)}
|
||||
</Badge>
|
||||
<ExpandToggle
|
||||
expanded={isExpanded}
|
||||
ariaLabel={`${isExpanded ? "Collapse" : "Expand"} MRTR conversation`}
|
||||
onToggle={() => setIsExpanded((v) => !v)}
|
||||
/>
|
||||
</HeaderRight>
|
||||
</HeaderRow>
|
||||
|
||||
<Collapse in={isExpanded}>
|
||||
<Stack gap="sm">
|
||||
<Divider />
|
||||
{ordered.map((round, index) => (
|
||||
<Stack key={round.id} gap="xs">
|
||||
<RoundLabel>{formatRoundLabel(index)}</RoundLabel>
|
||||
<ProtocolEntry
|
||||
entry={round}
|
||||
isPinned={pinnedIds.has(round.id)}
|
||||
isListExpanded={isListExpanded}
|
||||
embedded={embedded}
|
||||
onReplay={() => onReplay(round.id)}
|
||||
onTogglePin={() => onTogglePin(round.id)}
|
||||
/>
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
</Collapse>
|
||||
</Stack>
|
||||
</ConversationContainer>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue