fix: address Copilot review round 6 on #2251
Most of this round re-reported code the previous commit already changed (it generated 0 new comments). Two findings were genuinely open. - SkillsScreen: give "View SKILL.md" and "Fetch with skills/get" the same attempt ordering `verifyRow` got in round 3. Both were still last-completion-wins within one manifest: a double click whose older request settled last replaced the newer result. One shared `isStale` helper now covers all three actions — a result is dropped when its manifest was invalidated OR a later click already wrote. - test-servers/skills.ts + docs: stop calling all three edge cases "non-conforming". `resources: "dynamic"` is a LEGAL wire form for generated content — it is unverifiable, not invalid, which is exactly why the checker reports it as a warning. The prose contradicted the code it was describing. Now: one legal-but-unverifiable case and two actual violations. 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
754ce5bd95
commit
4e676a0a54
2 changed files with 135 additions and 37 deletions
|
|
@ -551,6 +551,70 @@ describe("SkillsScreen", () => {
|
|||
).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it("keeps the newest SKILL.md preview when two reads overlap", async () => {
|
||||
// Same skill, same manifest — the key cannot order these, so without an
|
||||
// attempt token the older read finishing last would replace the newer
|
||||
// preview with stale content.
|
||||
const user = userEvent.setup();
|
||||
const resolvers: ((value: { text: string }) => void)[] = [];
|
||||
const onReadSkillFile = vi.fn(
|
||||
() =>
|
||||
new Promise<{ text: string }>((resolve) => {
|
||||
resolvers.push(resolve);
|
||||
}),
|
||||
);
|
||||
renderWithMantine(
|
||||
<ControlledSkillsScreen onReadSkillFile={onReadSkillFile} />,
|
||||
);
|
||||
await user.click(screen.getByText("data-analysis"));
|
||||
const view = screen.getByRole("button", { name: /View SKILL.md/ });
|
||||
await user.click(view);
|
||||
await user.click(view);
|
||||
expect(resolvers).toHaveLength(2);
|
||||
|
||||
resolvers[1]({ text: "# newest\n" });
|
||||
expect(await screen.findByTestId("skill-md-preview")).toHaveTextContent(
|
||||
"newest",
|
||||
);
|
||||
resolvers[0]({ text: "# stale\n" });
|
||||
expect(screen.getByTestId("skill-md-preview")).not.toHaveTextContent(
|
||||
"stale",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the newest skills/get result when two fetches overlap", async () => {
|
||||
const user = userEvent.setup();
|
||||
const resolvers: ((value: SkillEntry) => void)[] = [];
|
||||
const onGetSkill = vi.fn(
|
||||
() =>
|
||||
new Promise<SkillEntry>((resolve) => {
|
||||
resolvers.push(resolve);
|
||||
}),
|
||||
);
|
||||
renderWithMantine(<ControlledSkillsScreen onGetSkill={onGetSkill} />);
|
||||
await user.click(screen.getByText("data-analysis"));
|
||||
const fetchButton = screen.getByRole("button", {
|
||||
name: /Fetch with skills\/get/,
|
||||
});
|
||||
await user.click(fetchButton);
|
||||
await user.click(fetchButton);
|
||||
expect(resolvers).toHaveLength(2);
|
||||
|
||||
// The newer fetch matches; the older one, landing last, would otherwise
|
||||
// overwrite it with a "different snapshot" verdict.
|
||||
resolvers[1](CLEAN_SKILL);
|
||||
expect(
|
||||
await screen.findByText("skills/get matches skills/list"),
|
||||
).toBeInTheDocument();
|
||||
resolvers[0]({
|
||||
...CLEAN_SKILL,
|
||||
frontmatter: { ...CLEAN_SKILL.frontmatter, description: "stale" },
|
||||
});
|
||||
expect(
|
||||
screen.getByText("skills/get matches skills/list"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the SKILL.md preview on demand", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithMantine(<ControlledSkillsScreen />);
|
||||
|
|
|
|||
|
|
@ -74,9 +74,15 @@ interface VerificationState {
|
|||
files: Record<number, FileState>;
|
||||
}
|
||||
|
||||
/** The SKILL.md preview, plus the manifest it belongs to (`null` as above). */
|
||||
/**
|
||||
* The SKILL.md preview, plus the manifest it belongs to (`null` as above) and
|
||||
* the click that produced it. The manifest key cannot order two reads issued
|
||||
* for the SAME manifest, so without `attempt` a double click whose older read
|
||||
* finishes last would replace the newer preview.
|
||||
*/
|
||||
interface PreviewState {
|
||||
key: string | null;
|
||||
attempt?: number;
|
||||
contents?: SkillFileContents;
|
||||
message?: string;
|
||||
}
|
||||
|
|
@ -88,6 +94,8 @@ interface PreviewState {
|
|||
*/
|
||||
interface FetchedEntryState {
|
||||
key: string | null;
|
||||
/** The click this result belongs to — see {@link PreviewState.attempt}. */
|
||||
attempt?: number;
|
||||
entry?: SkillEntry;
|
||||
matches?: boolean;
|
||||
message?: string;
|
||||
|
|
@ -274,6 +282,21 @@ function verificationLabel(state: FileState | undefined): string {
|
|||
return state.verification.status;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a settled request should be discarded: its manifest was invalidated
|
||||
* (a different key), or a later click for the same manifest already wrote (a
|
||||
* higher attempt). A `null` key is the un-adopted initial manifest, which the
|
||||
* first write claims.
|
||||
*/
|
||||
function isStale(
|
||||
held: { key: string | null; attempt?: number },
|
||||
key: string,
|
||||
attempt: number,
|
||||
): boolean {
|
||||
if (held.key !== null && held.key !== key) return true;
|
||||
return held.attempt !== undefined && held.attempt > attempt;
|
||||
}
|
||||
|
||||
/** `sha256:abcd…wxyz`, so a long digest stays readable in a table cell. */
|
||||
function shortDigest(digest: string | undefined): string {
|
||||
if (!digest) return "—";
|
||||
|
|
@ -322,8 +345,11 @@ export function SkillsScreen({
|
|||
// button disabled until the previous skill's reads settled — indefinitely, if
|
||||
// one of them hangs.
|
||||
const [batchKey, setBatchKey] = useState<string | null>(null);
|
||||
// Monotonic per-row attempt token. A ref because it is claimed inside an
|
||||
// event handler, never during render.
|
||||
// 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
|
||||
// counter rather than three because it only has to be *increasing*, and each
|
||||
// consumer compares it against its own slot. A ref because it is claimed
|
||||
// inside an event handler, never during render.
|
||||
const nextAttempt = useRef(0);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
|
|
@ -457,48 +483,46 @@ export function SkillsScreen({
|
|||
const showSkillMd = useCallback(() => {
|
||||
if (!selected) return;
|
||||
const key = manifestKey;
|
||||
const attempt = (nextAttempt.current += 1);
|
||||
// A click handler cannot await, and this chain terminates in its own
|
||||
// `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.
|
||||
// `catch` that surfaces the message in the preview slot. Both arms go
|
||||
// through `writePreview`, which drops a result whose manifest has been
|
||||
// invalidated OR whose click has been superseded.
|
||||
const writePreview = (next: Omit<PreviewState, "key" | "attempt">) =>
|
||||
setPreviewState((prev) =>
|
||||
isStale(prev, key, attempt) ? prev : { key, attempt, ...next },
|
||||
);
|
||||
void onReadSkillFile(selected.uri)
|
||||
.then((contents) => {
|
||||
setPreviewState((prev) =>
|
||||
prev.key !== null && prev.key !== key ? prev : { key, contents },
|
||||
);
|
||||
})
|
||||
.then((contents) => writePreview({ contents }))
|
||||
.catch((err: unknown) => {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
setPreviewState((prev) =>
|
||||
prev.key !== null && prev.key !== key ? prev : { key, message },
|
||||
);
|
||||
writePreview({
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
});
|
||||
}, [manifestKey, onReadSkillFile, selected]);
|
||||
|
||||
const fetchEntry = useCallback(() => {
|
||||
if (!selected) return;
|
||||
const key = manifestKey;
|
||||
const attempt = (nextAttempt.current += 1);
|
||||
// Same shape as the SKILL.md read: a click handler cannot await, the chain
|
||||
// ends in its own `catch`, and both arms compare the manifest key they
|
||||
// started under so a late answer cannot land under another skill.
|
||||
// ends in its own `catch`, and both arms drop a result whose manifest has
|
||||
// been invalidated or whose click has been superseded.
|
||||
const writeFetched = (next: Omit<FetchedEntryState, "key" | "attempt">) =>
|
||||
setFetchedEntry((prev) =>
|
||||
isStale(prev, key, attempt) ? prev : { key, attempt, ...next },
|
||||
);
|
||||
void onGetSkill(selected.uri)
|
||||
.then((entry) => {
|
||||
// Compared semantically against what `skills/list` advertised — see
|
||||
// `skillEntriesMatch` for why a `JSON.stringify` comparison would
|
||||
// report key order and manifest order as differences.
|
||||
const matches = skillEntriesMatch(entry, selected);
|
||||
setFetchedEntry((prev) =>
|
||||
prev.key !== null && prev.key !== key
|
||||
? prev
|
||||
: { key, entry, matches },
|
||||
);
|
||||
writeFetched({ entry, matches: skillEntriesMatch(entry, selected) });
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
setFetchedEntry((prev) =>
|
||||
prev.key !== null && prev.key !== key ? prev : { key, message },
|
||||
);
|
||||
writeFetched({
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
});
|
||||
}, [manifestKey, onGetSkill, selected]);
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue