fix: address Copilot review round 13 on #2251
- SkillsScreen: put the SESSION in the invalidation key. This screen stays
mounted across a disconnect, so content alone cannot tell server A's entry
from an identical-looking one on server B — A's in-flight verification
could land afterwards and report `verified` for a file never read from B,
and a retained batch entry could leave B's Verify all disabled.
`useInspectorStores` now exposes a `sessionNonce`, bumped on both create
and destroy so it never repeats across a reconnect, and App keys on
`${activeServerId}:${sessionNonce}` — the server id alone would repeat,
which is one of the crossings this exists to prevent.
- SkillsScreen: match the selection by `skillUriIdentity`, in both the
lookup and the NavLink active check. A refresh that canonicalizes
`skill://demo/%53KILL.md` names the same skill, and the detail pane was
emptying out because the server changed its spelling — the last raw-string
URI comparison left after round 12.
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
bd28a36f23
commit
4d7dd180d9
6 changed files with 135 additions and 7 deletions
|
|
@ -101,6 +101,7 @@ const readFixtureFile = vi.fn(async (uri: string) => {
|
|||
});
|
||||
|
||||
const baseProps: SkillsScreenProps = {
|
||||
sessionKey: "session-1",
|
||||
skills: ALL_SKILLS,
|
||||
pageCount: 2,
|
||||
ui: EMPTY_SKILLS_UI,
|
||||
|
|
@ -718,6 +719,67 @@ describe("SkillsScreen", () => {
|
|||
expect(verifyAll()).toBeDisabled();
|
||||
});
|
||||
|
||||
it("discards a verification that lands after the session changed", async () => {
|
||||
// This screen stays mounted across a disconnect, so content alone does not
|
||||
// tell server A's entry from an identical-looking one on server B. Without
|
||||
// the session in the key, A's in-flight read would land and report
|
||||
// `verified` for a file that was never read from B.
|
||||
const user = userEvent.setup();
|
||||
let release: ((value: { text: string }) => void) | undefined;
|
||||
const onReadSkillFile = vi.fn(
|
||||
() =>
|
||||
new Promise<{ text: string }>((resolve) => {
|
||||
release = resolve;
|
||||
}),
|
||||
);
|
||||
const { rerender } = renderWithMantine(
|
||||
<SkillsScreen
|
||||
{...baseProps}
|
||||
sessionKey="server-a:1"
|
||||
onReadSkillFile={onReadSkillFile}
|
||||
skills={[CLEAN_SKILL]}
|
||||
ui={{ ...EMPTY_SKILLS_UI, selectedSkillUri: CLEAN_SKILL.uri }}
|
||||
/>,
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: /Verify all/ }));
|
||||
|
||||
// Same entry, different session.
|
||||
rerender(
|
||||
<SkillsScreen
|
||||
{...baseProps}
|
||||
sessionKey="server-b:2"
|
||||
onReadSkillFile={onReadSkillFile}
|
||||
skills={[CLEAN_SKILL]}
|
||||
ui={{ ...EMPTY_SKILLS_UI, selectedSkillUri: CLEAN_SKILL.uri }}
|
||||
/>,
|
||||
);
|
||||
release?.({ text: SELF_TEXT });
|
||||
expect(screen.queryByText("verified")).not.toBeInTheDocument();
|
||||
// ...and the batch guard did not carry over either.
|
||||
expect(
|
||||
screen.getByRole("button", { name: /Verify all/ }),
|
||||
).not.toBeDisabled();
|
||||
});
|
||||
|
||||
it("keeps the selection when a refresh canonicalizes the skill's URI", async () => {
|
||||
// The selection is stored as the URI the list gave us, so a server that
|
||||
// re-spells it must not empty the detail pane for the same skill.
|
||||
renderWithMantine(
|
||||
<SkillsScreen
|
||||
{...baseProps}
|
||||
skills={[CLEAN_SKILL]}
|
||||
ui={{
|
||||
...EMPTY_SKILLS_UI,
|
||||
selectedSkillUri: "skill://data-analysis/%53KILL.md",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByTestId("skill-detail")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText("Select a skill to view details"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the SKILL.md preview on demand", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithMantine(<ControlledSkillsScreen />);
|
||||
|
|
|
|||
|
|
@ -108,6 +108,14 @@ interface FetchedEntryState {
|
|||
}
|
||||
|
||||
export interface SkillsScreenProps {
|
||||
/**
|
||||
* Identity of the connected session. Part of the invalidation key below, so
|
||||
* a verification still in flight when the user switches servers cannot land
|
||||
* afterwards and report a verdict for an identical-looking entry on the new
|
||||
* one — this screen stays mounted across a disconnect, so content alone does
|
||||
* not distinguish the two.
|
||||
*/
|
||||
sessionKey: string;
|
||||
skills: SkillEntry[];
|
||||
/** Pages the last `skills/list` walk took; shown so pagination is visible. */
|
||||
pageCount: number;
|
||||
|
|
@ -320,6 +328,7 @@ function shortDigest(digest: string | undefined): string {
|
|||
* the Inspector fetches only what the user asks it to.
|
||||
*/
|
||||
export function SkillsScreen({
|
||||
sessionKey,
|
||||
skills,
|
||||
pageCount,
|
||||
loadError,
|
||||
|
|
@ -378,10 +387,15 @@ export function SkillsScreen({
|
|||
);
|
||||
}, [skills, search]);
|
||||
|
||||
const selected = useMemo(
|
||||
() => skills.find((skill) => skill.uri === selectedSkillUri),
|
||||
[skills, selectedSkillUri],
|
||||
);
|
||||
// Matched by IDENTITY, like every other URI comparison here: a refresh that
|
||||
// canonicalizes `skill://demo/%53KILL.md` to `skill://demo/SKILL.md` names
|
||||
// the same skill, and the detail pane must not empty out because the server
|
||||
// changed its spelling.
|
||||
const selected = useMemo(() => {
|
||||
if (selectedSkillUri === undefined) return undefined;
|
||||
const wanted = skillUriIdentity(selectedSkillUri);
|
||||
return skills.find((skill) => skillUriIdentity(skill.uri) === wanted);
|
||||
}, [skills, selectedSkillUri]);
|
||||
|
||||
const issues = useMemo(
|
||||
() => (selected ? checkSkillConformance(selected) : []),
|
||||
|
|
@ -406,8 +420,9 @@ export function SkillsScreen({
|
|||
// not. A primitive string, because `useValueChange` compares with `Object.is`
|
||||
// and a fresh object every render would loop.
|
||||
const manifestKey = useMemo(
|
||||
() => (selected ? JSON.stringify(selected) : (selectedSkillUri ?? "")),
|
||||
[selected, selectedSkillUri],
|
||||
() =>
|
||||
`${sessionKey}\n${selected ? JSON.stringify(selected) : (selectedSkillUri ?? "")}`,
|
||||
[selected, selectedSkillUri, sessionKey],
|
||||
);
|
||||
|
||||
// Adjusted DURING RENDER via `useValueChange` rather than in an effect, so a
|
||||
|
|
@ -623,7 +638,11 @@ export function SkillsScreen({
|
|||
return (
|
||||
<NavLink
|
||||
key={skill.uri}
|
||||
active={skill.uri === selectedSkillUri}
|
||||
active={
|
||||
selectedSkillUri !== undefined &&
|
||||
skillUriIdentity(skill.uri) ===
|
||||
skillUriIdentity(selectedSkillUri)
|
||||
}
|
||||
label={skillDisplayName(skill)}
|
||||
description={skill.uri}
|
||||
onClick={() =>
|
||||
|
|
|
|||
|
|
@ -427,6 +427,7 @@ const appsArgs: AppsPanelProps = {
|
|||
};
|
||||
|
||||
const skillsArgs: SkillsPanelProps = {
|
||||
skillsSessionKey: "story-session",
|
||||
skills: [],
|
||||
skillsPageCount: 0,
|
||||
skillsUi: EMPTY_SKILLS_UI,
|
||||
|
|
|
|||
|
|
@ -172,6 +172,7 @@ function makeProps(...overrides: PropOverrides[]): InspectorViewProps {
|
|||
...mergeBundle("apps", overrides),
|
||||
},
|
||||
skills: {
|
||||
skillsSessionKey: "test-session",
|
||||
skills: [],
|
||||
skillsPageCount: 0,
|
||||
skillsUi: EMPTY_SKILLS_UI,
|
||||
|
|
|
|||
|
|
@ -456,6 +456,7 @@ export function InspectorView({
|
|||
onRefreshApps,
|
||||
} = appsPanel;
|
||||
const {
|
||||
skillsSessionKey,
|
||||
skills,
|
||||
skillsPageCount,
|
||||
skillsLoadError,
|
||||
|
|
@ -1055,6 +1056,7 @@ export function InspectorView({
|
|||
onSortChange: setConsoleSort,
|
||||
};
|
||||
const skillsScreenProps = {
|
||||
sessionKey: skillsSessionKey,
|
||||
skills,
|
||||
pageCount: skillsPageCount,
|
||||
loadError: skillsLoadError,
|
||||
|
|
|
|||
|
|
@ -313,6 +313,12 @@ export interface AppsPanelProps {
|
|||
|
||||
/** The Skills screen (SEP-2640): the enumerated skills and their verification. */
|
||||
export interface SkillsPanelProps {
|
||||
/**
|
||||
* Identity of the connected session. Part of the screen's invalidation key,
|
||||
* so async verification state can never cross a connection — see
|
||||
* `UseInspectorStoresResult.sessionNonce`.
|
||||
*/
|
||||
skillsSessionKey: string;
|
||||
skills: SkillEntry[];
|
||||
/** Pages the last `skills/list` walk took. */
|
||||
skillsPageCount: number;
|
||||
|
|
|
|||
Loading…
Reference in a new issue