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:
parent
097bfafdc1
commit
f201032137
3 changed files with 1080 additions and 48 deletions
|
|
@ -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(),
|
||||
},
|
||||
};
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue