test(workspace): pin composer skill suggestions against an empty catalog (#4063 Phase 4) (#5490)

A role whose skills policy allows nothing (or a fresh install with no
skills) gets an empty catalog from GET /api/skills once per-caller
filtering lands. Pin the composer wiring against that shape: the
dropdown still offers the builtin commands, an unmatched query hides it
quietly, and the matcher returns builtins-only for an empty catalog.
This commit is contained in:
hataa 2026-09-17 20:55:58 +08:00 committed by GitHub
parent 849d4910ad
commit 3322d93935
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 147 additions and 0 deletions

View File

@ -0,0 +1,131 @@
import { afterEach, describe, expect, it, rs } from "@rstest/core";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import type { ReactNode } from "react";
import { PromptInputProvider } from "@/components/ai-elements/prompt-input";
import { InputBox } from "@/components/workspace/input-box";
import { ThreadContext } from "@/components/workspace/messages/context";
import { AuthProvider } from "@/core/auth/AuthProvider";
import { DEFAULT_LOCALE } from "@/core/i18n";
import { I18nProvider } from "@/core/i18n/context";
rs.mock("next/navigation", () => ({
useRouter: () => ({ push: rs.fn(), replace: rs.fn(), refresh: rs.fn() }),
usePathname: () => "/workspace",
useSearchParams: () => new URLSearchParams(),
}));
// The composer's model selector is irrelevant to skill-suggestion alignment;
// keep the react-query + network machinery out of the way entirely.
rs.mock("@/core/models/hooks", () => ({
useModels: () => ({
models: [],
tokenUsageEnabled: false,
isLoading: false,
isFetching: false,
error: null,
refetch: rs.fn(),
}),
}));
// The server-side resource-level filter (GET /api/skills filtered by
// filter_resources(principal, "skill", ...)) is what produces an empty
// catalog for a denied role. Pin the composer wiring against that shape:
// useSkills returning [] must degrade to a builtin-only dropdown, never a
// broken or fully-vanishing catalog.
rs.mock("@/core/skills/hooks", () => ({
useSkills: () => ({
skills: [],
isLoading: false,
error: null,
}),
}));
function renderComposer() {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
const tree: ReactNode = (
<I18nProvider initialLocale={DEFAULT_LOCALE}>
<QueryClientProvider client={queryClient}>
<AuthProvider
initialUser={{
id: "user-1",
email: "user@example.test",
system_role: "user",
needs_setup: false,
oauth_provider: null,
}}
>
<ThreadContext.Provider
value={{ thread: { messages: [] } as never, isMock: true }}
>
<PromptInputProvider>
<InputBox
threadId="thread-1"
status="ready"
context={{ mode: "flash" } as never}
/>
</PromptInputProvider>
</ThreadContext.Provider>
</AuthProvider>
</QueryClientProvider>
</I18nProvider>
);
return render(tree);
}
function getComposerTextarea(container: HTMLElement): HTMLTextAreaElement {
const textarea = container.querySelector("textarea");
if (!(textarea instanceof HTMLTextAreaElement)) {
throw new Error("composer textarea not rendered");
}
return textarea;
}
function typeSlashQuery(
container: HTMLElement,
value: string,
): HTMLTextAreaElement {
const textarea = getComposerTextarea(container);
fireEvent.focus(textarea);
fireEvent.change(textarea, { target: { value } });
return textarea;
}
afterEach(() => {
rs.restoreAllMocks();
cleanup();
});
describe("InputBox skill suggestions with an empty (denied) catalog", () => {
it("offers the builtin commands when '/' is typed with zero visible skills", () => {
const { container } = renderComposer();
typeSlashQuery(container, "/");
// The dropdown survives the empty catalog and offers exactly the builtin
// commands (/goal, /compact) — no phantom skill rows, no crash.
const listbox = screen.getByRole("listbox", {
name: "Skill suggestions",
});
const options = Array.from(listbox.querySelectorAll('[role="option"]'));
const names = options.map((option) => option.textContent ?? "");
expect(names.some((name) => name.includes("/goal"))).toBe(true);
expect(names.some((name) => name.includes("/compact"))).toBe(true);
expect(names.every((name) => !name.includes("/data-analysis"))).toBe(true);
});
it("hides the dropdown when an empty catalog matches nothing more specifically", () => {
const { container } = renderComposer();
// No builtin matches "nosuch" and the catalog is empty, so the dropdown
// must be absent — same silent behavior as an unmatched query today.
typeSlashQuery(container, "/nosuch");
expect(
screen.queryByRole("listbox", { name: "Skill suggestions" }),
).toBeNull();
});
});

View File

@ -322,6 +322,22 @@ describe("filterSkillsForAgent", () => {
});
describe("getMatchingSkillSuggestions", () => {
it("offers only builtin commands when the catalog is empty (denied role)", () => {
// A role whose `skills` policy allows nothing gets an empty catalog from
// GET /api/skills (resource-level listing filter); the composer must
// still offer the builtin commands rather than lose the whole dropdown.
const result = getMatchingSkillSuggestions([], "", builtins);
expect(result.map((s) => `${s.kind}:${s.name}`)).toEqual([
"builtin:goal",
"builtin:new",
]);
});
it("returns an empty list when an empty catalog matches nothing", () => {
expect(getMatchingSkillSuggestions([], "deep", builtins)).toEqual([]);
});
it("excludes disabled skills and ranks prefix matches first", () => {
const skills = [
makeSkill("deep-research"),