Merge pull request #2265 from modelcontextprotocol/v2/feat/2263-skills-detail-pane

feat: refactor the Skills detail pane into collapsible sections with an internally-scrolling file viewer
This commit is contained in:
Cliff Hall 2026-09-06 11:03:27 -04:00 • committed by GitHub
commit 2c330daf69
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 2592 additions and 415 deletions

View file

@ -20,6 +20,7 @@ import { ContentViewer } from "../../elements/ContentViewer/ContentViewer";
import { getMimeKind } from "../../elements/ContentViewer/contentViewerUtils";
import { CopyButton } from "../../elements/CopyButton/CopyButton";
import { SubscribeButton } from "../../elements/SubscribeButton/SubscribeButton";
import { inferMimeFromUri } from "../../../utils/inferMimeFromUri";
export interface ResourcePreviewPanelProps {
resource: Resource;
@ -155,35 +156,6 @@ const ContentStack = Stack.withProps({
gap: "md",
});
// Map a file extension to the MIME type that drives ContentViewer's per-MIME
// renderer dispatch. MCP servers commonly omit `mimeType` (or return a generic
// `text/plain` / `application/octet-stream`), so the URI suffix is the most
// reliable signal for engaging the markdown / PDF / CSV / XML / HTML / CSS
// renderers. Order doesn't matter — suffixes are unique.
const URI_SUFFIX_MIME: ReadonlyArray<readonly [string, string]> = [
[".md", "text/markdown"],
[".markdown", "text/markdown"],
[".csv", "text/csv"],
[".json", "application/json"],
[".xml", "application/xml"],
[".html", "text/html"],
[".htm", "text/html"],
[".css", "text/css"],
[".pdf", "application/pdf"],
];
// Infer a MIME type from the URI's file extension when the server didn't supply
// one. Returns undefined for unrecognized suffixes so callers fall through to
// the octet-stream default.
function inferMimeFromUri(uri: string): string | undefined {
const path = uri.split("?")[0].split("#")[0];
const lower = path.toLowerCase();
for (const [suffix, mime] of URI_SUFFIX_MIME) {
if (lower.endsWith(suffix)) return mime;
}
return undefined;
}
function effectiveMime(
itemMime: string | undefined,
resource: Resource,

View file

@ -127,7 +127,12 @@ export const ConformingSkill: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByText("data-analysis"));
await expect(canvas.getByText("Conforms")).toBeInTheDocument();
// A clean entry opens with Conformance COLLAPSED (#2263) — its badge
// already carries the whole answer — so the verdict is behind one click.
const control = canvas.getByRole("button", { name: /Conformance/ });
await expect(control).toHaveAttribute("aria-expanded", "false");
await userEvent.click(control);
await expect(canvas.getByText("No structural issues")).toBeInTheDocument();
},
};
@ -157,3 +162,275 @@ export const DigestMismatch: Story = {
).toBeInTheDocument();
},
};
// A SKILL.md long enough to overflow the viewer. Every other fixture here is a
// line or two, which is precisely why the layout regression this screen was
// refactored for could not be caught in a story: with short content the viewer
// never scrolls, so a pane that scrolls as one column looks identical to one
// that does not (#2263).
const LONG_SKILL_MD = [
"---",
"name: data-analysis",
"description: Analyze a CSV and summarize its columns",
"---",
"",
"# Data analysis",
"",
...Array.from(
{ length: 40 },
(_, i) =>
`Paragraph ${i + 1}. Read the file as UTF-8 and sniff the delimiter from ` +
"the header line rather than assuming a comma, because a mis-sniffed " +
"delimiter yields a single column whose name is the entire header.\n",
),
].join("\n");
// A conforming manifest may declare up to 512 files. This is the sibling case
// to a long document: the metadata section, not the viewer, is what holds the
// overflowing content.
const manyFilesSkill: SkillEntry = {
uri: "skill://big-manifest/SKILL.md",
frontmatter: {
name: "big-manifest",
description: "A conforming skill that declares a great many files",
},
resources: [
selfEntry("big-manifest"),
...Array.from({ length: 120 }, (_, i) => ({
uri: `skill://big-manifest/file-${String(i).padStart(3, "0")}.md`,
digest: REF_DIGEST,
size: 15,
})),
],
};
/**
* The other half of the layout contract: a huge **manifest**, rather than a
* huge document.
*
* A section that keeps its full intrinsic height pushes the file viewer off the
* bottom of the pane, so reaching the file means scrolling past the manifest —
* which is the "the file is behind the manifest" problem this screen exists to
* end. The metadata sections must therefore shrink to their floor and scroll
* internally, leaving the viewer on screen.
*/
export const LongManifest: Story = {
args: {
skills: [manyFilesSkill],
onReadSkillFile: fn(async () => ({
text: "---\nname: big-manifest\n---\n\n# Big manifest\n",
mimeType: "text/markdown",
})),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByText("big-manifest"));
const viewerControl = await canvas.findByRole("button", {
name: /Skill Resource/,
});
const detailCard = canvasElement.querySelectorAll(".mantine-Card-root")[1];
if (!(detailCard instanceof HTMLElement)) {
throw new Error("Detail card not found");
}
// The viewer's header is ON SCREEN, not pushed below the manifest.
const cardRect = detailCard.getBoundingClientRect();
const viewerRect = viewerControl.getBoundingClientRect();
await expect(viewerRect.bottom).toBeLessThanOrEqual(cardRect.bottom + 1);
// The manifest section gave up space rather than keeping its full height,
// so its own panel is what scrolls.
const resourcesControl = canvas.getByRole("button", { name: /Resources/ });
const resourcesPanel = resourcesControl
.closest(".mantine-Accordion-item")
?.querySelector(".mantine-Accordion-panel");
if (!(resourcesPanel instanceof HTMLElement)) {
throw new Error("Resources panel not found");
}
await expect(resourcesPanel.scrollHeight).toBeGreaterThan(
resourcesPanel.clientHeight,
);
// And the pane still does not scroll as one column.
await expect(detailCard.scrollHeight).toBeLessThanOrEqual(
detailCard.clientHeight + 1,
);
},
};
// A skill whose every server-controlled header string is hostile: a very long
// name, a URI with many breakable segments, and a description at the upper end
// of what SEP-2640 permits.
const HOSTILE_NAME = "an-extremely-long-skill-name-".repeat(8);
// A manifest entry whose FILENAME is hostile, not just its path — selecting it
// puts that name in the Skill Resource control, which is pinned and does not
// scroll.
const HOSTILE_FILE_URI = `skill://${"very-long-path-segment/".repeat(30)}${"a-very-long-file-name-".repeat(10)}.md`;
const hostileHeaderSkill: SkillEntry = {
uri: `skill://${"very-long-path-segment/".repeat(30)}SKILL.md`,
frontmatter: {
name: HOSTILE_NAME,
description: "word ".repeat(400).trim(),
},
resources: [
{
uri: `skill://${"very-long-path-segment/".repeat(30)}SKILL.md`,
digest: SELF_DIGEST,
size: 8,
},
{ uri: HOSTILE_FILE_URI, digest: SELF_DIGEST, size: 8 },
],
};
/**
* The pane's fixed header cannot starve the accordion.
*
* The header is a sibling of an accordion whose flex-basis is `0`, so every
* unbounded string in it is subtracted from the sections rather than resisted
* by them. That has been the same defect three times in this PR — the viewer's
* content-sized basis, the `skills/get` region, and the description — so this
* asserts the whole class is closed rather than any one instance.
*/
export const HostileHeader: Story = {
args: {
skills: [hostileHeaderSkill],
onReadSkillFile: fn(async () => ({
text: "---\nname: x\n---\n\n# Body\n",
mimeType: "text/markdown",
})),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getAllByText(HOSTILE_NAME)[0]);
await canvas.findByRole("button", { name: /Skill Resource/ });
const detailCard = canvasElement.querySelectorAll(".mantine-Card-root")[1];
const accordion = canvasElement.querySelector(".disclosure-sections");
if (
!(detailCard instanceof HTMLElement) ||
!(accordion instanceof HTMLElement)
) {
throw new Error("detail card or accordion not found");
}
// The header takes a minority of the pane, leaving the sections the rest.
const cardHeight = detailCard.getBoundingClientRect().height;
const accordionHeight = accordion.getBoundingClientRect().height;
await expect(accordionHeight).toBeGreaterThan(cardHeight * 0.5);
// Every section is still usable, and the pane still does not scroll.
for (const item of accordion.querySelectorAll(
":scope > .mantine-Accordion-item",
)) {
await expect(item.getBoundingClientRect().height).toBeGreaterThan(0);
}
await expect(detailCard.scrollHeight).toBeLessThanOrEqual(
detailCard.clientHeight + 1,
);
// Selecting the resource with the hostile FILENAME puts it in the Skill
// Resource control, which is pinned and does not scroll — so it has to be
// clamped too, or the header grows instead.
// Measure the control while a SHORT name is displayed, then select the
// hostile one: a clamped caption leaves the control the same height, an
// unclamped one grows it. Comparing against its own baseline is what makes
// this detect the defect — an absolute threshold does not, because even an
// unclamped name only wraps to a few lines.
const control = await canvas.findByRole("button", {
name: /Skill Resource/,
});
const controlBefore = control.getBoundingClientRect().height;
const accordionBefore = accordion.getBoundingClientRect().height;
await userEvent.click(
canvas.getByRole("button", { name: HOSTILE_FILE_URI }),
);
await expect(
Math.abs(control.getBoundingClientRect().height - controlBefore),
).toBeLessThanOrEqual(1);
await expect(
Math.abs(accordion.getBoundingClientRect().height - accordionBefore),
).toBeLessThanOrEqual(1);
await expect(detailCard.scrollHeight).toBeLessThanOrEqual(
detailCard.clientHeight + 1,
);
},
};
/**
* The layout contract, asserted in a real browser.
*
* This is the regression the refactor exists to prevent, and it is only visible
* with content that overflows: the file viewer must scroll **inside its own
* panel** while its sibling sections keep usable height, rather than the whole
* pane scrolling as one column.
*
* It also pins the collapse-then-reopen case, which is how the original bug
* actually presented — the viewer's content-sized `flex-basis` crushed its
* siblings, so collapsing it laid out correctly and reopening it broke again.
*/
export const LongSkillDocument: Story = {
args: {
onReadSkillFile: fn(async () => ({
text: LONG_SKILL_MD,
mimeType: "text/markdown",
})),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByText("data-analysis"));
const viewerControl = await canvas.findByRole("button", {
name: /Skill Resource/,
});
const sections = () => [
...canvasElement.querySelectorAll(
".disclosure-sections > .mantine-Accordion-item",
),
];
const geometry = () =>
sections().map((s) => Math.round(s.getBoundingClientRect().height));
// Every section keeps a usable height: none is crushed to nothing by the
// viewer's content, which is exactly what a content-sized basis did.
const before = geometry();
await expect(before.length).toBeGreaterThanOrEqual(3);
for (const height of before) {
await expect(height).toBeGreaterThan(0);
}
// Sections tile in document order — none overlaps the header below it,
// which is how the crushed layout showed up on screen.
const rects = sections().map((s) => s.getBoundingClientRect());
for (let i = 1; i < rects.length; i++) {
await expect(Math.round(rects[i].top)).toBeGreaterThanOrEqual(
Math.round(rects[i - 1].bottom) - 1,
);
}
// The viewer scrolls WITHIN its own panel rather than growing the pane.
const viewerPanel = viewerControl
.closest(".mantine-Accordion-item")
?.querySelector(".mantine-Accordion-panel");
if (!(viewerPanel instanceof HTMLElement)) {
throw new Error("Skill Resource panel not found");
}
await expect(viewerPanel.scrollHeight).toBeGreaterThan(
viewerPanel.clientHeight,
);
// And the detail pane itself does not scroll as one column.
const detailCard = canvasElement.querySelectorAll(".mantine-Card-root")[1];
if (!(detailCard instanceof HTMLElement)) {
throw new Error("Detail card not found");
}
await expect(detailCard.scrollHeight).toBeLessThanOrEqual(
detailCard.clientHeight + 1,
);
// Collapse then reopen restores the same geometry.
await userEvent.click(viewerControl);
await userEvent.click(viewerControl);
await expect(geometry()).toEqual(before);
},
};

View file

@ -1,4 +1,4 @@
import { useState } from "react";
import { StrictMode, useState } from "react";
import { describe, it, expect, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import type { SkillEntry } from "@inspector/core/mcp/skillsSchemas";
@ -17,7 +17,10 @@ import {
import { EMPTY_SKILLS_UI } from "../screenUiState";
const REF_TEXT = "# Column rules\n";
const SELF_TEXT = "# data-analysis\n";
// A real SKILL.md carries frontmatter, and the screen now splits it out of the
// served bytes (#2263) — so the fixture has to have some, or the Frontmatter
// section it drives would never render here.
const SELF_TEXT = "---\nname: data-analysis\n---\n\n# data-analysis\n";
const NOTES_TEXT = "different\n";
// Computed once at module load so each fixture's advertised digest really is
// the digest of the bytes the fake read returns — a hard-coded constant would
@ -138,6 +141,14 @@ function ControlledSkillsScreen(props: Partial<SkillsScreenProps> = {}) {
);
}
// Mantine puts a Badge's colour on the ROOT as CSS custom properties, while
// `getByText` matches the inner label span — so the colour has to be read from
// the enclosing root rather than from the matched node.
function badgeStyle(text: RegExp): string {
const root = screen.getByText(text).closest(".mantine-Badge-root");
return root?.getAttribute("style") ?? "";
}
describe("SkillsScreen", () => {
it("renders the empty state until a skill is selected", () => {
renderWithMantine(<SkillsScreen {...baseProps} />);
@ -191,14 +202,232 @@ describe("SkillsScreen", () => {
expect(onRefreshList).toHaveBeenCalled();
});
it("reports a conforming skill as conforming", async () => {
it("collapses Conformance for a clean entry, and still reports it on expand", async () => {
// A clean entry opens collapsed (#2263): the header badge already says
// "0 error(s), 0 warning(s)", so an expanded "Conforms" panel is only
// taking space the file viewer could use. The verdict is still there.
const user = userEvent.setup();
renderWithMantine(<ControlledSkillsScreen />);
await user.click(screen.getByText("data-analysis"));
expect(screen.getByText("Conforms")).toBeInTheDocument();
const control = screen.getByRole("button", { name: /Conformance/ });
expect(control).toHaveAttribute("aria-expanded", "false");
expect(screen.queryByText("No structural issues")).not.toBeInTheDocument();
await user.click(control);
expect(screen.getByText("No structural issues")).toBeInTheDocument();
expect(screen.queryByTestId("skill-issues")).not.toBeInTheDocument();
});
it("collapses Conformance for a clean skill selected BEFORE mount", () => {
// `useValueChange` deliberately does not fire on the first render, so the
// auto-collapse it drives cannot cover a screen that mounts with a skill
// already chosen — a restored `SkillsUiState` does exactly that. The
// initialiser has to apply the same rule, or the behaviour only starts
// working after some later selection change (#2263).
renderWithMantine(
<SkillsScreen
{...baseProps}
ui={{ ...EMPTY_SKILLS_UI, selectedSkillUri: CLEAN_SKILL.uri }}
/>,
);
expect(screen.getByRole("button", { name: /Conformance/ })).toHaveAttribute(
"aria-expanded",
"false",
);
});
it("opens Conformance for a skill WITH findings selected before mount", () => {
renderWithMantine(
<SkillsScreen
{...baseProps}
ui={{ ...EMPTY_SKILLS_UI, selectedSkillUri: MISMATCHED_SKILL.uri }}
/>,
);
expect(screen.getByRole("button", { name: /Conformance/ })).toHaveAttribute(
"aria-expanded",
"true",
);
});
it("opens Conformance for an entry that has findings", async () => {
const user = userEvent.setup();
renderWithMantine(<ControlledSkillsScreen />);
await user.click(screen.getByText("right-name"));
expect(screen.getByRole("button", { name: /Conformance/ })).toHaveAttribute(
"aria-expanded",
"true",
);
expect(screen.getByTestId("skill-issues")).toBeInTheDocument();
});
it("re-opens Conformance when switching from a clean entry to a broken one", async () => {
// The section tracks the signal rather than latching: a user who lands on a
// clean skill and then picks a broken one must not have the findings hidden
// behind a click.
const user = userEvent.setup();
renderWithMantine(<ControlledSkillsScreen />);
await user.click(screen.getByText("data-analysis"));
expect(screen.getByRole("button", { name: /Conformance/ })).toHaveAttribute(
"aria-expanded",
"false",
);
await user.click(screen.getByText("right-name"));
expect(screen.getByRole("button", { name: /Conformance/ })).toHaveAttribute(
"aria-expanded",
"true",
);
});
it("reports a digest mismatch in Conformance, with its own red badge", async () => {
// `tampered-notes` is structurally clean but serves bytes that do not match
// its manifest, so its Conformance section starts collapsed — pressing
// Verify has to open it, or the verdict lands where nobody can see it
// (#2263). The mismatch count is a separate badge because it is a RUNTIME
// result: folding it into "N error(s)" would make that number change
// meaning after a click.
const user = userEvent.setup();
renderWithMantine(<ControlledSkillsScreen />);
await user.click(screen.getByText("tampered"));
expect(screen.getByRole("button", { name: /Conformance/ })).toHaveAttribute(
"aria-expanded",
"false",
);
expect(screen.queryByText(/mismatch\(es\)/)).not.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: /Verify all/ }));
expect(await screen.findByText("Digest mismatch")).toBeInTheDocument();
const conformance = screen.getByRole("button", { name: /Conformance/ });
expect(conformance).toHaveAttribute("aria-expanded", "true");
// The alert renders inside Conformance, not beside the manifest table.
expect(conformance.closest(".mantine-Accordion-item")).toContainElement(
screen.getByText("Digest mismatch"),
);
expect(badgeStyle(/1 mismatch\(es\)/)).toContain("red");
});
it("a stale Verify all batch does not reopen Conformance on another skill", async () => {
// `verifyRow` is called once per row by every "Verify all" worker as it
// advances, so a batch begun on one skill keeps calling it after the user
// has moved on. The keyed writes discard those results, but an open-state
// update is not keyed to a manifest — so opening the section from inside
// `verifyRow` let obsolete work mutate the current pane (#2263).
const user = userEvent.setup();
// Held open so the batch is still in flight when the selection changes.
const releases: (() => void)[] = [];
const onReadSkillFile = vi.fn(
() =>
new Promise<{ text: string }>((resolve) => {
releases.push(() => resolve({ text: SELF_TEXT }));
}),
);
// More rows than the concurrency cap, so workers keep pulling.
const manyRows: SkillEntry = {
...CLEAN_SKILL,
uri: "skill://many/SKILL.md",
frontmatter: { name: "many", description: "Many rows" },
resources: Array.from({ length: 10 }, (_, i) => ({
uri: i === 0 ? "skill://many/SKILL.md" : `skill://many/f${i}.md`,
digest: SELF_DIGEST,
size: textToBytes(SELF_TEXT).byteLength,
})),
};
renderWithMantine(
<ControlledSkillsScreen
skills={[manyRows, CLEAN_SKILL]}
onReadSkillFile={onReadSkillFile}
/>,
);
await user.click(screen.getByText("many"));
await user.click(screen.getByRole("button", { name: /Verify all/ }));
// Switch to a clean skill and collapse Conformance deliberately.
await user.click(
within(screen.getByTestId("skills-screen")).getAllByText(
"data-analysis",
)[0],
);
await waitFor(() =>
expect(
screen.getByRole("button", { name: /Conformance/ }),
).toHaveAttribute("aria-expanded", "false"),
);
// Let the abandoned batch's workers advance. They must not reopen it.
for (const release of releases) release();
await waitFor(() => expect(onReadSkillFile).toHaveBeenCalled());
expect(screen.getByRole("button", { name: /Conformance/ })).toHaveAttribute(
"aria-expanded",
"false",
);
});
it("bounds every server-controlled string in the fixed header", async () => {
// The header sits beside an accordion whose flex-basis is 0, so anything
// unbounded here is subtracted from the sections rather than resisted by
// them. This has been the same bug three times over (#2263) — the viewer's
// content-sized basis, the `skills/get` region, the description — so this
// asserts the *class* is closed rather than chasing one more instance.
const user = userEvent.setup();
const hostile: SkillEntry = {
uri: `skill://${"very-long-segment/".repeat(40)}SKILL.md`,
frontmatter: {
name: "x".repeat(300),
// SEP-2640 permits 1,024 characters here.
description: "word ".repeat(400).trim(),
},
resources: [
{
uri: `skill://${"very-long-segment/".repeat(40)}SKILL.md`,
digest: SELF_DIGEST,
size: textToBytes(SELF_TEXT).byteLength,
},
],
};
renderWithMantine(<ControlledSkillsScreen skills={[hostile]} />);
await user.click(screen.getAllByText("x".repeat(300))[0]);
// The *geometric* bound is a CSS concern and belongs in a real browser —
// `HostileHeader` in the stories asserts the header cannot starve the
// accordion. What is worth pinning here is the contract that makes
// clamping safe: the full value stays reachable on a `title`, so nothing
// is actually hidden from the user.
// Two captions legitimately carry it: the header's URI and the Skill
// Resource control's file name, which for the skill's own SKILL.md is the
// same URI.
expect(screen.getAllByTitle(hostile.uri).length).toBeGreaterThanOrEqual(1);
expect(
screen.getByTitle(hostile.frontmatter.description as string),
).toBeInTheDocument();
// And the header still shows all three, rather than dropping any.
const detail = screen.getByTestId("skill-detail");
expect(detail.textContent).toContain("xxxx");
expect(detail.textContent).toContain("skill://very-long-segment");
expect(detail.textContent).toContain("word word");
});
it("badges a warning-only entry yellow, not green", async () => {
// Green reads as "nothing to see", which would hide the only signal the
// section carries for an entry whose findings are all warnings (#2263).
const user = userEvent.setup();
renderWithMantine(<ControlledSkillsScreen />);
await user.click(screen.getByText("dynamic-report"));
// `dynamic-resources` is a warning, and the only finding on this fixture.
const style = badgeStyle(/0 error\(s\), 1 warning\(s\)/);
expect(style).toContain("yellow");
expect(style).not.toContain("green");
});
it("badges a clean entry green and a broken one red", async () => {
const user = userEvent.setup();
renderWithMantine(<ControlledSkillsScreen />);
await user.click(screen.getByText("data-analysis"));
expect(badgeStyle(/0 error\(s\), 0 warning\(s\)/)).toContain("green");
await user.click(screen.getByText("right-name"));
expect(badgeStyle(/1 error\(s\), 0 warning\(s\)/)).toContain("red");
});
it("shows the name/path mismatch as a distinct, named finding", async () => {
const user = userEvent.setup();
renderWithMantine(<ControlledSkillsScreen />);
@ -207,17 +436,48 @@ describe("SkillsScreen", () => {
expect(within(issues).getByText("name-path-mismatch")).toBeInTheDocument();
});
it("shows the dynamic warning and no manifest table", async () => {
it("states the dynamic case once, in Conformance, with no Resources section", async () => {
// A dynamic skill has no manifest, so an empty Resources section whose only
// content explains its own emptiness is redundant with the conformance
// finding — the fact is stated once, in prose, in Conformance (#2263).
const user = userEvent.setup();
renderWithMantine(<ControlledSkillsScreen />);
await user.click(screen.getByText("dynamic-report"));
expect(screen.getByText("Dynamic resources")).toBeInTheDocument();
const conformance = screen.getByRole("button", { name: /Conformance/ });
expect(conformance.closest(".mantine-Accordion-item")).toContainElement(
screen.getByText("Dynamic resources"),
);
// The section, its header and its table are all gone — not merely empty.
expect(
screen.queryByRole("button", { name: /Resources/ }),
).not.toBeInTheDocument();
expect(screen.queryByTestId("skill-manifest")).not.toBeInTheDocument();
// And the terse finding is not repeated beside the prose banner.
expect(screen.queryByText("dynamic-resources")).not.toBeInTheDocument();
// It still counts toward the warning total, because it is still a finding.
expect(
screen.getByText(/0 error\(s\), 1 warning\(s\)/),
).toBeInTheDocument();
// "Verify all" has nothing to verify, so it is disabled rather than a
// button that silently does nothing.
expect(screen.getByRole("button", { name: /Verify all/ })).toBeDisabled();
});
it("expand-all stays satisfiable for a dynamic skill", async () => {
// The toggle compares against the sections that actually render; leaving
// `resources` in that list would make "expand all" unreachable here.
const user = userEvent.setup();
renderWithMantine(<ControlledSkillsScreen />);
await user.click(screen.getByText("dynamic-report"));
await user.click(screen.getByRole("button", { name: "Collapse all" }));
await user.click(screen.getByRole("button", { name: "Expand all" }));
expect(
screen.getByRole("button", { name: "Collapse all" }),
).toBeInTheDocument();
});
it("verifies a file whose bytes match its digest", async () => {
const user = userEvent.setup();
renderWithMantine(<ControlledSkillsScreen />);
@ -262,7 +522,10 @@ describe("SkillsScreen", () => {
await user.click(screen.getByRole("button", { name: /Verify all/ }));
// One alert per file in the manifest — both reads failed.
expect(await screen.findAllByText("Could not read file")).toHaveLength(2);
expect(screen.getAllByText("403")).toHaveLength(2);
// Three, not two: the same rejecting read also serves the SKILL.md the
// viewer loads on selection (#2263), so the message appears once per
// manifest row plus once in the viewer.
expect(screen.getAllByText("403")).toHaveLength(3);
});
it("wraps a non-Error read rejection", async () => {
@ -273,7 +536,9 @@ describe("SkillsScreen", () => {
);
await user.click(screen.getByText("data-analysis"));
await user.click(screen.getByRole("button", { name: /Verify all/ }));
expect(await screen.findAllByText("plain string")).toHaveLength(2);
// Three: one per manifest row, plus the viewer's own auto-loaded SKILL.md
// read, which the same mock rejects (#2263).
expect(await screen.findAllByText("plain string")).toHaveLength(3);
});
it("titles a size disagreement a size mismatch, not a digest one", async () => {
@ -336,7 +601,7 @@ describe("SkillsScreen", () => {
it("renders a base64 SKILL.md preview instead of a blank one", async () => {
// `onReadSkillFile` supports blob content, and verification reads it
// correctly; dropping it in the preview would paint an empty box for a
// correctly; dropping it in the viewer would paint an empty box for a
// file the screen had just checked.
const user = userEvent.setup();
const onReadSkillFile = vi.fn().mockResolvedValue({
@ -347,9 +612,8 @@ describe("SkillsScreen", () => {
<ControlledSkillsScreen onReadSkillFile={onReadSkillFile} />,
);
await user.click(screen.getByText("data-analysis"));
await user.click(screen.getByRole("button", { name: /View SKILL.md/ }));
const preview = await screen.findByTestId("skill-md-preview");
expect(preview).toHaveTextContent("from a blob");
const viewer = screen.getByTestId("skill-resource-viewer");
await waitFor(() => expect(viewer).toHaveTextContent("from a blob"));
});
it("keeps the newest verdict when two verifications of one row overlap", async () => {
@ -382,18 +646,21 @@ describe("SkillsScreen", () => {
/>,
);
await user.click(screen.getByText("data-analysis"));
// Selecting the skill already issued the viewer's own SKILL.md read
// (#2263), so the two clicks below are the reads AFTER that one.
const base = resolvers.length;
const rowVerify = screen.getByRole("button", {
name: "Verify skill://data-analysis/SKILL.md",
});
await user.click(rowVerify);
await user.click(rowVerify);
expect(resolvers).toHaveLength(2);
expect(resolvers).toHaveLength(base + 2);
// The SECOND read answers first with the matching bytes, then the first
// read answers with bytes that would verify as a mismatch.
resolvers[1]({ text: SELF_TEXT });
resolvers[base + 1]({ text: SELF_TEXT });
expect(await screen.findByText("verified")).toBeInTheDocument();
resolvers[0]({ text: "stale bytes\n" });
resolvers[base]({ text: "stale bytes\n" });
// Still the newer verdict.
expect(await screen.findByText("verified")).toBeInTheDocument();
expect(screen.queryByText("mismatch")).not.toBeInTheDocument();
@ -460,6 +727,14 @@ describe("SkillsScreen", () => {
expect(
await screen.findByText("skills/get matches skills/list"),
).toBeInTheDocument();
// The verdict is a conformance statement, so it renders inside the
// Conformance section (#2263) — and that section auto-collapses for a clean
// entry, so the fetch has to open it or the answer would be invisible.
const conformance = screen.getByRole("button", { name: /Conformance/ });
expect(conformance).toHaveAttribute("aria-expanded", "true");
expect(conformance.closest(".mantine-Accordion-item")).toContainElement(
screen.getByTestId("skills-get-result"),
);
});
it("treats key and manifest order as immaterial when matching", async () => {
@ -608,19 +883,20 @@ describe("SkillsScreen", () => {
<ControlledSkillsScreen onReadSkillFile={onReadSkillFile} />,
);
await user.click(screen.getByText("data-analysis"));
const view = screen.getByRole("button", { name: /View SKILL.md/ });
// Past the viewer's own read for the selection (#2263).
const base = resolvers.length;
const view = screen.getByRole("button", {
name: "skill://data-analysis/reference.md",
});
await user.click(view);
await user.click(view);
expect(resolvers).toHaveLength(2);
expect(resolvers).toHaveLength(base + 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",
);
const viewer = screen.getByTestId("skill-resource-viewer");
resolvers[base + 1]({ text: "# newest\n" });
await waitFor(() => expect(viewer).toHaveTextContent("newest"));
resolvers[base]({ text: "# stale\n" });
expect(viewer).not.toHaveTextContent("stale");
});
it("keeps the newest skills/get result when two fetches overlap", async () => {
@ -804,18 +1080,21 @@ describe("SkillsScreen", () => {
<ControlledSkillsScreen onReadSkillFile={onReadSkillFile} />,
);
await user.click(screen.getByText("data-analysis"));
const view = screen.getByRole("button", { name: /View SKILL.md/ });
// Past the viewer's own read for the selection (#2263).
const base = resolvers.length;
const view = screen.getByRole("button", {
name: "skill://data-analysis/reference.md",
});
await user.click(view);
await user.click(view);
expect(resolvers).toHaveLength(2);
expect(resolvers).toHaveLength(base + 2);
const viewer = screen.getByTestId("skill-resource-viewer");
// The OLDER read answers first, while the newer one is still in flight.
resolvers[0]({ text: "# stale\n" });
expect(screen.queryByTestId("skill-md-preview")).not.toBeInTheDocument();
resolvers[1]({ text: "# newest\n" });
expect(await screen.findByTestId("skill-md-preview")).toHaveTextContent(
"newest",
);
resolvers[base]({ text: "# stale\n" });
expect(viewer).not.toHaveTextContent("stale");
resolvers[base + 1]({ text: "# newest\n" });
await waitFor(() => expect(viewer).toHaveTextContent("newest"));
});
it("rejects an older skills/get even when it resolves FIRST", async () => {
@ -849,12 +1128,190 @@ describe("SkillsScreen", () => {
).toBeInTheDocument();
});
it("shows the SKILL.md preview on demand", async () => {
it("shows the skill's own SKILL.md as soon as it is selected", async () => {
// No button to press (#2263): the viewer opens on the skill's own file, so
// selecting it is the whole interaction.
const user = userEvent.setup();
renderWithMantine(<ControlledSkillsScreen />);
await user.click(screen.getByText("data-analysis"));
await user.click(screen.getByRole("button", { name: /View SKILL.md/ }));
expect(await screen.findByTestId("skill-md-preview")).toBeInTheDocument();
const viewer = screen.getByTestId("skill-resource-viewer");
await waitFor(() => expect(viewer).toHaveTextContent("data-analysis"));
expect(
screen.queryByRole("button", { name: /View SKILL.md/ }),
).not.toBeInTheDocument();
});
it("heads the viewer with the displayed file, not the section's purpose", async () => {
// The heading is static so it does not change shape as the file changes;
// the file name sits beside it (#2263).
const user = userEvent.setup();
renderWithMantine(<ControlledSkillsScreen />);
await user.click(screen.getByText("data-analysis"));
// The heading lives on the section's control (it is the collapsible
// section's own header), so it is queried at screen level rather than
// inside the panel.
const control = within(
screen.getByRole("button", { name: /Skill Resource/ }),
);
expect(control.getByText("Skill Resource")).toBeInTheDocument();
expect(control.getByText("SKILL.md")).toBeInTheDocument();
});
it("swaps the displayed file when a manifest URI is clicked", async () => {
const user = userEvent.setup();
renderWithMantine(<ControlledSkillsScreen />);
await user.click(screen.getByText("data-analysis"));
const viewer = screen.getByTestId("skill-resource-viewer");
await waitFor(() => expect(viewer).toHaveTextContent("data-analysis"));
await user.click(
screen.getByRole("button", {
name: "skill://data-analysis/reference.md",
}),
);
await waitFor(() => expect(viewer).toHaveTextContent("Column rules"));
// The section header follows the file, and the previous contents are gone.
expect(
within(screen.getByRole("button", { name: /Skill Resource/ })).getByText(
"reference.md",
),
).toBeInTheDocument();
expect(viewer).not.toHaveTextContent("data-analysis");
});
it("shows the frontmatter of the file on display, and hides the section when it has none", async () => {
// Both halves come from one split (#2263), so the section can never show
// one file's frontmatter beside another file's body — and the viewer never
// repeats what the section is already showing.
const user = userEvent.setup();
const onReadSkillFile = vi.fn(async (uri: string) =>
uri.endsWith("reference.md")
? { text: "# Ref\n\nNo frontmatter here.\n" }
: { text: "---\nname: data-analysis\n---\n\n# The body\n" },
);
renderWithMantine(
<ControlledSkillsScreen onReadSkillFile={onReadSkillFile} />,
);
await user.click(screen.getByText("data-analysis"));
const viewer = screen.getByTestId("skill-resource-viewer");
await waitFor(() => expect(viewer).toHaveTextContent("The body"));
// Shown once, in its own section — not again in the viewer.
expect(
screen.getByRole("button", { name: /Frontmatter/ }),
).toBeInTheDocument();
expect(viewer).not.toHaveTextContent("name: data-analysis");
// reference.md has no frontmatter, so the section goes away entirely
// rather than lingering with SKILL.md's fields.
await user.click(
screen.getByRole("button", {
name: "skill://data-analysis/reference.md",
}),
);
await waitFor(() =>
expect(viewer).toHaveTextContent("No frontmatter here"),
);
expect(
screen.queryByRole("button", { name: /Frontmatter/ }),
).not.toBeInTheDocument();
});
it("lets a .md suffix outrank a generic declared MIME", async () => {
// Servers routinely serve SKILL.md as `text/plain`. Letting that outrank
// the suffix meant the file was not recognised as markdown, so its YAML
// stayed in the viewer and the Frontmatter section disappeared — for a
// perfectly valid skill.
const user = userEvent.setup();
const onReadSkillFile = vi.fn(async () => ({
text: "---\nname: data-analysis\n---\n\n# The body\n",
mimeType: "text/plain",
}));
renderWithMantine(
<ControlledSkillsScreen onReadSkillFile={onReadSkillFile} />,
);
await user.click(screen.getByText("data-analysis"));
const viewer = screen.getByTestId("skill-resource-viewer");
await waitFor(() => expect(viewer).toHaveTextContent("The body"));
expect(
screen.getByRole("button", { name: /Frontmatter/ }),
).toBeInTheDocument();
expect(viewer).not.toHaveTextContent("name: data-analysis");
});
it("keeps a SPECIFIC declared MIME over the suffix", async () => {
// The converse: a server that says `text/csv` for a `.md` URI knows its own
// resource, so the declaration wins and nothing is split.
const user = userEvent.setup();
const onReadSkillFile = vi.fn(async () => ({
text: "---\na,b\n---\n1,2\n",
mimeType: "text/csv",
}));
renderWithMantine(
<ControlledSkillsScreen onReadSkillFile={onReadSkillFile} />,
);
await user.click(screen.getByText("data-analysis"));
await waitFor(() =>
expect(
screen.queryByRole("button", { name: /Frontmatter/ }),
).not.toBeInTheDocument(),
);
});
it("does not treat an untyped supporting resource as markdown", async () => {
// SEP-2640 expects a manifest to carry supporting scripts, examples and
// assets with types of their own. A markdown fallback is right for the
// skill's OWN SKILL.md and wrong for the rest: an extensionless, untyped
// blob would be decoded and rendered as markdown rather than as binary.
const user = userEvent.setup();
const asset: SkillEntry = {
uri: "skill://assets/SKILL.md",
frontmatter: { name: "assets", description: "Has a typeless blob" },
resources: [
{ uri: "skill://assets/SKILL.md", digest: SELF_DIGEST, size: 1 },
// No suffix and no mimeType — nothing says what this is.
{ uri: "skill://assets/payload", digest: SELF_DIGEST, size: 1 },
],
};
const onReadSkillFile = vi.fn(async (uri: string) =>
uri.endsWith("payload")
? { blob: btoa("---\nnot: frontmatter\n---\n\nbinary-ish") }
: { text: SELF_TEXT, mimeType: "text/markdown" },
);
renderWithMantine(
<ControlledSkillsScreen
skills={[asset]}
onReadSkillFile={onReadSkillFile}
/>,
);
await user.click(screen.getByText("assets"));
await user.click(
screen.getByRole("button", { name: "skill://assets/payload" }),
);
// Not split, so no Frontmatter section is invented for it...
await waitFor(() =>
expect(
screen.queryByRole("button", { name: /Frontmatter/ }),
).not.toBeInTheDocument(),
);
});
it("marks the row whose file the viewer is showing", async () => {
const user = userEvent.setup();
renderWithMantine(<ControlledSkillsScreen />);
await user.click(screen.getByText("data-analysis"));
const self = screen.getByRole("button", {
name: "skill://data-analysis/SKILL.md",
});
const other = screen.getByRole("button", {
name: "skill://data-analysis/reference.md",
});
// The skill's own file is what the viewer opens on, so its row is current.
expect(self).toHaveAttribute("aria-current", "true");
expect(other).not.toHaveAttribute("aria-current");
await user.click(other);
await waitFor(() => expect(other).toHaveAttribute("aria-current", "true"));
expect(self).not.toHaveAttribute("aria-current");
});
it("reports a failed SKILL.md read", async () => {
@ -864,10 +1321,10 @@ describe("SkillsScreen", () => {
<ControlledSkillsScreen onReadSkillFile={onReadSkillFile} />,
);
await user.click(screen.getByText("data-analysis"));
await user.click(screen.getByRole("button", { name: /View SKILL.md/ }));
expect(
await screen.findByText("Could not read SKILL.md"),
await screen.findByText("Could not read this resource"),
).toBeInTheDocument();
expect(screen.getByText("gone")).toBeInTheDocument();
});
it("wraps a non-Error SKILL.md rejection", async () => {
@ -877,10 +1334,107 @@ describe("SkillsScreen", () => {
<ControlledSkillsScreen onReadSkillFile={onReadSkillFile} />,
);
await user.click(screen.getByText("data-analysis"));
await user.click(screen.getByRole("button", { name: /View SKILL.md/ }));
expect(await screen.findByText("bare")).toBeInTheDocument();
});
it("keeps the sections independently collapsible", async () => {
const user = userEvent.setup();
renderWithMantine(<ControlledSkillsScreen />);
// Everything starts open; `right-name` has a finding, so Conformance is
// open here too rather than auto-collapsed.
await user.click(screen.getByText("right-name"));
for (const name of [/Conformance/, /Resources/, /Frontmatter/]) {
expect(screen.getByRole("button", { name })).toHaveAttribute(
"aria-expanded",
"true",
);
}
// Collapsing one leaves the others alone — `multiple`, not a single-open
// accordion.
await user.click(screen.getByRole("button", { name: /Conformance/ }));
expect(screen.getByRole("button", { name: /Conformance/ })).toHaveAttribute(
"aria-expanded",
"false",
);
expect(screen.getByRole("button", { name: /Resources/ })).toHaveAttribute(
"aria-expanded",
"true",
);
});
it("expand-all covers sections that are not visible yet", async () => {
// `sectionIds` holds only what renders at this instant, and "expand all"
// used to write exactly that — so a section absent at the moment of the
// click (Frontmatter, while the read is still in flight; Resources, on a
// dynamic skill) was DROPPED from the open set, and arrived collapsed with
// the control offering to expand all over again (#2263).
const user = userEvent.setup();
renderWithMantine(<ControlledSkillsScreen />);
// Start on the dynamic skill, which renders no Resources section at all.
await user.click(screen.getByText("dynamic-report"));
// Settle the auto-read before touching the toggle, so the click lands on a
// known state rather than racing the section set.
await waitFor(() =>
expect(
screen.getByRole("button", { name: /Collapse all|Expand all/ }),
).toBeInTheDocument(),
);
const toggle = () =>
screen.getByRole("button", { name: /Collapse all|Expand all/ });
if (toggle().getAttribute("aria-label") === "Collapse all") {
await user.click(toggle());
}
await user.click(screen.getByRole("button", { name: "Expand all" }));
// Switch to a static skill WITH findings, so the clean-entry collapse rule
// does not overlap with what this test is about. Scoped to the sidebar:
// with every section expanded, the skill's own name also appears in the
// detail pane's frontmatter block.
await user.click(
within(screen.getByTestId("skills-screen")).getAllByText("right-name")[0],
);
await waitFor(() =>
expect(screen.getByRole("button", { name: /Resources/ })).toHaveAttribute(
"aria-expanded",
"true",
),
);
expect(screen.getByRole("button", { name: /Frontmatter/ })).toHaveAttribute(
"aria-expanded",
"true",
);
// And the control agrees that everything is open.
expect(
screen.getByRole("button", { name: "Collapse all" }),
).toBeInTheDocument();
});
it("toggles every section at once from the header control", async () => {
const user = userEvent.setup();
renderWithMantine(<ControlledSkillsScreen />);
const ALL = [/Conformance/, /Resources/, /Frontmatter/, /Skill Resource/];
await user.click(screen.getByText("right-name"));
// The shared `ListToggle` element, whose labels are "Expand all" /
// "Collapse all". Everything starts open, so it offers to collapse first.
await user.click(screen.getByRole("button", { name: "Collapse all" }));
for (const name of ALL) {
expect(screen.getByRole("button", { name })).toHaveAttribute(
"aria-expanded",
"false",
);
}
// And back the other way from the same control.
await user.click(screen.getByRole("button", { name: "Expand all" }));
for (const name of ALL) {
expect(screen.getByRole("button", { name })).toHaveAttribute(
"aria-expanded",
"true",
);
}
});
it("drops verification results when the selection changes", async () => {
const user = userEvent.setup();
renderWithMantine(<ControlledSkillsScreen />);
@ -895,14 +1449,114 @@ describe("SkillsScreen", () => {
expect(screen.getAllByText("—")).toHaveLength(2);
});
it("drops the SKILL.md preview when the selection changes", async () => {
it("issues exactly one automatic read per selection within a mount", async () => {
// The app renders under StrictMode, which deliberately replays effects, so
// without a guard one selection fires two identical `resources/read` calls
// — and in a protocol inspector a phantom request in the Protocol panel is
// worse than a wasted round trip: the tool misreports the conversation
// (#2263). The scope is deliberately one MOUNT: a `ScreenStage` remount
// mints a fresh ref and reads again, which is correct, because the preview
// bytes are local state and died with the same unmount.
const user = userEvent.setup();
renderWithMantine(<ControlledSkillsScreen />);
const onReadSkillFile = vi.fn(async () => ({ text: SELF_TEXT }));
// Mounted with the skill ALREADY selected — a restored `SkillsUiState`.
//
// ⚠️ This pins the CONTRACT (one automatic read per selection) rather than
// guarding it: this environment does not double-invoke mount effects, so
// the test passes with or without `autoReadKey`. Do not read a pass here as
// evidence the duplicate-read defect is fixed; that is only observable in a
// real dev-mode browser. The reappearance test below IS a guard.
renderWithMantine(
<StrictMode>
<ControlledSkillsScreen
onReadSkillFile={onReadSkillFile}
ui={{ ...EMPTY_SKILLS_UI, selectedSkillUri: CLEAN_SKILL.uri }}
/>
</StrictMode>,
);
await waitFor(() => expect(onReadSkillFile).toHaveBeenCalledTimes(1));
// A genuine selection change is a different manifest, so it reads once more.
await user.click(screen.getAllByText("tampered")[0]);
await waitFor(() => expect(onReadSkillFile).toHaveBeenCalledTimes(2));
});
it("re-reads when the selected entry leaves the list and comes back", async () => {
// A refresh in flight (or a disconnect) can empty `skills` while the
// selection persists. The render invalidates the preview, so the viewer is
// blank — and when the IDENTICAL entry returns its `manifestKey` matches
// what the guard still holds. Without clearing the guard on the way out,
// the read is skipped and the viewer stays permanently empty (#2263).
const onReadSkillFile = vi.fn(async () => ({
text: "---\nname: data-analysis\n---\n\nreloaded-body\n",
}));
const selectedUi = {
...EMPTY_SKILLS_UI,
selectedSkillUri: CLEAN_SKILL.uri,
};
const { rerender } = renderWithMantine(
<ControlledSkillsScreen
onReadSkillFile={onReadSkillFile}
ui={selectedUi}
/>,
);
await waitFor(() => expect(onReadSkillFile).toHaveBeenCalledTimes(1));
await waitFor(() =>
expect(screen.getByTestId("skill-resource-viewer")).toHaveTextContent(
"reloaded-body",
),
);
rerender(
<ControlledSkillsScreen
onReadSkillFile={onReadSkillFile}
skills={[]}
ui={selectedUi}
/>,
);
rerender(
<ControlledSkillsScreen
onReadSkillFile={onReadSkillFile}
ui={selectedUi}
/>,
);
await waitFor(() => expect(onReadSkillFile).toHaveBeenCalledTimes(2));
await waitFor(() =>
expect(screen.getByTestId("skill-resource-viewer")).toHaveTextContent(
"reloaded-body",
),
);
});
it("re-points the viewer at the newly selected skill's own file", async () => {
const user = userEvent.setup();
const onReadSkillFile = vi.fn(async (uri: string) => ({
text: `contents of ${uri}\n`,
}));
renderWithMantine(
<ControlledSkillsScreen onReadSkillFile={onReadSkillFile} />,
);
await user.click(screen.getByText("data-analysis"));
await user.click(screen.getByRole("button", { name: /View SKILL.md/ }));
expect(await screen.findByTestId("skill-md-preview")).toBeInTheDocument();
const viewer = screen.getByTestId("skill-resource-viewer");
await waitFor(() =>
expect(viewer).toHaveTextContent("contents of skill://data-analysis"),
);
// The previous skill's contents must not survive the switch: the viewer
// follows the selection rather than holding whatever was last read.
await user.click(screen.getByText("tampered"));
expect(screen.queryByTestId("skill-md-preview")).not.toBeInTheDocument();
// Re-queried, not reused: the accordion is keyed by the manifest so that a
// skill change gives every panel a fresh scroll container (#2263), which
// means the node captured above is detached and frozen on the old content.
await waitFor(() =>
expect(screen.getByTestId("skill-resource-viewer")).toHaveTextContent(
"contents of skill://tampered/SKILL.md",
),
);
expect(screen.getByTestId("skill-resource-viewer")).not.toHaveTextContent(
"contents of skill://data-analysis",
);
});
it("renders an em dash for a manifest entry with no size or digest", async () => {
@ -1032,10 +1686,15 @@ describe("SkillsScreen", () => {
<ControlledSkillsScreen onReadSkillFile={onReadSkillFile} />,
);
await user.click(screen.getByText("data-analysis"));
await user.click(screen.getByRole("button", { name: /View SKILL.md/ }));
// `release` now holds the resolver for the SECOND skill's auto-read; the
// first skill's is stranded, which is the point — resolving the older one
// must not publish into the newer selection.
const stale = release;
await user.click(screen.getByText("tampered"));
release?.({ text: SELF_TEXT });
expect(screen.queryByTestId("skill-md-preview")).not.toBeInTheDocument();
stale?.({ text: "# from the abandoned skill\n" });
expect(screen.getByTestId("skill-resource-viewer")).not.toHaveTextContent(
"abandoned",
);
});
it("discards a failed SKILL.md read that resolves after the selection moved on", async () => {
@ -1051,9 +1710,10 @@ describe("SkillsScreen", () => {
<ControlledSkillsScreen onReadSkillFile={onReadSkillFile} />,
);
await user.click(screen.getByText("data-analysis"));
await user.click(screen.getByRole("button", { name: /View SKILL.md/ }));
// The abandoned skill's own read, stranded by the selection change below.
const stale = fail;
await user.click(screen.getByText("tampered"));
fail?.(new Error("too late"));
stale?.(new Error("too late"));
expect(screen.queryByText("too late")).not.toBeInTheDocument();
});
});

View file

@ -216,9 +216,10 @@ export interface ServerCommands {
uri: string,
) => Promise<Awaited<ReturnType<InspectorClient["readResource"]>>["result"]>;
/**
* Read one skill file's contents (SEP-2640), for digest verification. Returns
* the single content block that answers the URI, narrowed to the two fields
* the digest is taken over.
* Read one skill file's contents (SEP-2640) — for display in the Skills
* screen's viewer, and for digest verification. Returns the single content
* block that answers the URI, narrowed to the two fields the digest is taken
* over.
*/
onReadSkillFile: (uri: string) => Promise<SkillFileContents>;
/** Re-fetch one skill entry through `skills/get` (SEP-2640). */
@ -947,9 +948,13 @@ export function useServerCommands({
runCommandInBackground(() => resourcesPagination.onLoadMore(), "ambient"),
[resourcesPagination, runCommandInBackground],
);
// Skill files are fetched on demand, never pre-fetched: SEP-2640 is explicit
// that a `resources/read` of a skill file is not a load and confers no
// standing, so the Inspector reads only what the user asks it to verify.
// Skill files are still never pre-fetched in bulk — nothing walks the
// manifest reading everything. What IS read without being asked is the single
// `SKILL.md` of whichever skill is selected, so the file is on screen (#2263);
// SEP-2640 is explicit that a `resources/read` of a skill file is not a load
// and confers no standing, so that claims nothing on the user's behalf.
// Digest verification remains strictly on demand.
//
// Routed through `onReadResourceContents` so a skill read gets the same
// auth-recovery retry every other read does.
const onReadSkillFile = useCallback(

View file

@ -14,7 +14,7 @@ export const ThemeAccordion = Accordion.extend({
// and a filled background when the section is open (`aria-expanded`).
// Pair it with `chevron={<RiArrowRightSLine />}` and per-item `flex` weights.
classNames: (_theme, props) => {
if (props.variant === "disclosure")
if (props.variant === "disclosure" || props.variant === "skillSections")
return {
root: "disclosure-sections",
chevron: "disclosure-chevron",
@ -22,4 +22,34 @@ export const ThemeAccordion = Accordion.extend({
};
return {};
},
// `skillSections` is `disclosure` plus a scrolling root (#2263).
//
// The Skills pane's metadata sections use `flex: 0 1 auto` with a `mih`
// floor: they give up space until they reach that floor and then scroll their
// own panels, which is what leaves the file viewer its share no matter how
// large a manifest or findings list gets.
//
// The scrolling root is the FALLBACK for when even the combined floors do not
// fit — with several sections open in a short window there is no arrangement
// that shows everything, and scrolling the stack at a section boundary beats
// crushing a panel below its floor.
//
// ⚠️ The floor is load-bearing, not decoration. Without it a section can be
// squeezed far below its content and slice it mid-line; with `flex-shrink: 0`
// instead, a 512-row manifest keeps its full height and pushes the viewer off
// the bottom of the pane. `SECTION_FLEX` in `SkillsScreen.tsx` records both
// failures — this variant only works in combination with it.
styles: (_theme, props) => {
if (props.variant === "skillSections") {
return {
root: { overflowY: "auto", minHeight: 0 },
// Every section gets the same breathing room under its header that its
// banners get between each other — without it a panel's first item sits
// flush against the control and reads as part of the header rather than
// as the section's content.
content: { paddingTop: "var(--mantine-spacing-sm)" },
};
}
return {};
},
});

View file

@ -0,0 +1,14 @@
import { Table } from "@mantine/core";
export const ThemeTable = Table.extend({
// The Skills resource manifest (#2263). `striped`, `withTableBorder`,
// `highlightOnHover` and `verticalSpacing` are Mantine's own API and stay
// props at the call site; the font size is a flat CSS property, so it lives
// here rather than as an `fz` prop on the screen's `.withProps()` constant.
styles: (_theme, props) => {
if (props.variant === "manifest") {
return { table: { fontSize: "var(--mantine-font-size-xs)" } };
}
return {};
},
});

View file

@ -42,6 +42,45 @@ export const ThemeText = Text.extend({
// footer's `space-between` Group. Both are grey, single-line, and out of the
// text-selection flow. (Superseded the fixed bottom-corner badges of #1639
// now that the footer is a real, full-width AppShell row.)
// The four typographic treatments the Skills screen repeats (#2263). They
// live here rather than as `fw`/`size`/`c`/`ff` props on the screen's
// `.withProps()` constants because they are flat CSS properties, which
// AGENTS.md places in the theme; the constants keep only layout.
//
// `sectionHeading` labels a collapsible section; `skillTitle` names the
// selected skill; `monoCaption` is the dimmed monospace line used for URIs
// and digests; `emptyState` is the centred placeholder shown before a
// selection exists.
if (props.variant === "sectionHeading") {
return {
root: { fontWeight: 600, fontSize: "var(--mantine-font-size-sm)" },
};
}
if (props.variant === "skillTitle") {
return {
root: { fontWeight: 600, fontSize: "var(--mantine-font-size-lg)" },
};
}
if (props.variant === "skillDescription") {
return { root: { fontSize: "var(--mantine-font-size-sm)" } };
}
if (props.variant === "monoCaption") {
return {
root: {
fontSize: "var(--mantine-font-size-xs)",
fontFamily: "var(--mantine-font-family-monospace)",
color: "var(--inspector-text-secondary)",
},
};
}
if (props.variant === "emptyState") {
return {
root: {
color: "var(--inspector-text-secondary)",
textAlign: "center",
},
};
}
if (
props.variant === "versionBadge" ||
props.variant === "copyrightBadge"

View file

@ -16,6 +16,7 @@ export { ThemePaper } from "./Paper";
export { ThemeScrollArea, ThemeScrollAreaAutosize } from "./ScrollArea";
export { ThemeSelect } from "./Select";
export { ThemeSwitch } from "./Switch";
export { ThemeTable } from "./Table";
export { ThemeText } from "./Text";
export { ThemeTextInput } from "./TextInput";
export { ThemeTitle } from "./Title";

View file

@ -19,6 +19,7 @@ import {
ThemeScrollAreaAutosize,
ThemeSelect,
ThemeSwitch,
ThemeTable,
ThemeText,
ThemeTextInput,
ThemeTitle,
@ -201,6 +202,7 @@ export const theme = createTheme({
ScrollAreaAutosize: ThemeScrollAreaAutosize,
Select: ThemeSelect,
Switch: ThemeSwitch,
Table: ThemeTable,
Text: ThemeText,
TextInput: ThemeTextInput,
Title: ThemeTitle,

View file

@ -0,0 +1,138 @@
import { describe, it, expect } from "vitest";
import {
inferMimeFromUri,
isGenericMime,
isMarkdownMime,
} from "./inferMimeFromUri";
describe("isGenericMime", () => {
it("names the types a server sends when it does not really know", () => {
expect(isGenericMime("text/plain")).toBe(true);
expect(isGenericMime("application/octet-stream")).toBe(true);
// Normalised like `isMarkdownMime`, so parameters and casing still count.
expect(isGenericMime("text/plain; charset=utf-8")).toBe(true);
expect(isGenericMime("TEXT/PLAIN")).toBe(true);
});
it("does not treat a specific type as generic", () => {
// A server that says `text/markdown` or `text/csv` knows its own resource,
// and that declaration must outrank a URI suffix.
expect(isGenericMime("text/markdown")).toBe(false);
expect(isGenericMime("text/csv")).toBe(false);
expect(isGenericMime("application/json")).toBe(false);
expect(isGenericMime(undefined)).toBe(false);
});
});
describe("inferMimeFromUri", () => {
it("maps every suffix in the table", () => {
// The whole table, so a typo in an entry cannot pass unnoticed — this is
// the only thing that engages ContentViewer's per-MIME renderers when a
// server omits `mimeType`.
expect(inferMimeFromUri("skill://a/SKILL.md")).toBe("text/markdown");
expect(inferMimeFromUri("skill://a/notes.markdown")).toBe("text/markdown");
expect(inferMimeFromUri("file:///data.csv")).toBe("text/csv");
expect(inferMimeFromUri("file:///a.json")).toBe("application/json");
expect(inferMimeFromUri("file:///a.xml")).toBe("application/xml");
expect(inferMimeFromUri("file:///a.html")).toBe("text/html");
expect(inferMimeFromUri("file:///a.htm")).toBe("text/html");
expect(inferMimeFromUri("file:///a.css")).toBe("text/css");
expect(inferMimeFromUri("file:///a.pdf")).toBe("application/pdf");
});
it("is case-insensitive about the suffix", () => {
expect(inferMimeFromUri("skill://a/SKILL.MD")).toBe("text/markdown");
});
it("ignores a query string and a fragment", () => {
// A URI's suffix is a property of its path; `?v=2` must not defeat the
// match, and a fragment must not be mistaken for one.
expect(inferMimeFromUri("https://x/a.md?v=2")).toBe("text/markdown");
expect(inferMimeFromUri("https://x/a.md#top")).toBe("text/markdown");
expect(inferMimeFromUri("https://x/a.md?v=2#top")).toBe("text/markdown");
});
it("matches a percent-encoded suffix", () => {
// `reference%2Emd` names the same file as `reference.md`, and
// `skillUriIdentity` already treats those spellings as equivalent — so
// matching the raw string here disagreed with the rest of the app and left
// an encoded `.md` with no MIME, no markdown renderer and no split.
expect(inferMimeFromUri("skill://a/reference%2Emd")).toBe("text/markdown");
expect(inferMimeFromUri("skill://a/report%2Epdf")).toBe("application/pdf");
// A percent-encoded path segment separator is decoded too.
expect(inferMimeFromUri("skill://a/docs%2Fnotes.md")).toBe("text/markdown");
});
it("inspects the PATH, not the whole URI", () => {
// An authority ending in a mapped suffix is not a filename:
// `https://documentation.md` has a pathname of `/` and no extension, and
// routing a root resource into the markdown renderer on that basis is
// wrong.
expect(inferMimeFromUri("https://documentation.md")).toBeUndefined();
expect(inferMimeFromUri("https://documentation.md/")).toBeUndefined();
// The same host WITH a real markdown path still resolves.
expect(inferMimeFromUri("https://documentation.md/a/readme.md")).toBe(
"text/markdown",
);
// Non-special schemes SEP-2640 allows parse the same way.
expect(inferMimeFromUri("skill://data-analysis/SKILL.md")).toBe(
"text/markdown",
);
});
it("decodes escape runs independently, so one bad octet cannot poison the path", () => {
// `%FF` is a syntactically valid triplet that is not valid UTF-8, so a
// single `decodeURIComponent` over the whole path throws and abandons the
// rest — leaving the `%2E` in a perfectly acceptable URI undecoded.
expect(inferMimeFromUri("skill://a/%FF/reference%2Emd")).toBe(
"text/markdown",
);
expect(inferMimeFromUri("skill://a/%FF/report%2Epdf")).toBe(
"application/pdf",
);
});
it("falls back to the raw path on a malformed escape", () => {
// `decodeURIComponent` throws on `%zz` or a lone `%`, and a server can send
// either. A MIME guess is the wrong place to raise.
expect(inferMimeFromUri("skill://a/bad%zz.md")).toBe("text/markdown");
expect(inferMimeFromUri("skill://a/100%.md")).toBe("text/markdown");
expect(inferMimeFromUri("skill://a/bad%zz.bin")).toBeUndefined();
});
it("returns undefined for an unrecognised suffix, so callers can default", () => {
expect(inferMimeFromUri("skill://a/notes.bin")).toBeUndefined();
expect(inferMimeFromUri("skill://a/no-extension")).toBeUndefined();
// A dot in the query must not be read as the path's extension.
expect(inferMimeFromUri("https://x/file?name=a.md")).toBeUndefined();
});
});
describe("isMarkdownMime", () => {
it("accepts both spellings of markdown", () => {
expect(isMarkdownMime("text/markdown")).toBe(true);
expect(isMarkdownMime("text/x-markdown")).toBe(true);
});
it("accepts a MIME carrying parameters", () => {
// `text/markdown; charset=utf-8` is a perfectly ordinary response, and
// ContentViewer accepts it. Rejecting it here skipped the frontmatter
// split, so the fence stayed in the document AND the Frontmatter section
// vanished.
expect(isMarkdownMime("text/markdown; charset=utf-8")).toBe(true);
expect(isMarkdownMime("text/markdown;charset=UTF-8")).toBe(true);
});
it("is case-insensitive", () => {
expect(isMarkdownMime("TEXT/MARKDOWN")).toBe(true);
expect(isMarkdownMime("Text/X-Markdown; charset=utf-8")).toBe(true);
});
it("rejects everything else, including undefined", () => {
// The gate on frontmatter splitting: a YAML resource must not be split, or
// a multi-document file loses its first document.
expect(isMarkdownMime("text/yaml")).toBe(false);
expect(isMarkdownMime("application/json")).toBe(false);
expect(isMarkdownMime(undefined)).toBe(false);
});
});

View file

@ -0,0 +1,128 @@
/**
* Infer a resource's MIME type from its URI suffix.
*
* A pure lookup with no I/O, so it lives in `utils/`. Extracted from
* `ResourcePreviewPanel` in #2263 when the Skills screen needed the same
* inference: both panels decide which renderer to engage for a resource whose
* server omitted `mimeType`, and two copies of this table would drift.
*/
// Map a file extension to the MIME type that drives ContentViewer's per-MIME
// renderer dispatch. MCP servers commonly omit `mimeType` (or return a generic
// `text/plain` / `application/octet-stream`), so the URI suffix is the most
// reliable signal for engaging the markdown / PDF / CSV / XML / HTML / CSS
// renderers. Order doesn't matter — suffixes are unique.
const URI_SUFFIX_MIME: ReadonlyArray<readonly [string, string]> = [
[".md", "text/markdown"],
[".markdown", "text/markdown"],
[".csv", "text/csv"],
[".json", "application/json"],
[".xml", "application/xml"],
[".html", "text/html"],
[".htm", "text/html"],
[".css", "text/css"],
[".pdf", "application/pdf"],
];
/**
* The MIME type a URI's file extension implies, or `undefined` for an
* unrecognised suffix so callers can fall through to their own default.
*/
export function inferMimeFromUri(uri: string): string | undefined {
const lower = decodePercentTriplets(pathOf(uri)).toLowerCase();
for (const [suffix, mime] of URI_SUFFIX_MIME) {
if (lower.endsWith(suffix)) return mime;
}
return undefined;
}
/**
* The **path** component of a URI, not the whole string.
*
* Stripping only the query and fragment left the authority in, so a host that
* happened to end in a mapped suffix was read as a filename:
* `https://documentation.md` has a pathname of `/` and no extension at all, yet
* matched `.md` and routed a root resource into the markdown renderer.
*
* `new URL` handles this for every hierarchical URI including the non-special
* schemes SEP-2640 allows (`skill://a/SKILL.md` → `/SKILL.md`). It throws on
* anything it cannot parse — a bare relative name like `notes.md`, say — so
* that case keeps the old string handling.
*/
function pathOf(uri: string): string {
try {
return new URL(uri).pathname;
} catch {
return uri.split("?")[0].split("#")[0];
}
}
/**
* Percent-decode a path for matching, one escape run at a time.
*
* A URI may percent-encode unreserved characters, so `reference%2Emd` names the
* same file as `reference.md` — and `skillUriIdentity` in `core/mcp/skills.ts`
* already treats those spellings as equivalent.
*
* Decoding the path in one `decodeURIComponent` call was not enough. That
* throws on a malformed escape (`%zz`) *and* on a syntactically valid octet
* that is not valid UTF-8 (`%FF`), and a single such octet anywhere in the path
* then defeated decoding for the whole string — `skill://a/%FF/reference%2Emd`
* is a perfectly acceptable URI whose `%2E` would never be seen.
*
* So each run of escapes is decoded independently, and a run that cannot be
* decoded as UTF-8 falls back to decoding its **ASCII** octets individually.
* That is enough for suffix matching, where every character that matters is
* ASCII, and it leaves a byte it cannot interpret untouched rather than
* guessing.
*/
function decodePercentTriplets(path: string): string {
return path.replace(/(?:%[0-9A-Fa-f]{2})+/g, (run) => {
try {
return decodeURIComponent(run);
} catch {
return run.replace(/%([0-9A-Fa-f]{2})/g, (raw, hex: string) => {
const code = Number.parseInt(hex, 16);
return code < 0x80 ? String.fromCharCode(code) : raw;
});
}
});
}
/**
* Types a server sends when it does not really know, or did not bother.
*
* `ResourcePreviewPanel` already records the underlying observation — servers
* "commonly omit `mimeType` (or return a generic `text/plain` /
* `application/octet-stream`), so the URI suffix is the most reliable signal".
* Naming them lets a caller act on that: a declared type this generic is weaker
* evidence than a `.md` suffix, while a specific declared type still wins.
*/
const GENERIC_MIMES = new Set(["text/plain", "application/octet-stream"]);
/**
* Whether a declared MIME is too generic to outrank a URI suffix.
*
* Normalised the same way `isMarkdownMime` is, so `text/plain; charset=utf-8`
* counts as generic too.
*/
export function isGenericMime(mime: string | undefined): boolean {
if (mime === undefined) return false;
return GENERIC_MIMES.has(mime.split(";")[0].trim().toLowerCase());
}
/**
* Whether an effective MIME type is Markdown — the only form that carries YAML
* frontmatter worth splitting off (#2263).
*
* Normalised before comparing, because a server may answer
* `text/markdown; charset=utf-8` or `TEXT/MARKDOWN`, and `ContentViewer`
* accepts both. Comparing the raw string rejected them, which skipped the
* split: the frontmatter stayed in the rendered document AND the Frontmatter
* section vanished, for a response that was perfectly valid.
*/
export function isMarkdownMime(mime: string | undefined): boolean {
if (mime === undefined) return false;
const base = mime.split(";")[0].trim().toLowerCase();
return base === "text/markdown" || base === "text/x-markdown";
}

View file

@ -0,0 +1,66 @@
import { describe, it, expect } from "vitest";
import { splitSkillFile } from "./splitSkillFile";
describe("splitSkillFile", () => {
it("separates a leading frontmatter fence from the body", () => {
expect(splitSkillFile("---\nname: a\n---\n\n# Title\n\nBody\n")).toEqual({
frontmatter: "name: a",
body: "# Title\n\nBody\n",
});
});
it("reports no frontmatter for a file that has none", () => {
expect(splitSkillFile("# Title\n\nBody\n")).toEqual({
body: "# Title\n\nBody\n",
});
});
it("leaves an unterminated opening fence alone rather than eating the file", () => {
// `---` with no closing fence is not frontmatter. Treating it as such would
// truncate the document to nothing, which is far worse than showing it.
const text = "---\nnot really frontmatter\n\n# Title\n";
expect(splitSkillFile(text)).toEqual({ body: text });
});
it("does not treat a horizontal rule mid-file as frontmatter", () => {
const text = "# Title\n\n---\n\nAfter the rule\n";
expect(splitSkillFile(text)).toEqual({ body: text });
});
it("handles CRLF line endings", () => {
expect(splitSkillFile("---\r\nname: a\r\n---\r\n\r\n# Title\r\n")).toEqual({
frontmatter: "name: a",
body: "# Title\r\n",
});
});
it("returns an empty body when the file is nothing but frontmatter", () => {
expect(splitSkillFile("---\nname: a\n---\n")).toEqual({
frontmatter: "name: a",
body: "",
});
});
it("handles a closing fence with no trailing newline", () => {
// The file ends ON the fence, so there is no newline after it to split at.
expect(splitSkillFile("---\nname: a\n---")).toEqual({
frontmatter: "name: a",
body: "",
});
});
it("keeps a body that follows the fence with no blank line", () => {
// The blank line between fence and body is a convention, not a rule —
// stripping unconditionally would eat the first line of a file without one.
expect(splitSkillFile("---\nname: a\n---\nBody\n")).toEqual({
frontmatter: "name: a",
body: "Body\n",
});
});
it("keeps every frontmatter line, not just the first", () => {
expect(
splitSkillFile("---\nname: a\ndescription: b\n---\n\nBody\n"),
).toEqual({ frontmatter: "name: a\ndescription: b", body: "Body\n" });
});
});

View file

@ -0,0 +1,55 @@
/**
* Split a skill file into its YAML frontmatter and its body (#2263).
*
* A pure transform with no I/O and no subsystem of its own, so it lives in
* `utils/` rather than `lib/` — and in its own module rather than in
* `SkillsScreen.tsx`, because a component file that also exports a function
* defeats React Fast Refresh (`react-refresh/only-export-components`).
*/
export interface SkillFileParts {
/**
* The raw YAML between the fences, fences excluded — `undefined` when the
* file has no frontmatter at all. Raw rather than parsed: this app carries no
* YAML parser, and showing the bytes the server actually served is the more
* useful answer for a conformance tool anyway.
*/
frontmatter?: string;
/** Everything after the closing fence, or the whole file when there is none. */
body: string;
}
/**
* Separate a leading YAML frontmatter fence from the rest of a skill file.
*
* The Skills screen renders the two halves in different places — the
* frontmatter in its own collapsible section, the body in the file viewer — and
* deriving both from **one** split is what stops them disagreeing: the section
* can never show one file's frontmatter while the viewer shows another's, and a
* file with no frontmatter cannot leave a stale section on screen.
*
* It also matters for rendering: the markdown renderer has no frontmatter
* support, so an un-split `---\nname: …\n---` is read as a setext heading and
* painted as a title above the document's real one.
*
* Two deliberate conservatisms, because this must never eat content:
*
* - Only a fence at the very **start** of the file counts. A `---` anywhere
* else is a horizontal rule and is left in the body.
* - A file that opens with `---` but never closes the fence is **not**
* frontmatter; the whole file is returned as the body rather than being
* truncated to nothing.
*/
export function splitSkillFile(text: string): SkillFileParts {
if (!/^---[ \t]*\r?\n/.test(text)) return { body: text };
const rest = text.slice(text.indexOf("\n") + 1);
const close = rest.search(/^---[ \t]*\r?$/m);
if (close === -1) return { body: text };
const frontmatter = rest.slice(0, close).replace(/\r?\n$/, "");
const after = rest.slice(close);
const newline = after.indexOf("\n");
if (newline === -1) return { frontmatter, body: "" };
// Drop the blank line conventionally left between the fence and the body, so
// the document does not open with dead space.
return { frontmatter, body: after.slice(newline + 1).replace(/^\r?\n/, "") };
}

View file

@ -126,9 +126,74 @@ const DATA_ANALYSIS_FM: Frontmatter = {
name: "data-analysis",
description: "Analyze a CSV and summarize its columns",
};
// Deliberately the long one. Every other fixture file here is two or three
// lines, which is not enough to exercise the detail pane's resource viewer:
// with a short file the viewer never scrolls, so a regression that let the
// whole pane scroll instead — the thing #2263 fixed — would look identical to
// the fix. One fixture has to be taller than the viewport for that difference
// to be visible at all.
const DATA_ANALYSIS_MD = skillMd(
DATA_ANALYSIS_FM,
"# Data analysis\n\nLoad the CSV, then follow `reference.md` for the column rules.",
[
"# Data analysis",
"",
"Load the CSV, then follow `reference.md` for the column rules.",
"",
"## Reading the file",
"",
"Read the file as UTF-8 and sniff the delimiter from the header line rather",
"than assuming a comma: exports from spreadsheet tools frequently use a",
"semicolon, and a mis-sniffed delimiter yields a single column whose name is",
"the entire header, which then reads as a valid — if useless — result.",
"",
"If the first line is not a header, every column name becomes a data value",
"and the row count is off by one. Prefer an explicit `has_header` flag over a",
"heuristic when the caller can supply it.",
"",
"## Typing the columns",
"",
"Infer a column's type from a sample rather than from its first value. A",
"column of integers with one empty cell is still numeric; a column of numbers",
"with a single stray `N/A` is not, and coercing it silently turns a data",
"quality problem into a wrong answer.",
"",
"Treat these as missing: the empty string, `NA`, `N/A`, `null`, and `-`.",
"Anything else that fails to parse is a value, not a gap, and belongs in the",
"report as such.",
"",
"## Summarising",
"",
"Numeric columns get min, max, mean and a null count. Report the null count",
"even when it is zero — its absence is indistinguishable from a column that",
"was skipped, and a reader cannot tell which they are looking at.",
"",
"Text columns get a distinct-value count and the five most common values with",
"their frequencies. Cap the distinct count: a free-text column can have as",
"many distinct values as rows, and enumerating them is not a summary.",
"",
"Date columns get an earliest and a latest. Do not attempt a mean of dates.",
"",
"## Reporting",
"",
"Lead with the shape — rows, columns — then the per-column detail. A reader",
"scanning the top of the report should learn whether the file is what they",
"expected before they read anything else.",
"",
"State the delimiter and the encoding you used. When either was guessed, say",
"that it was guessed: a summary computed from a mis-parsed file is worse than",
"no summary, because it looks the same as a correct one.",
"",
"## Failure modes worth naming",
"",
"A ragged file — rows with differing column counts — is a parse failure, not",
"a row to drop quietly. Report the first offending line number.",
"",
"A file whose every column types as text usually means the delimiter was",
"wrong. Say so rather than reporting fifty text columns as a finding.",
"",
"An empty file is not an error, but a summary of it must say the file was",
"empty rather than returning zeroed statistics that read like real ones.",
].join("\n"),
);
const DATA_ANALYSIS_REF =
"# Column rules\n\nNumeric columns get min/max/mean; text columns get a value count.\n";
@ -223,7 +288,9 @@ const FIXTURE_SKILLS: FixtureSkill[] = [
];
/** Every servable `skill://` file, by URI. `dynamic` skills contribute their
* `SKILL.md` too, so the screen's "View SKILL.md" works there as well. */
* `SKILL.md` too, so the Skills screen's resource viewer has something to show
* for them as well — it opens on the selected skill's own file, and a dynamic
* skill advertises no manifest but still serves that one. */
const FILES_BY_URI = new Map<string, FixtureFile>();
for (const skill of FIXTURE_SKILLS) {
if (skill.files === "dynamic") {