A floated promise is invisible at review time — the call reads like an awaited one minus four characters — and the unhandled rejection it produces surfaces in a different test, in a different file, as a stack pointing at SDK internals. In #1947 two un-held `callTool` calls made `npm run ci` unpassable and took a full investigation to attribute; the fix was two lines. `@typescript-eslint/no-floating-promises` catches the class outright, so enable it at `error` everywhere. The rule is type-aware and `tseslint.configs.recommended` is not, so each scope's config gains a parser project. The four clients name their own tsconfig projects (both of them — `src` and `__tests__`/`server` live in different ones). The root scope has no tsconfig at all, so add `tsconfig.lint.json`: a lint-only project covering `core/**`, `test-servers/src/**`, and `vitest.shared.mts`, emitting nothing and gating nothing. It sets `moduleResolution: bundler` deliberately — under NodeNext `core/`'s extensionless imports fail to resolve, every import degrades to `any`, and the rule silently stops seeing promises at all. Measured surface, and the fixes: - core (2) — `stream.close()` in two Hono `onAbort` listeners. Hono invokes subscribers with a bare `subscriber()`, so a promise returned from the listener is floated one layer up; `closeAbortedStream` owns the rejection where it can actually be owned. - tui (5) — `handleDisconnect` had no catch of its own and its only caller is a key handler that cannot await, so a failing disconnect was a live unhandled rejection; it now surfaces the message the way `handleConnect` does. The rest are `void` at callers that genuinely cannot await, over callees that already own their failures. - web (92) — 83 are Storybook `expect(...)`, which is instrumented and returns a promise, now awaited (`scrollAreaStoryAssertions` is async for the same reason). Two tests were asserting against a provider call they never awaited. `open(url)` in both web servers now warns instead of escaping a synchronous callback. - cli, launcher — already clean. Type-aware linting costs time: `clients/web`'s `eslint .` goes from ~8s to ~19s, inside `validate`. Narrowing the loaded projects is the lever if that needs reducing later; dropping the rule is not. Closes #1959 Signed-off-by: cliffhall <cliff@futurescale.com>
214 lines
5.8 KiB
TypeScript
214 lines
5.8 KiB
TypeScript
import { AppShell } from "@mantine/core";
|
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
import { expect, fn, userEvent, within } from "storybook/test";
|
|
import type {
|
|
CreateMessageRequestParams,
|
|
ElicitRequestFormParams,
|
|
} from "@modelcontextprotocol/client";
|
|
import {
|
|
PendingClientRequestModal,
|
|
type PendingClientRequestModalProps,
|
|
} from "./PendingClientRequestModal";
|
|
|
|
const samplingRequest: CreateMessageRequestParams = {
|
|
messages: [
|
|
{
|
|
role: "user",
|
|
content: {
|
|
type: "text",
|
|
text: "Summarize the latest changes in this repository.",
|
|
},
|
|
},
|
|
],
|
|
maxTokens: 1024,
|
|
};
|
|
|
|
// A long sampling request (many messages + preferences), to exercise the
|
|
// pinned-actions layout: the content scrolls, the Reject / Send Response
|
|
// buttons stay in view.
|
|
const tallSamplingRequest: CreateMessageRequestParams = {
|
|
messages: Array.from({ length: 8 }, (_, i) => ({
|
|
role: (i % 2 === 0 ? "user" : "assistant") as "user" | "assistant",
|
|
content: {
|
|
type: "text" as const,
|
|
text: `Message ${i + 1}: a representative turn in the conversation the server wants the model to continue from.`,
|
|
},
|
|
})),
|
|
modelPreferences: {
|
|
hints: [{ name: "claude-3-5-sonnet" }],
|
|
costPriority: 0.3,
|
|
speedPriority: 0.6,
|
|
intelligencePriority: 0.9,
|
|
},
|
|
maxTokens: 2048,
|
|
temperature: 0.7,
|
|
includeContext: "thisServer",
|
|
};
|
|
|
|
const formRequest: ElicitRequestFormParams = {
|
|
message: "Please provide your database connection details.",
|
|
requestedSchema: {
|
|
type: "object",
|
|
properties: {
|
|
host: { type: "string", title: "Host" },
|
|
port: { type: "string", title: "Port" },
|
|
},
|
|
},
|
|
};
|
|
|
|
// More fields than fit the modal, to exercise the pinned-actions layout:
|
|
// only the fields scroll; the message, warning, and buttons stay in view.
|
|
const tallFormRequest: ElicitRequestFormParams = {
|
|
message: "Please provide the full database configuration.",
|
|
requestedSchema: {
|
|
type: "object",
|
|
properties: Object.fromEntries(
|
|
[
|
|
"host",
|
|
"port",
|
|
"database",
|
|
"username",
|
|
"password",
|
|
"schema",
|
|
"poolSize",
|
|
"connectTimeout",
|
|
"idleTimeout",
|
|
"applicationName",
|
|
"sslMode",
|
|
"sslCert",
|
|
"sslKey",
|
|
"sslRootCert",
|
|
].map((name) => [name, { type: "string", title: name }]),
|
|
),
|
|
},
|
|
};
|
|
|
|
function InteractiveRender(args: PendingClientRequestModalProps) {
|
|
return (
|
|
<AppShell>
|
|
<AppShell.Main>
|
|
<PendingClientRequestModal {...args} />
|
|
</AppShell.Main>
|
|
</AppShell>
|
|
);
|
|
}
|
|
|
|
const meta: Meta<typeof PendingClientRequestModal> = {
|
|
title: "Groups/PendingClientRequestModal",
|
|
component: PendingClientRequestModal,
|
|
parameters: { layout: "fullscreen" },
|
|
render: InteractiveRender,
|
|
args: {
|
|
serverName: "Everything Server",
|
|
queuePosition: "1 of 1",
|
|
onSamplingRespond: fn(),
|
|
onSamplingReject: fn(),
|
|
onElicitationRespond: fn(),
|
|
},
|
|
};
|
|
|
|
export default meta;
|
|
type Story = StoryObj<typeof PendingClientRequestModal>;
|
|
|
|
export const Sampling: Story = {
|
|
args: {
|
|
request: {
|
|
kind: "sampling",
|
|
id: "sampling-1",
|
|
request: samplingRequest,
|
|
origin: "server-request",
|
|
},
|
|
},
|
|
play: async ({ canvasElement, args }) => {
|
|
const body = within(canvasElement.ownerDocument.body);
|
|
await body.findByText("Sampling Request");
|
|
await expect(
|
|
body.getByText("The server is requesting an LLM completion."),
|
|
).toBeInTheDocument();
|
|
await userEvent.click(body.getByRole("button", { name: "Send Response" }));
|
|
await expect(args.onSamplingRespond).toHaveBeenCalled();
|
|
},
|
|
};
|
|
|
|
// A modern MRTR round: the sampling request was embedded in a tool-call
|
|
// `input_required` result, so the panel shows the era-accurate note.
|
|
export const SamplingInputRequired: Story = {
|
|
args: {
|
|
request: {
|
|
kind: "sampling",
|
|
id: "sampling-mrtr",
|
|
request: samplingRequest,
|
|
origin: "input-required",
|
|
},
|
|
},
|
|
play: async ({ canvasElement }) => {
|
|
const body = within(canvasElement.ownerDocument.body);
|
|
await body.findByText("Sampling Request");
|
|
await expect(body.getByText("input_required")).toBeInTheDocument();
|
|
await expect(body.getByText(/sent back as a retry/i)).toBeInTheDocument();
|
|
},
|
|
};
|
|
|
|
export const SamplingTall: Story = {
|
|
args: {
|
|
request: {
|
|
kind: "sampling",
|
|
id: "sampling-tall",
|
|
request: tallSamplingRequest,
|
|
origin: "server-request",
|
|
},
|
|
},
|
|
};
|
|
|
|
export const ElicitationForm: Story = {
|
|
args: {
|
|
request: {
|
|
kind: "elicitation-form",
|
|
id: "elicitation-1",
|
|
request: formRequest,
|
|
origin: "server-request",
|
|
},
|
|
},
|
|
play: async ({ canvasElement, args }) => {
|
|
const body = within(canvasElement.ownerDocument.body);
|
|
await body.findByText("Elicitation Request");
|
|
await expect(
|
|
body.getByText(/Please provide your database connection details/),
|
|
).toBeInTheDocument();
|
|
await userEvent.click(body.getByRole("button", { name: "Submit" }));
|
|
await expect(args.onElicitationRespond).toHaveBeenCalledWith({
|
|
action: "accept",
|
|
content: {},
|
|
});
|
|
},
|
|
};
|
|
|
|
export const ElicitationFormTall: Story = {
|
|
args: {
|
|
request: {
|
|
kind: "elicitation-form",
|
|
id: "elicitation-tall",
|
|
request: tallFormRequest,
|
|
origin: "server-request",
|
|
},
|
|
},
|
|
};
|
|
|
|
export const ElicitationUrl: Story = {
|
|
args: {
|
|
request: {
|
|
kind: "elicitation-url",
|
|
id: "elicitation-2",
|
|
message: "Authorize access in your browser to continue.",
|
|
url: "https://example.com/authorize?token=abc123",
|
|
origin: "server-request",
|
|
},
|
|
},
|
|
play: async ({ canvasElement }) => {
|
|
const body = within(canvasElement.ownerDocument.body);
|
|
await body.findByText("Elicitation Request");
|
|
await expect(
|
|
body.getByText("https://example.com/authorize?token=abc123"),
|
|
).toBeInTheDocument();
|
|
},
|
|
};
|