fix: address Copilot review round 1 on #2248
Five findings, all real. **Critical — `verifySkills` selected `contents[0]`.** These bytes are hashed against the file's advertised digest, so accepting a block the server labelled something else verifies one file's content against another file's digest, and can report that as `verified`. A false pass is worse than a missing check. `contentsFor(result, uri)` now selects by NORMALIZED identity, which keeps the canonicalized-echo case that motivated the original code while rejecting an unrelated block, and reports a read error when nothing answers for the URI. This is what `onReadSkillFile` already did; the two paths hash bytes against digests and must not disagree about which bytes. **`skills/get` was dispatched without the extension gate** that `skills/list` had. Hoisted into `assertSkillsSupported` so the two cannot drift — declaring the extension commits a server to both. Without it, an undeclared server's -32601 is indistinguishable to a script from the -32602 a declared server returns for a URI it does not serve. **The TUI could strand a user on a hidden Skills tab.** The tab left the bar when the gate went false but `activeTab` did not, so the pane kept rendering for a server that never declared the extension. Reset to `info`, following the Auth precedent — additionally gated on `connected`, because this gate reads a server declaration and would otherwise fire during a reconnect. **The TUI keyed a verdict by URI**, so a refresh replacing the manifest under the same URI left hashes computed for the previous snapshot describing the new one. Keyed on the entry now, as the web screen already was. **A failed "Load more" discarded the pages on screen and the retry cursor.** Both are preserved now, and the success and failure paths share one staleness-guarded `commit` helper so they cannot drift on which results they may write — which is how they came to disagree. Two nits: the six-skill counts. The docs sentence was more wrong than reported (five awkward skills of six, three outright violations) and is rewritten rather than renumbered. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ND46Ci64DC3tzBy9aG81Nc Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
parent
9a8488681e
commit
775107776d
4 changed files with 452 additions and 56 deletions
|
|
@ -1784,6 +1784,27 @@ describe("SkillsScreen directory browsing (#2248)", () => {
|
|||
});
|
||||
}
|
||||
|
||||
it("starts collapsed, since its content needs a round trip nobody has made", async () => {
|
||||
// Open, it would hold a button and an empty frame — advertising content
|
||||
// that is not there while taking height from the sections that have some.
|
||||
const user = userEvent.setup();
|
||||
renderWithMantine(
|
||||
<ControlledSkillsScreen
|
||||
onReadResourceDirectory={directoryReader({ [ROOT]: { resources: [] } })}
|
||||
/>,
|
||||
);
|
||||
await user.click(screen.getByText("data-analysis"));
|
||||
expect(screen.getByRole("button", { name: /Directory/ })).toHaveAttribute(
|
||||
"aria-expanded",
|
||||
"false",
|
||||
);
|
||||
// Still reachable, and the other sections are unaffected.
|
||||
expect(screen.getByRole("button", { name: /Resources/ })).toHaveAttribute(
|
||||
"aria-expanded",
|
||||
"true",
|
||||
);
|
||||
});
|
||||
|
||||
it("renders no Directory section when the server did not declare directoryRead", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithMantine(<ControlledSkillsScreen />);
|
||||
|
|
@ -1808,6 +1829,7 @@ describe("SkillsScreen directory browsing (#2248)", () => {
|
|||
await user.click(screen.getByText("data-analysis"));
|
||||
expect(onReadResourceDirectory).not.toHaveBeenCalled();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /Directory/ }));
|
||||
await user.click(screen.getByRole("button", { name: "Read directory" }));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("skill-directory")).toBeInTheDocument(),
|
||||
|
|
@ -1836,6 +1858,8 @@ describe("SkillsScreen directory browsing (#2248)", () => {
|
|||
<ControlledSkillsScreen onReadResourceDirectory={reader} />,
|
||||
);
|
||||
await user.click(screen.getByText("data-analysis"));
|
||||
// Directory starts collapsed — see `DEFAULT_OPEN_SECTIONS`.
|
||||
await user.click(screen.getByRole("button", { name: /Directory/ }));
|
||||
await user.click(screen.getByRole("button", { name: "Read directory" }));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("skill-directory")).toBeInTheDocument(),
|
||||
|
|
@ -1906,6 +1930,7 @@ describe("SkillsScreen directory browsing (#2248)", () => {
|
|||
/>,
|
||||
);
|
||||
await user.click(screen.getByText("data-analysis"));
|
||||
await user.click(screen.getByRole("button", { name: /Directory/ }));
|
||||
await user.click(screen.getByRole("button", { name: "Read directory" }));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("skill-directory")).toBeInTheDocument(),
|
||||
|
|
@ -1934,6 +1959,7 @@ describe("SkillsScreen directory browsing (#2248)", () => {
|
|||
/>,
|
||||
);
|
||||
await user.click(screen.getByText("data-analysis"));
|
||||
await user.click(screen.getByRole("button", { name: /Directory/ }));
|
||||
await user.click(screen.getByRole("button", { name: "Read directory" }));
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
|
|
@ -2024,6 +2050,7 @@ describe("SkillsScreen directory browsing (#2248)", () => {
|
|||
<ControlledSkillsScreen onReadResourceDirectory={reader} />,
|
||||
);
|
||||
await user.click(screen.getByText("dynamic-report"));
|
||||
await user.click(screen.getByRole("button", { name: /Directory/ }));
|
||||
await user.click(screen.getByRole("button", { name: "Read directory" }));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("skill-directory")).toBeInTheDocument(),
|
||||
|
|
@ -2044,6 +2071,7 @@ describe("SkillsScreen directory browsing (#2248)", () => {
|
|||
/>,
|
||||
);
|
||||
await user.click(screen.getByText("data-analysis"));
|
||||
await user.click(screen.getByRole("button", { name: /Directory/ }));
|
||||
await user.click(screen.getByRole("button", { name: "Read directory" }));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText("This directory is empty.")).toBeInTheDocument(),
|
||||
|
|
@ -2063,6 +2091,7 @@ describe("SkillsScreen directory browsing (#2248)", () => {
|
|||
/>,
|
||||
);
|
||||
await user.click(screen.getByText("data-analysis"));
|
||||
await user.click(screen.getByRole("button", { name: /Directory/ }));
|
||||
await user.click(screen.getByRole("button", { name: "Read directory" }));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/Not a directory resource/)).toBeInTheDocument(),
|
||||
|
|
@ -2072,6 +2101,41 @@ describe("SkillsScreen directory browsing (#2248)", () => {
|
|||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps the pages already shown when Load more fails, and can retry", async () => {
|
||||
// Replacing the state outright made the table vanish and stranded the
|
||||
// reader with no way back to that page short of restarting at the root.
|
||||
const user = userEvent.setup();
|
||||
let fail = true;
|
||||
const onReadResourceDirectory = vi.fn(
|
||||
async (_uri: string, cursor?: string) => {
|
||||
if (cursor === undefined) {
|
||||
return { resources: [CHILD_FILE], nextCursor: "1" } as never;
|
||||
}
|
||||
if (fail) {
|
||||
fail = false;
|
||||
throw new Error("page two exploded");
|
||||
}
|
||||
return { resources: [CHILD_DIR] } as never;
|
||||
},
|
||||
);
|
||||
await openRoot(user, onReadResourceDirectory as never);
|
||||
await user.click(screen.getByRole("button", { name: "Load more" }));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/page two exploded/)).toBeInTheDocument(),
|
||||
);
|
||||
// The first page is still on screen…
|
||||
expect(
|
||||
within(screen.getByTestId("skill-directory")).getByText(CHILD_FILE.uri),
|
||||
).toBeInTheDocument();
|
||||
// …and the cursor survived, so the same page can be retried.
|
||||
await user.click(screen.getByRole("button", { name: "Load more" }));
|
||||
await waitFor(() => {
|
||||
const table = within(screen.getByTestId("skill-directory"));
|
||||
expect(table.getByText(CHILD_FILE.uri)).toBeInTheDocument();
|
||||
expect(table.getByText(CHILD_DIR.uri)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("drops a listing when the selection changes mid-read", async () => {
|
||||
// A read still in flight when the user switches skills must not land
|
||||
// afterwards and paint one skill's tree under another's name.
|
||||
|
|
@ -2089,6 +2153,7 @@ describe("SkillsScreen directory browsing (#2248)", () => {
|
|||
/>,
|
||||
);
|
||||
await user.click(screen.getByText("data-analysis"));
|
||||
await user.click(screen.getByRole("button", { name: /Directory/ }));
|
||||
await user.click(screen.getByRole("button", { name: "Read directory" }));
|
||||
await user.click(screen.getByText("right-name"));
|
||||
release?.({ resources: [CHILD_FILE] });
|
||||
|
|
|
|||
|
|
@ -495,6 +495,27 @@ const ALL_SECTIONS = [
|
|||
"resource",
|
||||
];
|
||||
|
||||
/**
|
||||
* The sections that open by default — everything except Directory.
|
||||
*
|
||||
* Directory is the one section whose content requires a round trip the user has
|
||||
* not made yet, so open it holds a button and an empty frame: it advertises
|
||||
* content that is not there, while taking height from the sections that do have
|
||||
* some. With five open sections in a short pane each is squeezed to its floor
|
||||
* and scrolls internally, which is the documented fallback but a poor first
|
||||
* impression — and the one it costs most is the file viewer, the section
|
||||
* `viewerFlex` exists to give the remainder to.
|
||||
*
|
||||
* The same argument as Conformance's auto-collapse, one step earlier: that one
|
||||
* closes a section whose header already carries the whole answer, this one
|
||||
* closes a section that has no answer yet. Both are defaults; neither prevents
|
||||
* opening it, and `openSections` outlives a selection, so a user who opens
|
||||
* Directory keeps it open across skills.
|
||||
*/
|
||||
const DEFAULT_OPEN_SECTIONS = ALL_SECTIONS.filter(
|
||||
(section) => section !== "directory",
|
||||
);
|
||||
|
||||
/**
|
||||
* The open set for the FIRST render.
|
||||
*
|
||||
|
|
@ -508,13 +529,13 @@ function initialOpenSections(
|
|||
skills: SkillEntry[],
|
||||
selectedSkillUri: string | undefined,
|
||||
): string[] {
|
||||
if (selectedSkillUri === undefined) return ALL_SECTIONS;
|
||||
if (selectedSkillUri === undefined) return DEFAULT_OPEN_SECTIONS;
|
||||
const wanted = skillUriIdentity(selectedSkillUri);
|
||||
const entry = skills.find((skill) => skillUriIdentity(skill.uri) === wanted);
|
||||
if (entry === undefined) return ALL_SECTIONS;
|
||||
if (entry === undefined) return DEFAULT_OPEN_SECTIONS;
|
||||
return checkSkillConformance(entry).length > 0
|
||||
? ALL_SECTIONS
|
||||
: ALL_SECTIONS.filter((section) => section !== "conformance");
|
||||
? DEFAULT_OPEN_SECTIONS
|
||||
: DEFAULT_OPEN_SECTIONS.filter((section) => section !== "conformance");
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -1032,11 +1053,17 @@ export function SkillsScreen({
|
|||
(uri: string, key: string, cursor?: string) => {
|
||||
if (!onReadResourceDirectory) return;
|
||||
const attempt = (nextAttempt.current += 1);
|
||||
const write = (next: Omit<DirectoryState, "key" | "attempt">) =>
|
||||
/**
|
||||
* Commit a settled result, dropping it when it no longer belongs to the
|
||||
* pane on screen — a different skill, or a newer read of this one.
|
||||
*/
|
||||
const commit = (
|
||||
next: (prev: DirectoryState) => Omit<DirectoryState, "key" | "attempt">,
|
||||
) =>
|
||||
setDirectory((prev) => {
|
||||
if (prev.key !== null && prev.key !== key) return prev;
|
||||
if (prev.attempt !== undefined && prev.attempt > attempt) return prev;
|
||||
return { key, attempt, ...next };
|
||||
return { key, attempt, ...next(prev) };
|
||||
});
|
||||
// The path is claimed before the request goes out, so the header names
|
||||
// the directory being read rather than continuing to announce the
|
||||
|
|
@ -1058,26 +1085,28 @@ export function SkillsScreen({
|
|||
// `catch`, which surfaces the message in the section.
|
||||
void onReadResourceDirectory(uri, cursor)
|
||||
.then((page) => {
|
||||
setDirectory((prev) => {
|
||||
if (prev.key !== null && prev.key !== key) return prev;
|
||||
if (prev.attempt !== undefined && prev.attempt > attempt) {
|
||||
return prev;
|
||||
}
|
||||
const held = cursor === undefined ? [] : (prev.children ?? []);
|
||||
return {
|
||||
key,
|
||||
attempt,
|
||||
uri,
|
||||
children: [...held, ...page.resources],
|
||||
nextCursor: page.nextCursor,
|
||||
};
|
||||
});
|
||||
commit((prev) => ({
|
||||
uri,
|
||||
children: [
|
||||
...(cursor === undefined ? [] : (prev.children ?? [])),
|
||||
...page.resources,
|
||||
],
|
||||
nextCursor: page.nextCursor,
|
||||
}));
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
write({
|
||||
// A FAILED page leaves what is already on screen where it is, and
|
||||
// keeps the cursor that would retry it. Replacing the state outright
|
||||
// made the table vanish and stranded the reader with no way back to
|
||||
// that page short of restarting at the root (Copilot). Only a first
|
||||
// read of a directory has nothing to preserve.
|
||||
commit((prev) => ({
|
||||
uri,
|
||||
...(cursor === undefined
|
||||
? {}
|
||||
: { children: prev.children, nextCursor: cursor }),
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}));
|
||||
});
|
||||
},
|
||||
[onReadResourceDirectory],
|
||||
|
|
|
|||
|
|
@ -137,13 +137,87 @@ describe("verifySkills (#2248)", () => {
|
|||
expect(report.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("refuses a block for a DIFFERENT uri rather than verifying it", async () => {
|
||||
// The dangerous shape, and the reason positional selection is wrong: these
|
||||
// bytes are about to be hashed against THIS file's advertised digest, so
|
||||
// accepting a block the server labelled something else verifies one file's
|
||||
// content against another file's digest — and can report that as
|
||||
// `verified`. A false pass is worse than a missing check.
|
||||
const skill = await entry();
|
||||
const readResource = vi.fn(async () => ({
|
||||
result: {
|
||||
contents: [{ uri: "skill://demo/unrelated.md", text: "other bytes" }],
|
||||
},
|
||||
}));
|
||||
const client = { readResource } as unknown as InspectorClientProtocol;
|
||||
const [report] = await verifySkills(client, [skill]);
|
||||
expect(report.files.every((f) => f.status === "read-error")).toBe(true);
|
||||
expect(report.files[0].reason).toMatch(/no content block for this URI/);
|
||||
expect(report.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("finds the matching block when it is not the first one", async () => {
|
||||
// A server may answer with more than one block, in any order; taking
|
||||
// `contents[0]` would hash the wrong file's bytes.
|
||||
const bytes = new TextEncoder().encode(REF);
|
||||
const skill: SkillEntry = {
|
||||
uri: "skill://demo/SKILL.md",
|
||||
frontmatter: { name: "demo", description: "A demo" },
|
||||
resources: [
|
||||
{
|
||||
uri: "skill://demo/ref.md",
|
||||
digest: await sha256Digest(bytes),
|
||||
size: bytes.byteLength,
|
||||
},
|
||||
],
|
||||
};
|
||||
const readResource = vi.fn(async () => ({
|
||||
result: {
|
||||
contents: [
|
||||
{ uri: "skill://demo/decoy.md", text: "decoy" },
|
||||
{ uri: "skill://demo/ref.md", text: REF },
|
||||
],
|
||||
},
|
||||
}));
|
||||
const client = { readResource } as unknown as InspectorClientProtocol;
|
||||
const [report] = await verifySkills(client, [skill]);
|
||||
expect(report.files[0].status).toBe("verified");
|
||||
});
|
||||
|
||||
it("ignores a malformed block while still finding the real one", async () => {
|
||||
const bytes = new TextEncoder().encode(REF);
|
||||
const skill: SkillEntry = {
|
||||
uri: "skill://demo/SKILL.md",
|
||||
frontmatter: { name: "demo", description: "A demo" },
|
||||
resources: [
|
||||
{
|
||||
uri: "skill://demo/ref.md",
|
||||
digest: await sha256Digest(bytes),
|
||||
size: bytes.byteLength,
|
||||
},
|
||||
],
|
||||
};
|
||||
const readResource = vi.fn(async () => ({
|
||||
result: {
|
||||
contents: [
|
||||
null,
|
||||
{ uri: 42 },
|
||||
{ uri: "skill://demo/ref.md", text: REF },
|
||||
],
|
||||
},
|
||||
}));
|
||||
const client = { readResource } as unknown as InspectorClientProtocol;
|
||||
const [report] = await verifySkills(client, [skill]);
|
||||
expect(report.files[0].status).toBe("verified");
|
||||
});
|
||||
|
||||
it("reports a response with no content blocks as a read failure", async () => {
|
||||
const skill = await entry();
|
||||
const readResource = vi.fn(async () => ({ result: { contents: [] } }));
|
||||
const client = { readResource } as unknown as InspectorClientProtocol;
|
||||
const [report] = await verifySkills(client, [skill]);
|
||||
expect(report.files[0]).toMatchObject({ status: "read-error" });
|
||||
expect(report.files[0].reason).toMatch(/no content blocks/);
|
||||
expect(report.files[0].reason).toMatch(/no content block for this URI/);
|
||||
});
|
||||
|
||||
it("reports a block carrying neither text nor blob as a read failure", async () => {
|
||||
|
|
|
|||
|
|
@ -121,8 +121,8 @@ describe("Skills extension over a real transport (#2234)", () => {
|
|||
// below — so a modern page missing the envelope surfaces here as a
|
||||
// rejection rather than as a missing property.
|
||||
//
|
||||
// The fixture pages at two over five skills, so a client that stops
|
||||
// here sees less than half.
|
||||
// The fixture pages at two over six skills, so a client that stops
|
||||
// here sees a third of the catalog.
|
||||
expect(first.skills).toHaveLength(2);
|
||||
expect(first.nextCursor).toBeDefined();
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue