fix: surface list-fetch failures instead of swallowing them

A list load that failed — a transport error, or a result the SDK's era
codec rejects as invalid — reported nothing. The connect-time refresh is
fired with no caller to await it (`void this.refresh()`), so its rejection
became an unhandled rejection: the header said "Connected", the panel
rendered an empty list indistinguishable from a server that has none, and
the failed exchange showed in the Protocol panel as a clean success.

Two surfaces now tell the truth:

- ManagedListState records the failure as observable state (`getError` +
  an `errorChange` event) and still re-throws, so App's auth-recovery
  wrapper keeps working. The four `useManaged*` hooks expose it via a
  shared `useManagedListError`, and the Tools/Prompts/Resources sidebars
  render a `ListLoadError` alert with the reason and a Retry.
- A response the client refused is attributed back to its Protocol entry
  (`markResponseRejected` → `responseRejected` → `MessageEntry.clientError`),
  which now renders an Error status and a "Rejected by the Inspector"
  alert rather than the green resultType badge alone. The SDK gives no
  request id with a decode failure, so the id is recovered by correlation;
  see the comment on `markResponseRejected` for why that is exact.

Also stops `listAllTools` from dropping a failing excluded-tools walk on
the floor — it stays non-fatal but is logged.

Closes #1953

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SW1p8E2uiyyLx4RKwrwSrt
This commit is contained in:
cliffhall 2026-08-08 16:48:48 -04:00
commit f201032137
3 changed files with 1080 additions and 48 deletions

View file

@ -0,0 +1,48 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { fn } from "storybook/test";
import { ListLoadError } from "./ListLoadError";
const meta: Meta<typeof ListLoadError> = {
title: "Elements/ListLoadError",
component: ListLoadError,
};
export default meta;
type Story = StoryObj<typeof ListLoadError>;
/** The failure this was built for: a modern server returning a list result the
* SDK codec rejects (#1953). */
export const CodecRejection: Story = {
args: {
what: "tools",
error: new Error(
'Invalid result for tools/list: [\n {\n "expected": "number",\n "code": "invalid_type",\n "path": [\n "ttlMs"\n ]\n }\n]',
),
onRetry: fn(),
},
};
export const TransportFailure: Story = {
args: {
what: "prompts",
error: new Error("fetch failed: ECONNREFUSED 127.0.0.1:3100"),
onRetry: fn(),
},
};
/** No retry handler — the alert renders without the affordance. */
export const WithoutRetry: Story = {
args: {
what: "resources",
error: new Error("Request timed out"),
},
};
/** The resting state: no error, nothing rendered. */
export const NoError: Story = {
args: {
what: "tools",
error: null,
onRetry: fn(),
},
};

View file

@ -0,0 +1,51 @@
import { describe, it, expect, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
import { ListLoadError } from "./ListLoadError";
describe("ListLoadError", () => {
it("renders nothing when there is no error", () => {
renderWithMantine(<ListLoadError error={null} what="tools" />);
expect(screen.queryByText(/Couldn't load/)).not.toBeInTheDocument();
});
it("renders nothing when the error prop is omitted", () => {
renderWithMantine(<ListLoadError what="tools" />);
expect(screen.queryByText(/Couldn't load/)).not.toBeInTheDocument();
});
it("names what failed to load and shows the reason verbatim", () => {
// The exact text is the diagnostic — a validation failure names the JSON
// path and what was expected, so it is rendered unabridged (#1953).
const message = 'Invalid result for tools/list: [{"path":["ttlMs"]}]';
renderWithMantine(
<ListLoadError error={new Error(message)} what="tools" />,
);
expect(screen.getByText("Couldn't load tools")).toBeInTheDocument();
expect(screen.getByText(message)).toBeInTheDocument();
});
it("uses the given noun so each list names itself", () => {
renderWithMantine(<ListLoadError error={new Error("x")} what="prompts" />);
expect(screen.getByText("Couldn't load prompts")).toBeInTheDocument();
});
it("invokes onRetry when Retry is clicked", async () => {
const user = userEvent.setup();
const onRetry = vi.fn();
renderWithMantine(
<ListLoadError error={new Error("x")} what="tools" onRetry={onRetry} />,
);
await user.click(screen.getByRole("button", { name: "Retry" }));
expect(onRetry).toHaveBeenCalledTimes(1);
});
it("omits the retry affordance when no handler is given", () => {
renderWithMantine(<ListLoadError error={new Error("x")} what="tools" />);
expect(
screen.queryByRole("button", { name: "Retry" }),
).not.toBeInTheDocument();
});
});

View file

@ -0,0 +1,65 @@
import { Alert, Button, Code, ScrollArea, Stack } from "@mantine/core";
export interface ListLoadErrorProps {
/**
* The failed load's error, or `null`/`undefined` when the last load
* succeeded (renders nothing).
*/
error?: Error | null;
/** What failed to load, for the alert title — e.g. "tools", "prompts". */
what: string;
/** Retry the load. Omit to render the alert without a retry affordance. */
onRetry?: () => void;
}
// `variant="light"` + red: an error the user can act on (retry), not a fatal
// one. Sits above the list rather than replacing it — a stale list plus a
// visible "this didn't reload" beats an empty panel that looks like an answer.
const ErrorAlert = Alert.withProps({
color: "red",
variant: "light",
});
// The raw message, monospaced and wrapping: these are validation failures
// (JSON paths, schema expectations) where the exact text is the diagnostic.
const ErrorMessage = Code.withProps({
block: true,
variant: "wrapping",
});
// Caps the message: a schema-validation failure serializes to a dozen-plus
// lines, which would otherwise push the list itself off the sidebar.
const MessageScroll = ScrollArea.withProps({
mah: 180,
type: "auto",
});
const RetryButton = Button.withProps({
size: "xs",
variant: "light",
color: "red",
w: "fit-content",
});
/**
* The list panel's "couldn't load" state (#1953).
*
* A list fetch that fails — a transport error, or a result the SDK codec
* rejects as invalid for the negotiated protocol era — used to leave the panel
* empty, which is indistinguishable from a server that legitimately has no
* tools/prompts/resources. This says what happened and offers a retry.
*/
export function ListLoadError({ error, what, onRetry }: ListLoadErrorProps) {
if (!error) return null;
return (
<ErrorAlert title={`Couldn't load ${what}`}>
<Stack gap="xs">
<MessageScroll>
<ErrorMessage>{error.message}</ErrorMessage>
</MessageScroll>
{onRetry && <RetryButton onClick={onRetry}>Retry</RetryButton>}
</Stack>
</ErrorAlert>
);
}