feat: require the modern list envelope on skills/list (review round 14)

The review finally quoted the normative text, which is what I asked for
across five rounds before declining: SEP-2640's `skills/list` section says
"In protocol versions 2026-07-28 and later, the result also carries …
`ttlMs` and `cacheScope`." So the era-aware validation is in.

- skillsSchemas: `ModernListSkillsResultSchema` = the page plus the base
  list envelope, with field shapes mirroring `ModernResultEnvelopeSchema`
  in `listSalvage.ts` so this repo's two statements of a modern envelope
  cannot drift. `InspectorClient.listSkills` picks it from the negotiated
  era; the legacy shape stays permissive, because those are 2026-era
  attributes a legacy server has no business sending.
  Nothing else catches this: `skills/*` is consumer-owned, so it is absent
  from the SDK's cacheable-method registry and a modern server answering
  `{ skills: [] }` was reaching the conformance UI as a clean list.
- skills.ts: count `frontmatter.description` (and `name`) length in Unicode
  CODE POINTS. `String.length` counts UTF-16 code units, so 600 non-BMP
  characters measured as 1200 and a perfectly valid description was
  reported `malformed-description` — a conforming server failed by an
  off-by-encoding, which is the direction this module works hardest to
  avoid. The Agent Skills reference validator uses Python `len()`.
- #2248 narrowed: the `skills/list` half is settled here; what remains is
  whether `skills/get` carries the same attributes.

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:
cliffhall 2026-09-05 02:01:18 -04:00
commit 11ac134975
3 changed files with 178 additions and 27 deletions

View file

@ -27,6 +27,7 @@ describe("InspectorClient skills methods (#2234)", () => {
};
interface SkillsInternals {
protocolEra: string | undefined;
client: {
request: (
req: { method: string; params: Record<string, unknown> },
@ -146,6 +147,40 @@ describe("InspectorClient skills methods (#2234)", () => {
).rejects.toBeDefined();
});
it("requires the modern list envelope on a modern connection", async () => {
// SEP-2640: "In protocol versions 2026-07-28 and later, the result also
// carries … `ttlMs` and `cacheScope`." Nothing else validates it —
// `skills/*` is consumer-owned, so the SDK codec never sees it.
const client = makeClient();
internals(client).protocolEra = "modern";
stubRequest(client, { skills: [] });
await expect(client.listSkills()).rejects.toBeDefined();
});
it("accepts a modern result that carries the envelope", async () => {
const client = makeClient();
internals(client).protocolEra = "modern";
stubRequest(client, {
resultType: "complete",
ttlMs: 0,
cacheScope: "public",
skills: [ENTRY],
});
await expect(client.listSkills()).resolves.toMatchObject({
skills: [ENTRY],
});
});
it("does NOT require the envelope on a legacy connection", async () => {
// Those are 2026-era attributes; failing a legacy server for their absence
// would reject a conforming server.
const client = makeClient();
stubRequest(client, { skills: [ENTRY] });
await expect(client.listSkills()).resolves.toMatchObject({
skills: [ENTRY],
});
});
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
// consumer-owned extension method, so a nonconforming result must fail

View file

@ -378,6 +378,24 @@ describe("checkSkillConformance", () => {
expect(issues[0].severity).toBe("error");
});
it("counts description length in code points, not UTF-16 code units", () => {
// 600 non-BMP characters are 1200 code units. Measuring those would report
// a perfectly valid description as over the 1024-character limit — a
// conforming server failed by an off-by-encoding.
const issues = checkSkillConformance(
entry({ frontmatter: { name: "demo", description: "𝄞".repeat(600) } }),
);
expect(issues).toEqual([]);
});
it("still reports a description over the limit in code points", () => {
const issues = checkSkillConformance(
entry({ frontmatter: { name: "demo", description: "𝄞".repeat(1025) } }),
);
expect(issues.map((i) => i.code)).toEqual(["malformed-description"]);
expect(issues[0].message).toContain("1025");
});
it("accepts a description exactly at the limit", () => {
const issues = checkSkillConformance(
entry({ frontmatter: { name: "demo", description: "d".repeat(1024) } }),

View file

@ -3,6 +3,7 @@ import {
DYNAMIC_RESOURCES,
GetSkillResultSchema,
ListSkillsResultSchema,
ModernListSkillsResultSchema,
SKILLS_EXTENSION_KEY,
SKILLS_GET_METHOD,
SKILLS_LIST_METHOD,
@ -87,6 +88,56 @@ describe("ListSkillsResultSchema", () => {
});
});
describe("ModernListSkillsResultSchema", () => {
const envelope = { resultType: "complete", ttlMs: 0, cacheScope: "public" };
it("accepts a modern page carrying the base list envelope", () => {
const parsed = ModernListSkillsResultSchema.parse({
...envelope,
skills: [ENTRY],
});
expect(parsed.skills).toHaveLength(1);
});
it("rejects a modern page that omits the caching attributes", () => {
// The whole reason for the era split: `skills/*` is consumer-owned, so the
// SDK codec validates none of it, and `{ skills: [] }` would otherwise
// reach the conformance UI as a clean list.
expect(() => ModernListSkillsResultSchema.parse({ skills: [] })).toThrow();
expect(() =>
ModernListSkillsResultSchema.parse({
...envelope,
ttlMs: undefined,
skills: [],
}),
).toThrow();
});
it("rejects a malformed ttlMs rather than accepting the envelope loosely", () => {
for (const ttlMs of [-1, 0.5]) {
expect(() =>
ModernListSkillsResultSchema.parse({ ...envelope, ttlMs, skills: [] }),
).toThrow();
}
});
it("rejects an unknown cacheScope", () => {
expect(() =>
ModernListSkillsResultSchema.parse({
...envelope,
cacheScope: "shared",
skills: [],
}),
).toThrow();
});
it("the LEGACY schema still accepts a page without the envelope", () => {
// Those are 2026-era attributes; a legacy server has no business sending
// them and must not be failed for their absence.
expect(ListSkillsResultSchema.parse({ skills: [] }).skills).toEqual([]);
});
});
describe("GetSkillResultSchema", () => {
it("unwraps the envelope to the entry", () => {
expect(GetSkillResultSchema.parse({ skill: ENTRY })).toEqual(ENTRY);