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
4 changed files with 406 additions and 143 deletions
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue