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:
parent
4d7dd180d9
commit
11ac134975
3 changed files with 178 additions and 27 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) } }),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue