fix: address Copilot review round 7 on #2251
- skills.ts: stop sorting `resources` arrays recursively. Only the entry's OWN manifest is a set; `frontmatter` is verbatim arbitrary JSON, so a custom `frontmatter.metadata.resources` array was being sorted too and two genuinely different frontmatters compared equal. The manifest sort is now done once, at the top level, and every other array keeps its order. - SkillsScreen: invalidate on the whole selected entry, not just its manifest. The `skills/get` comparison covers `frontmatter`, so a refresh that changed only a description left a stale "matches skills/list" verdict on screen — computed against an entry that no longer existed. Re-verifying after a metadata-only refresh is the cheap direction to be wrong in; showing a match against a different entry is not. - SkillsScreen: give each "Verify all" invocation a token, so a finalizer can only ever clear its own batch rather than any batch with a matching manifest key. - SkillsScreen.stories: supply `onGetSkill`. Every story rendered the "Fetch with skills/get" button, and clicking it threw. On the batch token: the A → B → A sequence the review describes is not actually reachable, because the button is disabled whenever the in-flight batch's key matches the selection — returning to A finds it disabled, not clickable. The token is defence in depth for the same property, and the test asserts what is really guaranteed rather than the unreachable path. 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
4e676a0a54
commit
1f33ba93be
5 changed files with 197 additions and 41 deletions
|
|
@ -99,6 +99,13 @@ const meta: Meta<typeof SkillsScreen> = {
|
||||||
? { text: REF_TEXT }
|
? { text: REF_TEXT }
|
||||||
: { text: SELF_TEXT, mimeType: "text/markdown" },
|
: { text: SELF_TEXT, mimeType: "text/markdown" },
|
||||||
),
|
),
|
||||||
|
// Echoes back the entry `skills/list` advertised, so "Fetch with
|
||||||
|
// skills/get" demonstrates the matching case rather than throwing.
|
||||||
|
onGetSkill: fn(async (uri: string) => {
|
||||||
|
const found = sampleSkills.find((skill) => skill.uri === uri);
|
||||||
|
if (!found) throw new Error(`Unknown skill uri: ${uri}`);
|
||||||
|
return found;
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
render: (args) => <StatefulSkillsScreen {...args} />,
|
render: (args) => <StatefulSkillsScreen {...args} />,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -615,6 +615,74 @@ describe("SkillsScreen", () => {
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("drops the skills/get verdict when a refresh changes only metadata", async () => {
|
||||||
|
// The manifest is untouched, so a manifest-only invalidation key would
|
||||||
|
// leave "matches" on screen even though it was computed against the
|
||||||
|
// previous entry — and that comparison covers `frontmatter` too.
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onGetSkill = vi.fn().mockResolvedValue(CLEAN_SKILL);
|
||||||
|
const { rerender } = renderWithMantine(
|
||||||
|
<SkillsScreen
|
||||||
|
{...baseProps}
|
||||||
|
skills={[CLEAN_SKILL]}
|
||||||
|
onGetSkill={onGetSkill}
|
||||||
|
ui={{ ...EMPTY_SKILLS_UI, selectedSkillUri: CLEAN_SKILL.uri }}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
await user.click(
|
||||||
|
screen.getByRole("button", { name: /Fetch with skills\/get/ }),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
await screen.findByText("skills/get matches skills/list"),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
<SkillsScreen
|
||||||
|
{...baseProps}
|
||||||
|
skills={[
|
||||||
|
{
|
||||||
|
...CLEAN_SKILL,
|
||||||
|
frontmatter: {
|
||||||
|
...CLEAN_SKILL.frontmatter,
|
||||||
|
description: "reworded since the fetch",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
onGetSkill={onGetSkill}
|
||||||
|
ui={{ ...EMPTY_SKILLS_UI, selectedSkillUri: CLEAN_SKILL.uri }}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.queryByTestId("skills-get-result")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps Verify all disabled on returning to a skill whose batch is still running", async () => {
|
||||||
|
// This is what actually blocks a second batch for one manifest: the button
|
||||||
|
// is disabled whenever the in-flight batch's key matches the selection, so
|
||||||
|
// A → B → A comes back to a disabled button rather than a second pool.
|
||||||
|
// (`batch` also carries a per-invocation token, so a finalizer can only
|
||||||
|
// ever clear its own batch — belt and braces for the same property.)
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onReadSkillFile = vi.fn(
|
||||||
|
() => new Promise<{ text: string }>(() => {}),
|
||||||
|
);
|
||||||
|
renderWithMantine(
|
||||||
|
<ControlledSkillsScreen onReadSkillFile={onReadSkillFile} />,
|
||||||
|
);
|
||||||
|
await user.click(screen.getByText("data-analysis"));
|
||||||
|
await user.click(screen.getByRole("button", { name: /Verify all/ }));
|
||||||
|
expect(screen.getByRole("button", { name: /Verify all/ })).toBeDisabled();
|
||||||
|
|
||||||
|
// B is free to run its own batch...
|
||||||
|
await user.click(screen.getByText("tampered"));
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: /Verify all/ }),
|
||||||
|
).not.toBeDisabled();
|
||||||
|
|
||||||
|
// ...and returning to A finds its batch still in flight.
|
||||||
|
await user.click(screen.getByText("data-analysis"));
|
||||||
|
expect(screen.getByRole("button", { name: /Verify all/ })).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
it("shows the SKILL.md preview on demand", async () => {
|
it("shows the SKILL.md preview on demand", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
renderWithMantine(<ControlledSkillsScreen />);
|
renderWithMantine(<ControlledSkillsScreen />);
|
||||||
|
|
|
||||||
|
|
@ -340,11 +340,19 @@ export function SkillsScreen({
|
||||||
const [fetchedEntry, setFetchedEntry] = useState<FetchedEntryState>({
|
const [fetchedEntry, setFetchedEntry] = useState<FetchedEntryState>({
|
||||||
key: null,
|
key: null,
|
||||||
});
|
});
|
||||||
// The manifest whose "Verify all" batch is in flight, or `null`. Keyed rather
|
// The "Verify all" batch in flight, as the manifest it belongs to plus a
|
||||||
// than a bare boolean: a global flag would leave a NEWLY selected skill's
|
// token unique to that invocation, or `null`.
|
||||||
// button disabled until the previous skill's reads settled — indefinitely, if
|
//
|
||||||
// one of them hangs.
|
// The key alone would be a bare boolean's problem one level up: a global flag
|
||||||
const [batchKey, setBatchKey] = useState<string | null>(null);
|
// leaves a NEWLY selected skill's button disabled until the previous skill's
|
||||||
|
// reads settle (indefinitely, if one hangs), and a key-only guard lets two
|
||||||
|
// batches for the SAME manifest clear each other — start on A, switch to B,
|
||||||
|
// return to A and start again, and the first A batch's finalizer sees a
|
||||||
|
// matching key and frees the button while the second is still running,
|
||||||
|
// re-opening the concurrency cap it exists to hold.
|
||||||
|
const [batch, setBatch] = useState<{ key: string; token: number } | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
// Monotonic attempt token, shared by every on-demand action here: a manifest
|
// Monotonic attempt token, shared by every on-demand action here: a manifest
|
||||||
// row's verification, the SKILL.md preview, and the `skills/get` fetch. One
|
// row's verification, the SKILL.md preview, and the `skills/get` fetch. One
|
||||||
// counter rather than three because it only has to be *increasing*, and each
|
// counter rather than three because it only has to be *increasing*, and each
|
||||||
|
|
@ -380,20 +388,18 @@ export function SkillsScreen({
|
||||||
[selected],
|
[selected],
|
||||||
);
|
);
|
||||||
|
|
||||||
// What every verdict on screen is a verdict *about*: the selected skill AND
|
// What every result on screen is a result *about*: the selected skill entry,
|
||||||
// the manifest it advertised. Keying invalidation on the URI alone would
|
// in full. Keying on the URI alone would leave a green `verified` badge
|
||||||
// leave a green `verified` badge attached to a digest the Refresh replaced,
|
// attached to a digest a Refresh replaced; keying on the manifest alone would
|
||||||
// so the UI would vouch for content it has never checked. A primitive string
|
// leave a stale "skills/get matches skills/list" verdict after a
|
||||||
// rather than the manifest object, because `useValueChange` compares with
|
// metadata-only change, since that comparison covers `frontmatter` too.
|
||||||
// `Object.is` and a fresh array every render would loop.
|
// Re-verifying after a metadata-only refresh is the cheap direction to be
|
||||||
|
// wrong in; showing a match that was computed against a different entry is
|
||||||
|
// not. A primitive string, because `useValueChange` compares with `Object.is`
|
||||||
|
// and a fresh object every render would loop.
|
||||||
const manifestKey = useMemo(
|
const manifestKey = useMemo(
|
||||||
() =>
|
() => (selected ? JSON.stringify(selected) : (selectedSkillUri ?? "")),
|
||||||
[
|
[selected, selectedSkillUri],
|
||||||
selectedSkillUri ?? "",
|
|
||||||
selected?.resources === DYNAMIC_RESOURCES ? "dynamic" : "",
|
|
||||||
...manifest.map((r) => `${r.uri}|${r.digest ?? ""}|${r.size ?? ""}`),
|
|
||||||
].join("\n"),
|
|
||||||
[manifest, selected, selectedSkillUri],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Adjusted DURING RENDER via `useValueChange` rather than in an effect, so a
|
// Adjusted DURING RENDER via `useValueChange` rather than in an effect, so a
|
||||||
|
|
@ -469,14 +475,15 @@ export function SkillsScreen({
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const workers = Math.min(VERIFY_CONCURRENCY, manifest.length);
|
const workers = Math.min(VERIFY_CONCURRENCY, manifest.length);
|
||||||
setBatchKey(key);
|
const token = (nextAttempt.current += 1);
|
||||||
|
setBatch({ key, token });
|
||||||
// The concurrency cap is per invocation, so without the button being
|
// The concurrency cap is per invocation, so without the button being
|
||||||
// disabled below, a second click would start a second pool of four and a
|
// disabled below, a second click would start a second pool of four and a
|
||||||
// third would make it twelve — the flood the cap exists to prevent.
|
// third would make it twelve — the flood the cap exists to prevent.
|
||||||
void Promise.all(Array.from({ length: workers }, () => worker())).finally(
|
void Promise.all(Array.from({ length: workers }, () => worker())).finally(
|
||||||
// Clears only ITS OWN batch: a stale finalizer must not free a button the
|
// Clears only ITS OWN invocation: matched on the token, not the key, so
|
||||||
// user has since re-armed on another skill.
|
// an earlier batch settling cannot free a button a later one is holding.
|
||||||
() => setBatchKey((prev) => (prev === key ? null : prev)),
|
() => setBatch((prev) => (prev?.token === token ? null : prev)),
|
||||||
);
|
);
|
||||||
}, [manifest, manifestKey, verifyRow]);
|
}, [manifest, manifestKey, verifyRow]);
|
||||||
|
|
||||||
|
|
@ -527,7 +534,7 @@ export function SkillsScreen({
|
||||||
}, [manifestKey, onGetSkill, selected]);
|
}, [manifestKey, onGetSkill, selected]);
|
||||||
|
|
||||||
const fetched = fetchedEntry.key === manifestKey ? fetchedEntry : undefined;
|
const fetched = fetchedEntry.key === manifestKey ? fetchedEntry : undefined;
|
||||||
const batchRunning = batchKey === manifestKey;
|
const batchRunning = batch?.key === manifestKey;
|
||||||
|
|
||||||
const preview =
|
const preview =
|
||||||
previewState.key === manifestKey ? previewState.contents : undefined;
|
previewState.key === manifestKey ? previewState.contents : undefined;
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import {
|
||||||
getSkillsExtension,
|
getSkillsExtension,
|
||||||
isSkillsExtensionSupported,
|
isSkillsExtensionSupported,
|
||||||
normalizeSkillUri,
|
normalizeSkillUri,
|
||||||
|
skillEntriesMatch,
|
||||||
sha256Digest,
|
sha256Digest,
|
||||||
skillDisplayName,
|
skillDisplayName,
|
||||||
skillNameFromUri,
|
skillNameFromUri,
|
||||||
|
|
@ -172,6 +173,68 @@ describe("normalizeSkillUri", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("skillEntriesMatch", () => {
|
||||||
|
const base = (): SkillEntry => ({
|
||||||
|
uri: "skill://demo/SKILL.md",
|
||||||
|
frontmatter: { name: "demo", description: "d" },
|
||||||
|
resources: [
|
||||||
|
{ uri: "skill://demo/SKILL.md", digest: DIGEST, size: 20 },
|
||||||
|
{ uri: "skill://demo/ref.md", digest: DIGEST, size: 10 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores object key order", () => {
|
||||||
|
const reordered = {
|
||||||
|
resources: base().resources,
|
||||||
|
uri: base().uri,
|
||||||
|
frontmatter: { description: "d", name: "demo" },
|
||||||
|
};
|
||||||
|
expect(skillEntriesMatch(base(), reordered)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores manifest order, because the manifest is a set", () => {
|
||||||
|
const reversed = {
|
||||||
|
...base(),
|
||||||
|
resources: [...(base().resources as object[])].reverse(),
|
||||||
|
} as SkillEntry;
|
||||||
|
expect(skillEntriesMatch(base(), reversed)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT reorder an array nested in frontmatter", () => {
|
||||||
|
// `frontmatter` is verbatim arbitrary JSON from the skill author, so a
|
||||||
|
// custom `resources` array inside it is an ordinary list. A recursive
|
||||||
|
// sort would make these two genuinely different entries compare equal.
|
||||||
|
const withNested = (order: string[]): SkillEntry => ({
|
||||||
|
...base(),
|
||||||
|
frontmatter: {
|
||||||
|
...base().frontmatter,
|
||||||
|
metadata: { resources: order.map((uri) => ({ uri })) },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
skillEntriesMatch(withNested(["a", "b"]), withNested(["b", "a"])),
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
skillEntriesMatch(withNested(["a", "b"]), withNested(["a", "b"])),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still sees a real difference", () => {
|
||||||
|
expect(
|
||||||
|
skillEntriesMatch(base(), {
|
||||||
|
...base(),
|
||||||
|
frontmatter: { name: "demo", description: "changed" },
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("compares the dynamic form without sorting it", () => {
|
||||||
|
const dynamic = { ...base(), resources: "dynamic" } as SkillEntry;
|
||||||
|
expect(skillEntriesMatch(dynamic, { ...dynamic })).toBe(true);
|
||||||
|
expect(skillEntriesMatch(dynamic, base())).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("skillDisplayName", () => {
|
describe("skillDisplayName", () => {
|
||||||
it("prefers the declared frontmatter name", () => {
|
it("prefers the declared frontmatter name", () => {
|
||||||
expect(skillDisplayName(entry())).toBe("demo");
|
expect(skillDisplayName(entry())).toBe("demo");
|
||||||
|
|
|
||||||
|
|
@ -411,31 +411,42 @@ export function totalSkillBytes(resources: readonly SkillResource[]): number {
|
||||||
* presents it as "the snapshot moved" and leaves the judgement to the reader.
|
* presents it as "the snapshot moved" and leaves the judgement to the reader.
|
||||||
*/
|
*/
|
||||||
export function skillEntriesMatch(a: SkillEntry, b: SkillEntry): boolean {
|
export function skillEntriesMatch(a: SkillEntry, b: SkillEntry): boolean {
|
||||||
return canonicalJson(a) === canonicalJson(b);
|
return canonicalEntry(a) === canonicalEntry(b);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Deterministic JSON: object keys sorted recursively, and a skill's manifest
|
/**
|
||||||
* sorted by URI so its enumeration order carries no meaning. */
|
* Deterministic JSON for one entry: object keys sorted recursively, and **the
|
||||||
function canonicalJson(value: unknown): string {
|
* entry's own manifest** — nothing else — sorted by URI.
|
||||||
return JSON.stringify(canonicalize(value));
|
*
|
||||||
|
* The manifest sort is deliberately not recursive. `frontmatter` is verbatim
|
||||||
|
* arbitrary JSON from the skill author, so a custom `frontmatter.metadata.
|
||||||
|
* resources` array would be caught by a recursive rule and two genuinely
|
||||||
|
* different frontmatters would compare equal. Only `SkillEntry.resources` is
|
||||||
|
* a set; every other array keeps its order.
|
||||||
|
*/
|
||||||
|
function canonicalEntry(entry: SkillEntry): string {
|
||||||
|
const { resources, ...rest } = entry;
|
||||||
|
const manifest = Array.isArray(resources)
|
||||||
|
? [...resources]
|
||||||
|
.sort((x, y) => String(x?.uri).localeCompare(String(y?.uri)))
|
||||||
|
.map(canonicalize)
|
||||||
|
: resources;
|
||||||
|
return JSON.stringify({ ...sortKeys(rest), resources: manifest });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Object keys sorted recursively; array ORDER is preserved throughout. */
|
||||||
function canonicalize(value: unknown): unknown {
|
function canonicalize(value: unknown): unknown {
|
||||||
if (Array.isArray(value)) return value.map(canonicalize);
|
if (Array.isArray(value)) return value.map(canonicalize);
|
||||||
if (value === null || typeof value !== "object") return value;
|
if (value === null || typeof value !== "object") return value;
|
||||||
const entries = Object.entries(value as Record<string, unknown>)
|
return sortKeys(value as Record<string, unknown>);
|
||||||
.map(([key, member]): [string, unknown] => {
|
}
|
||||||
// The manifest is a set; only its contents are meaningful.
|
|
||||||
if (key === "resources" && Array.isArray(member)) {
|
function sortKeys(value: Record<string, unknown>): Record<string, unknown> {
|
||||||
const sorted = [...(member as SkillResource[])].sort((x, y) =>
|
return Object.fromEntries(
|
||||||
String(x?.uri).localeCompare(String(y?.uri)),
|
Object.entries(value)
|
||||||
);
|
.map(([key, member]): [string, unknown] => [key, canonicalize(member)])
|
||||||
return [key, sorted.map(canonicalize)];
|
.sort(([x], [y]) => x.localeCompare(y)),
|
||||||
}
|
);
|
||||||
return [key, canonicalize(member)];
|
|
||||||
})
|
|
||||||
.sort(([x], [y]) => x.localeCompare(y));
|
|
||||||
return Object.fromEntries(entries);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Outcome of comparing a fetched file against its advertised digest. */
|
/** Outcome of comparing a fetched file against its advertised digest. */
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue