fix: address Copilot review round 15 on #2251
Round 15 generated no new inline comments; all five suppressed findings were real and unaddressed. - SkillsScreen: claim the attempt BEFORE issuing the request, for both the SKILL.md preview and `skills/get`. Recording it only on settle left a window where an older request that happened to resolve first was still considered current and published while a newer one was in flight — `verifyRow` already claimed its row up front, these two did not. Tests answer the OLDER request first and assert nothing is published. - SkillsScreen: "No skills listed", not "No skills". SEP-2640 lets a server return an empty or partial catalog and says an empty result is not proof it has none — an unlisted skill is still fetchable by URI — so the old copy had the tool asserting something the protocol explicitly does not. - SkillsScreen: each row's Verify button gets an `aria-label` carrying its URI. Every row's visible text is "Verify" and the URI cell is not programmatically associated with the button, so a screen-reader user had no way to tell which file each control checked. - inspectorClient: attribute a rejected `skills/get` envelope with `markResponseRejected`, so the Protocol tab stops rendering it as a clean success while the screen shows an error. In the client rather than a store because `skills/get` has none. Gated on `isClientDecodeRejection`, with a test that a transport failure is NOT attributed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018kfrH3uRNUM4JAk1reKtyY Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
parent
11ac134975
commit
a561cd1daa
4 changed files with 176 additions and 20 deletions
|
|
@ -153,9 +153,14 @@ describe("SkillsScreen", () => {
|
||||||
expect(root).toHaveAttribute("data-skill-page-count", "2");
|
expect(root).toHaveAttribute("data-skill-page-count", "2");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders 'No skills' when the list is empty", () => {
|
it("says the list was empty without claiming there are no skills", () => {
|
||||||
|
// SEP-2640 lets a server return an empty or partial catalog and says an
|
||||||
|
// empty result is not proof it has none — an unlisted skill is still
|
||||||
|
// fetchable by URI — so "No skills" would be the tool asserting something
|
||||||
|
// the protocol explicitly does not.
|
||||||
renderWithMantine(<SkillsScreen {...baseProps} skills={[]} />);
|
renderWithMantine(<SkillsScreen {...baseProps} skills={[]} />);
|
||||||
expect(screen.getByText("No skills")).toBeInTheDocument();
|
expect(screen.getByText("No skills listed")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("No skills")).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders a load failure above the list", () => {
|
it("renders a load failure above the list", () => {
|
||||||
|
|
@ -236,10 +241,13 @@ describe("SkillsScreen", () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
renderWithMantine(<ControlledSkillsScreen />);
|
renderWithMantine(<ControlledSkillsScreen />);
|
||||||
await user.click(screen.getByText("data-analysis"));
|
await user.click(screen.getByText("data-analysis"));
|
||||||
const manifest = screen.getByTestId("skill-manifest");
|
// Addressed by its accessible name, which carries the URI — every row's
|
||||||
// One row at a time: the first row's own Verify button, not "Verify all".
|
// visible text is just "Verify", so that name is what tells a
|
||||||
|
// screen-reader user (and this test) which file the button checks.
|
||||||
await user.click(
|
await user.click(
|
||||||
within(manifest).getAllByRole("button", { name: "Verify" })[0],
|
screen.getByRole("button", {
|
||||||
|
name: "Verify skill://data-analysis/reference.md",
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
expect(await screen.findByText("verified")).toBeInTheDocument();
|
expect(await screen.findByText("verified")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
@ -374,10 +382,9 @@ describe("SkillsScreen", () => {
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
await user.click(screen.getByText("data-analysis"));
|
await user.click(screen.getByText("data-analysis"));
|
||||||
const manifest = screen.getByTestId("skill-manifest");
|
const rowVerify = screen.getByRole("button", {
|
||||||
const rowVerify = within(manifest).getAllByRole("button", {
|
name: "Verify skill://data-analysis/SKILL.md",
|
||||||
name: "Verify",
|
});
|
||||||
})[0];
|
|
||||||
await user.click(rowVerify);
|
await user.click(rowVerify);
|
||||||
await user.click(rowVerify);
|
await user.click(rowVerify);
|
||||||
expect(resolvers).toHaveLength(2);
|
expect(resolvers).toHaveLength(2);
|
||||||
|
|
@ -780,6 +787,68 @@ describe("SkillsScreen", () => {
|
||||||
).not.toBeInTheDocument();
|
).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("rejects an older preview read even when it resolves FIRST", async () => {
|
||||||
|
// The ordering hole: recording an attempt only when it settles leaves a
|
||||||
|
// window where the older request is still considered current. Claiming it
|
||||||
|
// before the request goes out is what makes the older callback stale
|
||||||
|
// immediately, whatever order the two resolve in.
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const resolvers: ((value: { text: string }) => void)[] = [];
|
||||||
|
const onReadSkillFile = vi.fn(
|
||||||
|
() =>
|
||||||
|
new Promise<{ text: string }>((resolve) => {
|
||||||
|
resolvers.push(resolve);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
renderWithMantine(
|
||||||
|
<ControlledSkillsScreen onReadSkillFile={onReadSkillFile} />,
|
||||||
|
);
|
||||||
|
await user.click(screen.getByText("data-analysis"));
|
||||||
|
const view = screen.getByRole("button", { name: /View SKILL.md/ });
|
||||||
|
await user.click(view);
|
||||||
|
await user.click(view);
|
||||||
|
expect(resolvers).toHaveLength(2);
|
||||||
|
|
||||||
|
// The OLDER read answers first, while the newer one is still in flight.
|
||||||
|
resolvers[0]({ text: "# stale\n" });
|
||||||
|
expect(screen.queryByTestId("skill-md-preview")).not.toBeInTheDocument();
|
||||||
|
resolvers[1]({ text: "# newest\n" });
|
||||||
|
expect(await screen.findByTestId("skill-md-preview")).toHaveTextContent(
|
||||||
|
"newest",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an older skills/get even when it resolves FIRST", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const resolvers: ((value: SkillEntry) => void)[] = [];
|
||||||
|
const onGetSkill = vi.fn(
|
||||||
|
() =>
|
||||||
|
new Promise<SkillEntry>((resolve) => {
|
||||||
|
resolvers.push(resolve);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
renderWithMantine(<ControlledSkillsScreen onGetSkill={onGetSkill} />);
|
||||||
|
await user.click(screen.getByText("data-analysis"));
|
||||||
|
const fetchButton = screen.getByRole("button", {
|
||||||
|
name: /Fetch with skills\/get/,
|
||||||
|
});
|
||||||
|
await user.click(fetchButton);
|
||||||
|
await user.click(fetchButton);
|
||||||
|
expect(resolvers).toHaveLength(2);
|
||||||
|
|
||||||
|
// The older fetch answers first with a differing entry; it must not
|
||||||
|
// publish a verdict while the newer one is pending.
|
||||||
|
resolvers[0]({
|
||||||
|
...CLEAN_SKILL,
|
||||||
|
frontmatter: { ...CLEAN_SKILL.frontmatter, description: "stale" },
|
||||||
|
});
|
||||||
|
expect(screen.queryByTestId("skills-get-result")).not.toBeInTheDocument();
|
||||||
|
resolvers[1](CLEAN_SKILL);
|
||||||
|
expect(
|
||||||
|
await screen.findByText("skills/get matches skills/list"),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it("shows the SKILL.md preview on demand", async () => {
|
it("shows the SKILL.md preview on demand", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
renderWithMantine(<ControlledSkillsScreen />);
|
renderWithMantine(<ControlledSkillsScreen />);
|
||||||
|
|
|
||||||
|
|
@ -529,6 +529,13 @@ export function SkillsScreen({
|
||||||
setPreviewState((prev) =>
|
setPreviewState((prev) =>
|
||||||
isStale(prev, key, attempt) ? prev : { key, attempt, ...next },
|
isStale(prev, key, attempt) ? prev : { key, attempt, ...next },
|
||||||
);
|
);
|
||||||
|
// Claimed BEFORE the request goes out, the way `verifyRow` claims its row.
|
||||||
|
// Recording the attempt only on settle leaves a window where an older
|
||||||
|
// request that happens to resolve first is still considered current, and
|
||||||
|
// publishes its contents while a newer one is in flight. Clearing the
|
||||||
|
// previous result at the same time also means the pane doesn't keep
|
||||||
|
// showing the old file while the new read is running.
|
||||||
|
writePreview({});
|
||||||
void onReadSkillFile(selected.uri)
|
void onReadSkillFile(selected.uri)
|
||||||
.then((contents) => writePreview({ contents }))
|
.then((contents) => writePreview({ contents }))
|
||||||
.catch((err: unknown) => {
|
.catch((err: unknown) => {
|
||||||
|
|
@ -549,6 +556,9 @@ export function SkillsScreen({
|
||||||
setFetchedEntry((prev) =>
|
setFetchedEntry((prev) =>
|
||||||
isStale(prev, key, attempt) ? prev : { key, attempt, ...next },
|
isStale(prev, key, attempt) ? prev : { key, attempt, ...next },
|
||||||
);
|
);
|
||||||
|
// Claimed before the request goes out — see `showSkillMd` for why settling
|
||||||
|
// is too late.
|
||||||
|
writeFetched({});
|
||||||
void onGetSkill(selected.uri)
|
void onGetSkill(selected.uri)
|
||||||
.then((entry) => {
|
.then((entry) => {
|
||||||
// The fetched entry is checked ON ITS OWN before being compared. A
|
// The fetched entry is checked ON ITS OWN before being compared. A
|
||||||
|
|
@ -628,7 +638,12 @@ export function SkillsScreen({
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
{filtered.length === 0 ? (
|
{filtered.length === 0 ? (
|
||||||
<EmptyState>No skills</EmptyState>
|
// Not "No skills": SEP-2640 lets a server return an empty or
|
||||||
|
// partial catalog, and says an empty result must not be read as
|
||||||
|
// proof it has none — an unlisted skill can still be fetched by
|
||||||
|
// URI with `skills/get`. Claiming otherwise would be the tool
|
||||||
|
// asserting something the protocol explicitly does not.
|
||||||
|
<EmptyState>No skills listed</EmptyState>
|
||||||
) : (
|
) : (
|
||||||
filtered.map((skill) => {
|
filtered.map((skill) => {
|
||||||
const skillIssues = checkSkillConformance(skill);
|
const skillIssues = checkSkillConformance(skill);
|
||||||
|
|
@ -783,8 +798,14 @@ export function SkillsScreen({
|
||||||
{verificationLabel(state)}
|
{verificationLabel(state)}
|
||||||
</CountBadge>
|
</CountBadge>
|
||||||
<RowVerifyButton
|
<RowVerifyButton
|
||||||
|
// Every row's button reads "Verify", so the
|
||||||
|
// visible text alone gives a screen-reader
|
||||||
|
// user no way to tell which file each one
|
||||||
|
// checks; the URI cell is in the same row but
|
||||||
|
// is not programmatically associated with it.
|
||||||
|
aria-label={`Verify ${resource.uri}`}
|
||||||
// A click handler cannot await, and
|
// A click handler cannot await, and
|
||||||
// `verifyFile` owns its own failures — it
|
// `verifyRow` owns its own failures — it
|
||||||
// records them as this row's state.
|
// records them as this row's state.
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
void verifyRow(index, resource, manifestKey)
|
void verifyRow(index, resource, manifestKey)
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { describe, it, expect, vi } from "vitest";
|
||||||
import { InspectorClient } from "@inspector/core/mcp/inspectorClient.js";
|
import { InspectorClient } from "@inspector/core/mcp/inspectorClient.js";
|
||||||
import type { ServerCapabilities } from "@modelcontextprotocol/client";
|
import type { ServerCapabilities } from "@modelcontextprotocol/client";
|
||||||
import { SKILLS_EXTENSION_KEY } from "@inspector/core/mcp/skillsSchemas.js";
|
import { SKILLS_EXTENSION_KEY } from "@inspector/core/mcp/skillsSchemas.js";
|
||||||
|
import { SdkError, SdkErrorCode } from "@modelcontextprotocol/client";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Unit coverage for the Skills extension methods (#2234, SEP-2640).
|
* Unit coverage for the Skills extension methods (#2234, SEP-2640).
|
||||||
|
|
@ -181,6 +182,52 @@ describe("InspectorClient skills methods (#2234)", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("attributes a rejected skills/get envelope to its Protocol entry", async () => {
|
||||||
|
// Without this the Skills screen shows an error while the Protocol tab
|
||||||
|
// renders the same exchange as a clean success. Done in the client rather
|
||||||
|
// than a store because `skills/get` has none — the screen calls it.
|
||||||
|
const client = makeClient();
|
||||||
|
const marked: [string, string][] = [];
|
||||||
|
(
|
||||||
|
client as unknown as {
|
||||||
|
markResponseRejected: (m: string, r: string) => void;
|
||||||
|
}
|
||||||
|
).markResponseRejected = (method, reason) => {
|
||||||
|
marked.push([method, reason]);
|
||||||
|
};
|
||||||
|
internals(client).client = {
|
||||||
|
request: async () => {
|
||||||
|
throw new SdkError(
|
||||||
|
SdkErrorCode.InvalidResult,
|
||||||
|
"Invalid result for skills/get",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
await expect(client.getSkill("skill://demo/SKILL.md")).rejects.toThrow();
|
||||||
|
expect(marked).toEqual([["skills/get", "Invalid result for skills/get"]]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT attribute a transport failure on skills/get", async () => {
|
||||||
|
// No response frame arrived, so the last-answered id still points at an
|
||||||
|
// earlier, successful exchange; marking it would stamp that one.
|
||||||
|
const client = makeClient();
|
||||||
|
const marked: string[] = [];
|
||||||
|
(
|
||||||
|
client as unknown as {
|
||||||
|
markResponseRejected: (m: string, r: string) => void;
|
||||||
|
}
|
||||||
|
).markResponseRejected = (method) => {
|
||||||
|
marked.push(method);
|
||||||
|
};
|
||||||
|
internals(client).client = {
|
||||||
|
request: async () => {
|
||||||
|
throw new SdkError(SdkErrorCode.ConnectionClosed, "Connection closed");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
await expect(client.getSkill("skill://demo/SKILL.md")).rejects.toThrow();
|
||||||
|
expect(marked).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
it("rejects a skills/list result that is not a skills page", async () => {
|
it("rejects a skills/list result that is not a skills page", async () => {
|
||||||
// The explicit result schema is the whole client-side mechanism for a
|
// The explicit result schema is the whole client-side mechanism for a
|
||||||
// consumer-owned extension method, so a nonconforming result must fail
|
// consumer-owned extension method, so a nonconforming result must fail
|
||||||
|
|
|
||||||
|
|
@ -203,6 +203,7 @@ import {
|
||||||
LIST_MAX_PAGES,
|
LIST_MAX_PAGES,
|
||||||
ModernResultEnvelopeSchema,
|
ModernResultEnvelopeSchema,
|
||||||
isSalvageableRejection,
|
isSalvageableRejection,
|
||||||
|
isClientDecodeRejection,
|
||||||
listPaginationExceeded,
|
listPaginationExceeded,
|
||||||
toolItemSchemaForEra,
|
toolItemSchemaForEra,
|
||||||
nextCursorOf,
|
nextCursorOf,
|
||||||
|
|
@ -5603,15 +5604,33 @@ export class InspectorClient extends InspectorClientEventTarget {
|
||||||
};
|
};
|
||||||
// `GetSkillResultSchema` unwraps the envelope, so there is nothing to
|
// `GetSkillResultSchema` unwraps the envelope, so there is nothing to
|
||||||
// unwrap here.
|
// unwrap here.
|
||||||
return this.invokeMcpClient(
|
try {
|
||||||
() =>
|
return await this.invokeMcpClient(
|
||||||
this.client!.request(
|
() =>
|
||||||
{ method: SKILLS_GET_METHOD, params },
|
this.client!.request(
|
||||||
GetSkillResultSchema,
|
{ method: SKILLS_GET_METHOD, params },
|
||||||
this.getRequestOptions(this.progressTokenOf(metadata)),
|
GetSkillResultSchema,
|
||||||
),
|
this.getRequestOptions(this.progressTokenOf(metadata)),
|
||||||
{ method: SKILLS_GET_METHOD },
|
),
|
||||||
);
|
{ method: SKILLS_GET_METHOD },
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
// Attribute a rejected envelope to the exchange it came from, so the
|
||||||
|
// Protocol tab stops rendering it as a clean success while the Skills
|
||||||
|
// screen shows an error — the same handling every managed list gets
|
||||||
|
// (#1953). Done here rather than in a store because `skills/get` has
|
||||||
|
// none: the screen calls it directly. Must happen in this catch, while
|
||||||
|
// the correlation window is still current, and ONLY for a decode
|
||||||
|
// rejection — a request that never produced a response would otherwise
|
||||||
|
// stamp an earlier, successful exchange.
|
||||||
|
if (isClientDecodeRejection(err)) {
|
||||||
|
this.markResponseRejected(
|
||||||
|
SKILLS_GET_METHOD,
|
||||||
|
err instanceof Error ? err.message : String(err),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue