fix: address Copilot review round 2 on #2251
- SkillsScreen: key verdicts and React elements by manifest ROW INDEX,
not URI. The checker deliberately tolerates a duplicated URI so it can
report `duplicate-resource`; a URI key collided those rows into one
verdict, so verifying either updated both and "Verify all" raced two
different digest/size declarations into the same slot.
- SkillsScreen: hold the invalidation generation in React state keyed by
the manifest, not a ref bumped during render. `useValueChange` runs its
callback during render and requires setState-only purity — an abandoned
render left the ref incremented and silently discarded a live
verification.
- SkillsScreen: title a size disagreement "Size mismatch" and render its
reason. `verifySkillResource` catches it before hashing, so the alert
was showing "actual undefined" under "Digest mismatch".
- SkillsScreen: pass `contents` to ContentViewer so a base64 SKILL.md
renders. The text-block form substituted "" and painted a blank preview
for a file verification had just read correctly.
- skills.ts: add `malformed-size` for a size that is not a non-negative
safe integer, and exclude such values from the 16 MiB total. A negative
one could pull the sum back under the limit and hide a violation.
- managedSkillsState: make the overlap guard per-session instead of a
boolean. A disconnect during an in-flight walk left it set, so the
reconnect's own load no-oped and was never retried — permanently, if
the stale request never settled. A stale `finally` can no longer clear
the live session's guard either.
- skillsSchemas: require the `{ skill }` envelope for `skills/get`. The
accepted SEP settles it, and normalizing an inline entry would let a
non-conforming response past the one place that could report it.
- #2248 and the PR description: corrected — they said
`ReadResourceDirectoryResultSchema` was already declared, which round 1
removed. #2248 now owns defining it, and records the frontmatter
cross-check gap too.
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
da8cc166ce
commit
bfeebd440d
11 changed files with 406 additions and 143 deletions
|
|
@ -259,6 +259,82 @@ describe("SkillsScreen", () => {
|
|||
expect(await screen.findAllByText("plain string")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("titles a size disagreement a size mismatch, not a digest one", async () => {
|
||||
// `verifySkillResource` catches a size disagreement BEFORE hashing, so
|
||||
// there is no `actualDigest` — labelling it "Digest mismatch" would render
|
||||
// "actual undefined" and hide the real failure.
|
||||
const user = userEvent.setup();
|
||||
renderWithMantine(
|
||||
<ControlledSkillsScreen
|
||||
skills={[
|
||||
{
|
||||
...CLEAN_SKILL,
|
||||
resources: [{ uri: "skill://data-analysis/SKILL.md", size: 9999 }],
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
await user.click(screen.getByText("data-analysis"));
|
||||
await user.click(screen.getByRole("button", { name: /Verify all/ }));
|
||||
expect(await screen.findByText("Size mismatch")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Digest mismatch")).not.toBeInTheDocument();
|
||||
// The alert states both lengths; the manifest row also shows the declared
|
||||
// one, hence `getAllByText`.
|
||||
expect(screen.getAllByText(/9999 bytes/).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("gives duplicated manifest URIs their own row and their own verdict", async () => {
|
||||
// The conformance checker reports `duplicate-resource` rather than
|
||||
// collapsing the rows, so the verdicts must not collapse either: the two
|
||||
// entries declare different digests and only one of them is right.
|
||||
const user = userEvent.setup();
|
||||
renderWithMantine(
|
||||
<ControlledSkillsScreen
|
||||
skills={[
|
||||
{
|
||||
...CLEAN_SKILL,
|
||||
resources: [
|
||||
{
|
||||
uri: "skill://data-analysis/SKILL.md",
|
||||
digest: SELF_DIGEST,
|
||||
size: textToBytes(SELF_TEXT).byteLength,
|
||||
},
|
||||
{
|
||||
uri: "skill://data-analysis/SKILL.md",
|
||||
digest: `sha256:${"d".repeat(64)}`,
|
||||
size: textToBytes(SELF_TEXT).byteLength,
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
await user.click(screen.getByText("data-analysis"));
|
||||
await user.click(screen.getByRole("button", { name: /Verify all/ }));
|
||||
// One row verifies and the other does not — a shared key would have made
|
||||
// both show whichever landed last.
|
||||
expect(await screen.findByText("verified")).toBeInTheDocument();
|
||||
expect(screen.getByText("mismatch")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a base64 SKILL.md preview instead of a blank one", async () => {
|
||||
// `onReadSkillFile` supports blob content, and verification reads it
|
||||
// correctly; dropping it in the preview would paint an empty box for a
|
||||
// file the screen had just checked.
|
||||
const user = userEvent.setup();
|
||||
const onReadSkillFile = vi.fn().mockResolvedValue({
|
||||
blob: btoa("# from a blob\n"),
|
||||
mimeType: "text/markdown",
|
||||
});
|
||||
renderWithMantine(
|
||||
<ControlledSkillsScreen onReadSkillFile={onReadSkillFile} />,
|
||||
);
|
||||
await user.click(screen.getByText("data-analysis"));
|
||||
await user.click(screen.getByRole("button", { name: /View SKILL.md/ }));
|
||||
const preview = await screen.findByTestId("skill-md-preview");
|
||||
expect(preview).toHaveTextContent("from a blob");
|
||||
});
|
||||
|
||||
it("shows the SKILL.md preview on demand", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithMantine(<ControlledSkillsScreen />);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
|
|
@ -42,12 +42,37 @@ import {
|
|||
*/
|
||||
const VERIFY_CONCURRENCY = 4;
|
||||
|
||||
/** Per-file verification progress, keyed by the manifest entry's URI. */
|
||||
/** Per-row verification progress. */
|
||||
type FileState =
|
||||
| { status: "pending" }
|
||||
| { status: "done"; verification: SkillVerification }
|
||||
| { status: "error"; message: string };
|
||||
|
||||
/**
|
||||
* Verification verdicts plus the manifest they belong to. Rows are keyed by
|
||||
* their **index**, not their URI: the conformance checker deliberately tolerates
|
||||
* a duplicated URI so it can report `duplicate-resource`, and a URI key would
|
||||
* collide those two rows into one verdict.
|
||||
*/
|
||||
interface VerificationState {
|
||||
/**
|
||||
* The manifest these verdicts belong to, or `null` before anything has been
|
||||
* verified. `useValueChange` deliberately does not fire on the first render,
|
||||
* so `null` stands in for "the initial manifest, not yet adopted" — the first
|
||||
* write claims it. Once set it is only ever replaced by an invalidation, so a
|
||||
* stale continuation can never be mistaken for an initial one.
|
||||
*/
|
||||
key: string | null;
|
||||
files: Record<number, FileState>;
|
||||
}
|
||||
|
||||
/** The SKILL.md preview, plus the manifest it belongs to (`null` as above). */
|
||||
interface PreviewState {
|
||||
key: string | null;
|
||||
contents?: SkillFileContents;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface SkillsScreenProps {
|
||||
skills: SkillEntry[];
|
||||
/** Pages the last `skills/list` walk took; shown so pagination is visible. */
|
||||
|
|
@ -239,15 +264,19 @@ export function SkillsScreen({
|
|||
onReadSkillFile,
|
||||
}: SkillsScreenProps) {
|
||||
const { selectedSkillUri, search } = ui;
|
||||
const [fileStates, setFileStates] = useState<Record<string, FileState>>({});
|
||||
const [preview, setPreview] = useState<SkillFileContents | null>(null);
|
||||
const [previewError, setPreviewError] = useState<string | null>(null);
|
||||
// Bumped every time the verdicts are invalidated. A read that was already in
|
||||
// flight captures the value it started under and discards its result when
|
||||
// this has moved on — otherwise a slow fetch for the previous selection (or
|
||||
// the previous manifest) lands afterwards and writes a verdict for content
|
||||
// nobody is looking at, or worse, one that was never checked.
|
||||
const epoch = useRef(0);
|
||||
// Both slices carry the manifest key they belong to, and every async
|
||||
// continuation writes through a functional update that compares it. That is
|
||||
// what discards a read still in flight when the selection changes or a
|
||||
// Refresh replaces the manifest — without it, a slow fetch lands afterwards
|
||||
// and writes a verdict for content nobody is looking at, or one that was
|
||||
// never checked. Storing the key IN the state (rather than bumping a ref
|
||||
// during render) keeps the `useValueChange` callback to `setState` calls
|
||||
// only, which is the purity that hook documents and requires.
|
||||
const [verification, setVerification] = useState<VerificationState>({
|
||||
key: null,
|
||||
files: {},
|
||||
});
|
||||
const [previewState, setPreviewState] = useState<PreviewState>({ key: null });
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const needle = search.trim().toLowerCase();
|
||||
|
|
@ -295,43 +324,45 @@ export function SkillsScreen({
|
|||
|
||||
// Adjusted DURING RENDER via `useValueChange` rather than in an effect, so a
|
||||
// new selection (or a refreshed manifest) never paints a frame carrying the
|
||||
// previous one's verification results. The epoch bump is a ref write, which
|
||||
// is why it is done in the callback alongside the state resets rather than
|
||||
// during the render body itself.
|
||||
useValueChange(manifestKey, () => {
|
||||
epoch.current += 1;
|
||||
setFileStates({});
|
||||
setPreview(null);
|
||||
setPreviewError(null);
|
||||
// previous one's verification results. `setState` calls only — the hook
|
||||
// replays this callback whenever React replays the render.
|
||||
useValueChange(manifestKey, (next) => {
|
||||
setVerification({ key: next, files: {} });
|
||||
setPreviewState({ key: next });
|
||||
});
|
||||
|
||||
const verifyFile = useCallback(
|
||||
async (resource: SkillResource) => {
|
||||
const started = epoch.current;
|
||||
setFileStates((prev) => ({
|
||||
...prev,
|
||||
[resource.uri]: { status: "pending" },
|
||||
}));
|
||||
const fileStates = verification.key === manifestKey ? verification.files : {};
|
||||
|
||||
/**
|
||||
* Verify one manifest ROW. Keyed by row index, not by URI: the checker
|
||||
* deliberately tolerates a duplicated URI so it can report
|
||||
* `duplicate-resource`, and two rows sharing a key would share one verdict —
|
||||
* verifying either would update both, and "Verify all" would race two
|
||||
* different digest/size declarations into the same slot.
|
||||
*/
|
||||
const verifyRow = useCallback(
|
||||
async (index: number, resource: SkillResource, key: string) => {
|
||||
const write = (state: FileState) =>
|
||||
setVerification((prev) => {
|
||||
// `null` is the un-adopted initial manifest; any other mismatch is a
|
||||
// continuation from a manifest that has since been invalidated.
|
||||
if (prev.key !== null && prev.key !== key) return prev;
|
||||
const files = prev.key === key ? prev.files : {};
|
||||
return { key, files: { ...files, [index]: state } };
|
||||
});
|
||||
write({ status: "pending" });
|
||||
try {
|
||||
const contents = await onReadSkillFile(resource.uri);
|
||||
const verification = await verifySkillResource(
|
||||
const result = await verifySkillResource(
|
||||
resource,
|
||||
skillFileBytes(contents),
|
||||
);
|
||||
if (epoch.current !== started) return;
|
||||
setFileStates((prev) => ({
|
||||
...prev,
|
||||
[resource.uri]: { status: "done", verification },
|
||||
}));
|
||||
write({ status: "done", verification: result });
|
||||
} catch (err) {
|
||||
if (epoch.current !== started) return;
|
||||
setFileStates((prev) => ({
|
||||
...prev,
|
||||
[resource.uri]: {
|
||||
status: "error",
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
}));
|
||||
write({
|
||||
status: "error",
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
},
|
||||
[onReadSkillFile],
|
||||
|
|
@ -348,34 +379,42 @@ export function SkillsScreen({
|
|||
// records them as per-row state), and this handler cannot be async, so the
|
||||
// settled promise is discarded explicitly at one place instead of per file.
|
||||
let next = 0;
|
||||
const key = manifestKey;
|
||||
const worker = async (): Promise<void> => {
|
||||
for (let i = next++; i < manifest.length; i = next++) {
|
||||
await verifyFile(manifest[i]);
|
||||
await verifyRow(i, manifest[i], key);
|
||||
}
|
||||
};
|
||||
const workers = Math.min(VERIFY_CONCURRENCY, manifest.length);
|
||||
void Promise.all(Array.from({ length: workers }, () => worker()));
|
||||
}, [manifest, verifyFile]);
|
||||
}, [manifest, manifestKey, verifyRow]);
|
||||
|
||||
const showSkillMd = useCallback(() => {
|
||||
if (!selected) return;
|
||||
const started = epoch.current;
|
||||
const key = manifestKey;
|
||||
// A click handler cannot await, and this chain terminates in its own
|
||||
// `catch` that surfaces the message in the preview slot. Both arms are
|
||||
// epoch-guarded: a read that resolves after the selection moved on would
|
||||
// otherwise show one skill's SKILL.md under another's heading.
|
||||
// `catch` that surfaces the message in the preview slot. Both arms compare
|
||||
// the manifest key they started under: a read that resolves after the
|
||||
// selection moved on would otherwise show one skill's SKILL.md under
|
||||
// another's heading.
|
||||
void onReadSkillFile(selected.uri)
|
||||
.then((contents) => {
|
||||
if (epoch.current !== started) return;
|
||||
setPreview(contents);
|
||||
setPreviewError(null);
|
||||
setPreviewState((prev) =>
|
||||
prev.key !== null && prev.key !== key ? prev : { key, contents },
|
||||
);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (epoch.current !== started) return;
|
||||
setPreview(null);
|
||||
setPreviewError(err instanceof Error ? err.message : String(err));
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
setPreviewState((prev) =>
|
||||
prev.key !== null && prev.key !== key ? prev : { key, message },
|
||||
);
|
||||
});
|
||||
}, [onReadSkillFile, selected]);
|
||||
}, [manifestKey, onReadSkillFile, selected]);
|
||||
|
||||
const preview =
|
||||
previewState.key === manifestKey ? previewState.contents : undefined;
|
||||
const previewError =
|
||||
previewState.key === manifestKey ? previewState.message : undefined;
|
||||
|
||||
const errorCount = issues.filter((i) => i.severity === "error").length;
|
||||
const warningCount = issues.length - errorCount;
|
||||
|
|
@ -527,8 +566,8 @@ export function SkillsScreen({
|
|||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{manifest.map((resource) => {
|
||||
const state = fileStates[resource.uri];
|
||||
{manifest.map((resource, index) => {
|
||||
const state = fileStates[index];
|
||||
const color =
|
||||
state?.status === "done"
|
||||
? verificationColor(state.verification.status)
|
||||
|
|
@ -536,7 +575,10 @@ export function SkillsScreen({
|
|||
? "red"
|
||||
: "gray";
|
||||
return (
|
||||
<Table.Tr key={resource.uri}>
|
||||
// Index-keyed for the same reason the verdicts are:
|
||||
// a duplicated URI is a case this screen reports, so
|
||||
// it must not also collide two rows into one.
|
||||
<Table.Tr key={index}>
|
||||
<Table.Td>{resource.uri}</Table.Td>
|
||||
<Table.Td>{resource.size ?? "—"}</Table.Td>
|
||||
<Table.Td>{shortDigest(resource.digest)}</Table.Td>
|
||||
|
|
@ -549,7 +591,9 @@ export function SkillsScreen({
|
|||
// A click handler cannot await, and
|
||||
// `verifyFile` owns its own failures — it
|
||||
// records them as this row's state.
|
||||
onClick={() => void verifyFile(resource)}
|
||||
onClick={() =>
|
||||
void verifyRow(index, resource, manifestKey)
|
||||
}
|
||||
>
|
||||
Verify
|
||||
</RowVerifyButton>
|
||||
|
|
@ -561,35 +605,45 @@ export function SkillsScreen({
|
|||
</Table.Tbody>
|
||||
</ManifestTable>
|
||||
)}
|
||||
{manifest.map((resource) => {
|
||||
const state = fileStates[resource.uri];
|
||||
{manifest.map((resource, index) => {
|
||||
const state = fileStates[index];
|
||||
if (state?.status === "done") {
|
||||
const { verification } = state;
|
||||
if (verification.status === "mismatch") {
|
||||
return (
|
||||
<Alert
|
||||
key={`mismatch:${resource.uri}`}
|
||||
color="red"
|
||||
title="Digest mismatch"
|
||||
>
|
||||
<Stack gap={2}>
|
||||
<MonoCaption>{resource.uri}</MonoCaption>
|
||||
<MonoCaption>
|
||||
expected {verification.expectedDigest}
|
||||
</MonoCaption>
|
||||
<MonoCaption>
|
||||
actual {verification.actualDigest}
|
||||
</MonoCaption>
|
||||
</Stack>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
const result = state.verification;
|
||||
if (result.status !== "mismatch") return null;
|
||||
// A size disagreement is caught BEFORE hashing, so it has
|
||||
// no `actualDigest` — titling it "Digest mismatch" and
|
||||
// rendering "actual undefined" would hide the real failure.
|
||||
const sizeFailure = result.actualDigest === undefined;
|
||||
return (
|
||||
<Alert
|
||||
key={`mismatch:${index}`}
|
||||
color="red"
|
||||
title={
|
||||
sizeFailure ? "Size mismatch" : "Digest mismatch"
|
||||
}
|
||||
>
|
||||
<Stack gap={2}>
|
||||
<MonoCaption>{resource.uri}</MonoCaption>
|
||||
{sizeFailure ? (
|
||||
<Text size="sm">{result.reason}</Text>
|
||||
) : (
|
||||
<>
|
||||
<MonoCaption>
|
||||
expected {result.expectedDigest}
|
||||
</MonoCaption>
|
||||
<MonoCaption>
|
||||
actual {result.actualDigest}
|
||||
</MonoCaption>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
if (state?.status === "error") {
|
||||
return (
|
||||
<Alert
|
||||
key={`read:${resource.uri}`}
|
||||
key={`read:${index}`}
|
||||
color="red"
|
||||
title="Could not read file"
|
||||
>
|
||||
|
|
@ -612,9 +666,24 @@ export function SkillsScreen({
|
|||
{preview && (
|
||||
<Stack gap="xs" data-testid="skill-md-preview">
|
||||
<SectionHeading>SKILL.md</SectionHeading>
|
||||
{/* `contents`, not a text `block`: a server may serve
|
||||
SKILL.md as a base64 `blob`, and the block form would
|
||||
substitute an empty string and paint a blank preview for
|
||||
a file verification just read correctly. */}
|
||||
<ContentViewer
|
||||
block={{ type: "text", text: preview.text ?? "" }}
|
||||
mimeType={preview.mimeType ?? "text/markdown"}
|
||||
contents={
|
||||
typeof preview.text === "string"
|
||||
? {
|
||||
uri: selected.uri,
|
||||
text: preview.text,
|
||||
mimeType: preview.mimeType ?? "text/markdown",
|
||||
}
|
||||
: {
|
||||
uri: selected.uri,
|
||||
blob: preview.blob ?? "",
|
||||
mimeType: preview.mimeType ?? "text/markdown",
|
||||
}
|
||||
}
|
||||
copyable
|
||||
/>
|
||||
</Stack>
|
||||
|
|
|
|||
|
|
@ -115,18 +115,20 @@ describe("InspectorClient skills methods (#2234)", () => {
|
|||
expect(request.mock.calls[0][0].params.uri).toBe("skill://demo/SKILL.md");
|
||||
});
|
||||
|
||||
it("normalizes the enveloped skills/get result to the entry", async () => {
|
||||
it("unwraps the skills/get envelope to the entry", async () => {
|
||||
const client = makeClient();
|
||||
stubRequest(client, { skill: ENTRY });
|
||||
expect(await client.getSkill("skill://demo/SKILL.md")).toEqual(ENTRY);
|
||||
});
|
||||
|
||||
it("normalizes the inline skills/get result to the entry", async () => {
|
||||
// The SEP settles the entry shape but not the envelope; a server that
|
||||
// returns the entry at the top level must not fail here.
|
||||
it("rejects a skills/get result returned without its envelope", async () => {
|
||||
// Normalizing it would let a non-conforming server through the one place
|
||||
// that could have reported it.
|
||||
const client = makeClient();
|
||||
stubRequest(client, ENTRY);
|
||||
expect(await client.getSkill("skill://demo/SKILL.md")).toEqual(ENTRY);
|
||||
await expect(
|
||||
client.getSkill("skill://demo/SKILL.md"),
|
||||
).rejects.toBeDefined();
|
||||
});
|
||||
|
||||
it("rejects a skills/list result that is not a skills page", async () => {
|
||||
|
|
|
|||
|
|
@ -295,6 +295,39 @@ describe("checkSkillConformance", () => {
|
|||
}
|
||||
});
|
||||
|
||||
it("reports a size that is not a non-negative integer byte length", () => {
|
||||
for (const size of [-1, 1.5, Number.NaN, Number.MAX_SAFE_INTEGER + 2]) {
|
||||
const issues = checkSkillConformance(
|
||||
entry({
|
||||
resources: [
|
||||
{ uri: "skill://demo/SKILL.md", digest: DIGEST, size: 20 },
|
||||
{ uri: "skill://demo/ref.md", digest: DIGEST, size },
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(issues.map((i) => i.code)).toEqual(["malformed-size"]);
|
||||
expect(issues[0].severity).toBe("error");
|
||||
}
|
||||
});
|
||||
|
||||
it("a negative size cannot pull the total back under the 16 MiB limit", () => {
|
||||
// The reason `malformed-size` is an error and not just noise: summing a
|
||||
// negative would hide a genuine `size-limit-exceeded`.
|
||||
const issues = checkSkillConformance(
|
||||
entry({
|
||||
resources: [
|
||||
{
|
||||
uri: "skill://demo/SKILL.md",
|
||||
digest: DIGEST,
|
||||
size: SKILL_MAX_TOTAL_BYTES + 1,
|
||||
},
|
||||
{ uri: "skill://demo/ref.md", digest: DIGEST, size: -1000 },
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(issues.map((i) => i.code)).toContain("size-limit-exceeded");
|
||||
});
|
||||
|
||||
it("reports a manifest over the 512-entry limit", () => {
|
||||
const resources = [
|
||||
{ uri: "skill://demo/SKILL.md", digest: DIGEST, size: 20 },
|
||||
|
|
@ -350,6 +383,20 @@ describe("totalSkillBytes", () => {
|
|||
]),
|
||||
).toBe(15);
|
||||
});
|
||||
|
||||
it("excludes an unusable size rather than summing it", () => {
|
||||
// An incomplete manifest may only ever *understate* the total, which is
|
||||
// what keeps the limit check free of false positives. A negative or
|
||||
// fractional value would break that.
|
||||
expect(
|
||||
totalSkillBytes([
|
||||
{ uri: "a", size: 10 },
|
||||
{ uri: "b", size: -100 },
|
||||
{ uri: "c", size: 2.5 },
|
||||
{ uri: "d", size: Number.NaN },
|
||||
]),
|
||||
).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe("byte helpers", () => {
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ import {
|
|||
SKILLS_GET_METHOD,
|
||||
SKILLS_LIST_METHOD,
|
||||
SkillEntrySchema,
|
||||
normalizeGetSkillResult,
|
||||
} from "@inspector/core/mcp/skillsSchemas";
|
||||
|
||||
const ENTRY = {
|
||||
|
|
@ -89,20 +88,24 @@ describe("ListSkillsResultSchema", () => {
|
|||
});
|
||||
|
||||
describe("GetSkillResultSchema", () => {
|
||||
it("normalizes the enveloped form to the entry", () => {
|
||||
it("unwraps the envelope to the entry", () => {
|
||||
expect(GetSkillResultSchema.parse({ skill: ENTRY })).toEqual(ENTRY);
|
||||
});
|
||||
|
||||
it("normalizes the inline form to the entry", () => {
|
||||
expect(GetSkillResultSchema.parse(ENTRY)).toEqual(ENTRY);
|
||||
});
|
||||
|
||||
it("normalizeGetSkillResult accepts either shape directly", () => {
|
||||
expect(normalizeGetSkillResult({ skill: ENTRY })).toEqual(ENTRY);
|
||||
expect(normalizeGetSkillResult(ENTRY)).toEqual(ENTRY);
|
||||
it("rejects an entry returned inline rather than normalizing it", () => {
|
||||
// The envelope is required. Accepting the inline form would silently
|
||||
// normalize a non-conforming response, which is the failure this
|
||||
// extension's support exists to report.
|
||||
expect(() => GetSkillResultSchema.parse(ENTRY)).toThrow();
|
||||
});
|
||||
|
||||
it("rejects a result that is neither shape", () => {
|
||||
expect(() => GetSkillResultSchema.parse({ nothing: true })).toThrow();
|
||||
});
|
||||
|
||||
it("rejects an envelope whose skill is not an entry", () => {
|
||||
expect(() =>
|
||||
GetSkillResultSchema.parse({ skill: { frontmatter: {} } }),
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -142,6 +142,52 @@ describe("ManagedSkillsState", () => {
|
|||
expect(state.getPagination()).toEqual({ pageCount: 0 });
|
||||
});
|
||||
|
||||
it("lets a reconnect load its skills while a stale walk is still hanging", async () => {
|
||||
client.setStatus("connected");
|
||||
// The first walk never settles. A boolean overlap guard would stay set,
|
||||
// so the reconnect's own load would no-op and never be retried — the
|
||||
// reconnect would show an empty Skills tab forever.
|
||||
client.listSkills.mockImplementationOnce(() => new Promise(() => {}));
|
||||
void state.refresh().catch(() => {});
|
||||
await client.disconnect();
|
||||
|
||||
client.skillPages = [{ skills: [skill("fresh")] }];
|
||||
await client.connect();
|
||||
expect(state.getSkills().map((s) => s.frontmatter.name)).toEqual(["fresh"]);
|
||||
});
|
||||
|
||||
it("a stale walk settling later cannot release the live session's guard", async () => {
|
||||
client.setStatus("connected");
|
||||
let release: ((value: { skills: SkillEntry[] }) => void) | undefined;
|
||||
client.listSkills.mockImplementationOnce(
|
||||
async () =>
|
||||
new Promise((resolve) => {
|
||||
release = resolve;
|
||||
}),
|
||||
);
|
||||
const stale = state.refresh();
|
||||
await client.disconnect();
|
||||
client.setStatus("connected");
|
||||
|
||||
// A live walk is now in flight under the new generation.
|
||||
let releaseLive: ((value: { skills: SkillEntry[] }) => void) | undefined;
|
||||
client.listSkills.mockImplementationOnce(
|
||||
async () =>
|
||||
new Promise((resolve) => {
|
||||
releaseLive = resolve;
|
||||
}),
|
||||
);
|
||||
const live = state.refresh();
|
||||
// The stale one settles first; its `finally` must not free the guard.
|
||||
release?.({ skills: [] });
|
||||
await stale;
|
||||
const blocked = await state.refresh();
|
||||
expect(blocked).toEqual([]);
|
||||
releaseLive?.({ skills: [skill("live")] });
|
||||
await live;
|
||||
expect(state.getSkills().map((s) => s.frontmatter.name)).toEqual(["live"]);
|
||||
});
|
||||
|
||||
it("does not surface a dead session's failure in the live one", async () => {
|
||||
client.setStatus("connected");
|
||||
let fail: ((err: Error) => void) | undefined;
|
||||
|
|
|
|||
|
|
@ -5577,9 +5577,10 @@ export class InspectorClient extends InspectorClientEventTarget {
|
|||
}
|
||||
|
||||
/**
|
||||
* One skill entry by URI (`skills/get`, SEP-2640). The SEP settles the entry
|
||||
* shape but not the envelope around it, so the result is normalized through
|
||||
* {@link normalizeGetSkillResult} rather than assuming one form.
|
||||
* One skill entry by URI (`skills/get`, SEP-2640). The result envelope is
|
||||
* required — `GetSkillResultSchema` unwraps `{ skill }` and rejects an entry
|
||||
* returned inline, so a non-conforming shape fails here rather than being
|
||||
* silently normalized past the conformance checks.
|
||||
*/
|
||||
async getSkill(uri: string, metadata?: RequestMetadata): Promise<SkillEntry> {
|
||||
if (!this.client) {
|
||||
|
|
@ -5590,8 +5591,8 @@ export class InspectorClient extends InspectorClientEventTarget {
|
|||
uri,
|
||||
...(effectiveMeta ? { _meta: effectiveMeta } : {}),
|
||||
};
|
||||
// `GetSkillResultSchema` normalizes both accepted envelopes to the entry,
|
||||
// so there is nothing to unwrap here.
|
||||
// `GetSkillResultSchema` unwraps the envelope, so there is nothing to
|
||||
// unwrap here.
|
||||
return this.invokeMcpClient(
|
||||
() =>
|
||||
this.client!.request(
|
||||
|
|
|
|||
|
|
@ -126,6 +126,7 @@ export type SkillIssueCode =
|
|||
| "missing-digest"
|
||||
| "malformed-digest"
|
||||
| "missing-size"
|
||||
| "malformed-size"
|
||||
| "duplicate-resource"
|
||||
| "resource-outside-skill-root"
|
||||
| "manifest-missing-self"
|
||||
|
|
@ -288,6 +289,13 @@ export function checkSkillConformance(entry: SkillEntry): SkillIssue[] {
|
|||
"Manifest entry declares no size, so it is excluded from the 16 MiB total and its length cannot be cross-checked.",
|
||||
resourceUri: resource.uri,
|
||||
});
|
||||
} else if (!isUsableSize(resource.size)) {
|
||||
issues.push({
|
||||
code: "malformed-size",
|
||||
severity: "error",
|
||||
message: `Size ${resource.size} is not a non-negative integer byte length.`,
|
||||
resourceUri: resource.uri,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -295,13 +303,28 @@ export function checkSkillConformance(entry: SkillEntry): SkillIssue[] {
|
|||
}
|
||||
|
||||
/**
|
||||
* Sum of the manifest's declared `size` fields. An entry that omits `size`
|
||||
* contributes nothing rather than failing the sum — the limit check is about
|
||||
* catching a server that is demonstrably over, and an incomplete manifest can
|
||||
* only ever understate the total, so this never produces a false positive.
|
||||
* Whether a declared `size` is a usable byte length. SEP-2640 defines it as the
|
||||
* raw byte count, so anything that is not a non-negative safe integer is
|
||||
* nonsense — and a *negative* one is worse than nonsense, because summing it
|
||||
* would pull the manifest total back under the 16 MiB limit and hide a
|
||||
* violation. Reported as `malformed-size` and excluded from the sum.
|
||||
*/
|
||||
function isUsableSize(size: number | undefined): size is number {
|
||||
return size !== undefined && Number.isSafeInteger(size) && size >= 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sum of the manifest's declared `size` fields. An entry that omits `size` — or
|
||||
* declares an unusable one — contributes nothing rather than failing the sum:
|
||||
* the limit check is about catching a server that is demonstrably over, and an
|
||||
* incomplete manifest can only ever understate the total, so this never
|
||||
* produces a false positive.
|
||||
*/
|
||||
export function totalSkillBytes(resources: readonly SkillResource[]): number {
|
||||
return resources.reduce((sum, r) => sum + (r.size ?? 0), 0);
|
||||
return resources.reduce(
|
||||
(sum, r) => sum + (isUsableSize(r.size) ? r.size : 0),
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
/** Outcome of comparing a fetched file against its advertised digest. */
|
||||
|
|
|
|||
|
|
@ -105,39 +105,26 @@ export const ListSkillsResultSchema = z.looseObject({
|
|||
export type ListSkillsResult = z.infer<typeof ListSkillsResultSchema>;
|
||||
|
||||
/**
|
||||
* The envelope form of a `skills/get` result: the entry wrapped under `skill`.
|
||||
* The `skills/get` result envelope: the entry wrapped under `skill`.
|
||||
*
|
||||
* Required, not one of two accepted shapes. An earlier revision of this module
|
||||
* also accepted a bare entry at the top level, on the reading that the SEP
|
||||
* settled the entry but not its wrapper. It does settle the wrapper, and
|
||||
* accepting the inline form would silently normalize a non-conforming response
|
||||
* — which is exactly the failure this extension's support exists to *report*.
|
||||
* A server that returns the entry inline now fails the parse, loudly.
|
||||
*/
|
||||
const GetSkillEnvelopeSchema = z.looseObject({ skill: SkillEntrySchema });
|
||||
|
||||
/**
|
||||
* Collapse either accepted `skills/get` shape to the entry it carries.
|
||||
* `skills/get` result, unwrapped to the entry it carries.
|
||||
*
|
||||
* Written as a parse rather than an `in` check because both accepted shapes are
|
||||
* loose objects — they carry an index signature, so `"skill" in result` narrows
|
||||
* nothing and would leave the extracted value `unknown`. Parsing the envelope
|
||||
* is what proves its `skill` really is an entry, with no cast anywhere.
|
||||
* The transform means every caller receives a `SkillEntry` and none of them
|
||||
* reaches into the envelope; the strictness lives in the schema.
|
||||
*/
|
||||
export function normalizeGetSkillResult(result: unknown): SkillEntry {
|
||||
const enveloped = GetSkillEnvelopeSchema.safeParse(result);
|
||||
return enveloped.success
|
||||
? enveloped.data.skill
|
||||
: SkillEntrySchema.parse(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* `skills/get` result, normalized to the entry.
|
||||
*
|
||||
* ⚠️ The SEP settles the *entry* shape but not the envelope this result wraps it
|
||||
* in, so both plausible forms are accepted: `{ skill: <entry> }` and the entry
|
||||
* returned inline at the top level. Being permissive here costs nothing (the
|
||||
* two are structurally distinguishable — an inline entry carries `uri` and
|
||||
* `frontmatter`, the envelope carries neither) and spares a server author a
|
||||
* failure whose cause is a spec ambiguity rather than their code. The transform
|
||||
* means every caller receives the entry and none of them branches.
|
||||
*/
|
||||
export const GetSkillResultSchema = z
|
||||
.union([GetSkillEnvelopeSchema, SkillEntrySchema])
|
||||
.transform(normalizeGetSkillResult);
|
||||
export const GetSkillResultSchema = GetSkillEnvelopeSchema.transform(
|
||||
(result) => result.skill,
|
||||
);
|
||||
|
||||
export type GetSkillResult = SkillEntry;
|
||||
|
||||
|
|
|
|||
|
|
@ -63,9 +63,9 @@ export class ManagedSkillsState extends TypedEventTarget<ManagedSkillsStateEvent
|
|||
private error: Error | null = null;
|
||||
private client: InspectorClientProtocol | null = null;
|
||||
private unsubscribe: (() => void) | null = null;
|
||||
// Overlap guard: a walk in flight makes a second one a no-op so a slow older
|
||||
// walk can't clobber a newer list via last-write-wins.
|
||||
private running = false;
|
||||
// Overlap guard, held as the generation whose walk is in flight (or `null`).
|
||||
// See `refresh` for why this is not a boolean.
|
||||
private runningGeneration: number | null = null;
|
||||
// Session counter, advanced by `reset` (disconnect) and by `destroy`. A walk
|
||||
// captures it and abandons its writes when it no longer matches.
|
||||
private generation = 0;
|
||||
|
|
@ -151,8 +151,6 @@ export class ManagedSkillsState extends TypedEventTarget<ManagedSkillsStateEvent
|
|||
this.applyPages([], 0);
|
||||
return this.getSkills();
|
||||
}
|
||||
if (this.running) return this.getSkills();
|
||||
this.running = true;
|
||||
// The session this walk belongs to. A disconnect, a `destroy()`, or a
|
||||
// reconnect on the same client advances it, and every write below is
|
||||
// gated on it still being current — otherwise a slow walk's continuation
|
||||
|
|
@ -160,6 +158,14 @@ export class ManagedSkillsState extends TypedEventTarget<ManagedSkillsStateEvent
|
|||
// previous session's skills into the next one.
|
||||
const generation = this.generation;
|
||||
const isCurrent = () => this.generation === generation;
|
||||
// The overlap guard is per SESSION, not a bare boolean. A boolean would
|
||||
// stay set while a walk from a dead session was still awaiting the server,
|
||||
// so the reconnect's own load would return here as a no-op and never be
|
||||
// retried — permanently, if that stale request never settles. Keying it on
|
||||
// the generation lets a new session start immediately, and the `finally`
|
||||
// below only clears the guard it actually set.
|
||||
if (this.runningGeneration === generation) return this.getSkills();
|
||||
this.runningGeneration = generation;
|
||||
try {
|
||||
const collected: SkillEntry[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
|
@ -199,7 +205,11 @@ export class ManagedSkillsState extends TypedEventTarget<ManagedSkillsStateEvent
|
|||
}
|
||||
throw err;
|
||||
} finally {
|
||||
this.running = false;
|
||||
// Only the walk that set the guard may clear it: a stale session's
|
||||
// `finally` must not release the guard a live session is holding.
|
||||
if (this.runningGeneration === generation) {
|
||||
this.runningGeneration = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -216,9 +216,8 @@ export function getSkillEntry(uri: string): Record<string, unknown> {
|
|||
(candidate) => `skill://${candidate.path}/SKILL.md` === uri,
|
||||
);
|
||||
if (!skill) throw new Error(`Unknown skill uri: ${uri}`);
|
||||
// The envelope form (`{ skill }`) is served deliberately: the SEP settles the
|
||||
// entry shape but not this wrapper, and the Inspector accepts both — serving
|
||||
// the wrapped one keeps that tolerance exercised.
|
||||
// The envelope (`{ skill }`) is the conforming shape, and the only one the
|
||||
// Inspector accepts — see `GetSkillResultSchema`.
|
||||
return { skill: toEntry(skill) };
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue