fix(frontend): scope skill suggestions by agent (#5451)

* fix(frontend): scope skill suggestions by agent

Filter composer slash-skill suggestions through the active custom agent allowlist so restricted agents do not offer unavailable skills.

Co-Authored-By: Claude Code <noreply@anthropic.com>

* fix(frontend): wait for agent scope before draft hydration

Keep agent loading distinct from explicit empty and inherited skill scopes so saved skill chips are restored only after the active scope settles.

Co-Authored-By: Claude Code <noreply@anthropic.com>

---------

Co-authored-by: Claude Code <noreply@anthropic.com>
This commit is contained in:
FanouZeng-TT 2026-09-16 23:13:44 +08:00 committed by GitHub
parent 49f2197ba1
commit a022be195a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 253 additions and 8 deletions

View File

@ -74,7 +74,7 @@ export default function AgentChatPage() {
agent_name: string;
}>();
const { agent } = useAgent(agent_name);
const { agent, isLoading: agentSkillsLoading } = useAgent(agent_name);
const { threadId, setThreadId, isNewThread, setIsNewThread, isMock } =
useThreadChat();
@ -436,6 +436,8 @@ export default function AgentChatPage() {
threadId={threadId}
draftThreadId={isNewThread ? "new" : threadId}
draftAgentName={agent_name}
agentSkillNames={agent?.skills}
agentSkillsLoading={agentSkillsLoading}
defaultModelName={agent?.model}
autoFocus={isWelcomeMode}
status={

View File

@ -161,6 +161,18 @@ export function getLeadingSlashSkillQuery(value: string): string | null {
return query;
}
export function filterSkillsForAgent(
skills: Skill[],
agentSkillNames?: string[] | null,
): Skill[] {
if (!agentSkillNames) {
return skills;
}
const allowedNames = new Set(agentSkillNames);
return skills.filter((skill) => allowedNames.has(skill.name));
}
export function getMatchingSkillSuggestions(
skills: Skill[],
query: string,

View File

@ -141,6 +141,7 @@ import {
createGoalRequestState,
findSuggestionTemplatePlaceholder,
finishGoalRequest,
filterSkillsForAgent,
getGoalObjectiveCounter,
getInputSubmitAction,
getLeadingSlashSkillQuery,
@ -309,6 +310,8 @@ export function InputBox({
onSubmit,
onStop,
canStopStreaming = true,
agentSkillNames,
agentSkillsLoading = false,
...props
}: Omit<ComponentProps<typeof PromptInput>, "onSubmit"> & {
assistantId?: string | null;
@ -331,6 +334,8 @@ export function InputBox({
threadId: string;
draftThreadId?: string;
draftAgentName?: string | null;
agentSkillNames?: string[] | null;
agentSkillsLoading?: boolean;
/**
* The active custom agent's configured default model, if any. Used as the
* auto-selection fallback so an agent chat honors the agent's own default
@ -661,12 +666,19 @@ export function InputBox({
}),
[context.agent_name, draftAgentName, draftThreadId, user?.id],
);
const agentScopedSkills = useMemo(
() =>
agentSkillsLoading ? [] : filterSkillsForAgent(skills, agentSkillNames),
[agentSkillNames, agentSkillsLoading, skills],
);
const enabledSkillNames = useMemo(
() =>
new Set(
skills.filter((skill) => skill.enabled).map((skill) => skill.name),
agentScopedSkills
.filter((skill) => skill.enabled)
.map((skill) => skill.name),
),
[skills],
[agentScopedSkills],
);
const cancelDraftSaveTimer = useCallback(() => {
if (draftSaveTimerRef.current === null) {
@ -772,7 +784,7 @@ export function InputBox({
}, [flushLatestDraft]);
useEffect(() => {
if (skillsLoading || hydratedDraftKey === draftKey) {
if (skillsLoading || agentSkillsLoading || hydratedDraftKey === draftKey) {
return;
}
@ -791,7 +803,7 @@ export function InputBox({
const resolvedDraft = resolveComposerDraft(savedDraft, enabledSkillNames);
setTextInput(resolvedDraft.text);
const restoredSkill = resolvedDraft.skillName
? skills.find(
? agentScopedSkills.find(
(skill) => skill.enabled && skill.name === resolvedDraft.skillName,
)
: undefined;
@ -811,7 +823,8 @@ export function InputBox({
hydratedDraftKey,
initialValue,
setTextInput,
skills,
agentScopedSkills,
agentSkillsLoading,
skillsLoading,
textInput.value,
]);
@ -1429,7 +1442,7 @@ export function InputBox({
return [];
}
const matches = getMatchingSkillSuggestions(
skills,
agentScopedSkills,
slashSkillQuery,
builtinSlashCommands,
);
@ -1442,7 +1455,12 @@ export function InputBox({
return selectedSlashSkill
? matches.filter(({ kind }) => kind === "skill")
: matches;
}, [builtinSlashCommands, selectedSlashSkill, skills, slashSkillQuery]);
}, [
agentScopedSkills,
builtinSlashCommands,
selectedSlashSkill,
slashSkillQuery,
]);
// A selected skill does not close the catalog: `/` reopens it so a skill can
// be found by browsing and swapped without first clearing the chip.
const showSkillSuggestions =

View File

@ -0,0 +1,187 @@
import { afterEach, describe, expect, it, rs } from "@rstest/core";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { cleanup, render, screen, waitFor } from "@testing-library/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";
import { buildComposerDraftKey } from "@/core/threads/composer-draft";
const skillState = rs.hoisted(() => ({
skills: [
{
name: "research",
description: "Research a topic",
category: "general",
license: "MIT",
enabled: true,
editable: false,
},
],
}));
rs.mock("next/navigation", () => ({
useRouter: () => ({ push: rs.fn(), replace: rs.fn(), refresh: rs.fn() }),
usePathname: () => "/workspace",
useSearchParams: () => new URLSearchParams(),
}));
rs.mock("@/core/models/hooks", () => ({
useModels: () => ({
models: [],
tokenUsageEnabled: false,
isLoading: false,
isFetching: false,
error: null,
refetch: rs.fn(),
}),
}));
rs.mock("@/core/skills/hooks", () => ({
useSkills: () => ({
skills: skillState.skills,
isLoading: false,
error: null,
}),
}));
const draftKey = buildComposerDraftKey({
userId: "user-1",
agentName: "researcher",
threadId: "thread-1",
});
function renderComposer({
agentSkillsLoading,
agentSkillNames,
}: {
agentSkillsLoading: boolean;
agentSkillNames?: string[] | null;
}) {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
return render(
<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"
draftAgentName="researcher"
agentSkillNames={agentSkillNames}
agentSkillsLoading={agentSkillsLoading}
context={{ mode: "flash" } as never}
/>
</PromptInputProvider>
</ThreadContext.Provider>
</AuthProvider>
</QueryClientProvider>
</I18nProvider>,
);
}
afterEach(() => {
rs.restoreAllMocks();
window.sessionStorage.clear();
cleanup();
});
describe("InputBox agent skill draft hydration", () => {
it("waits for the agent scope before restoring a saved skill chip", async () => {
window.sessionStorage.setItem(
draftKey,
JSON.stringify({ version: 1, text: "topic", skillName: "research" }),
);
const view = renderComposer({
agentSkillsLoading: true,
agentSkillNames: undefined,
});
expect(
screen.queryByRole("button", { name: "Remove /research" }),
).toBeNull();
expect(window.sessionStorage.getItem(draftKey)).toContain(
'"skillName":"research"',
);
view.rerender(
<I18nProvider initialLocale={DEFAULT_LOCALE}>
<QueryClientProvider
client={
new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
})
}
>
<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"
draftAgentName="researcher"
agentSkillNames={["research"]}
agentSkillsLoading={false}
context={{ mode: "flash" } as never}
/>
</PromptInputProvider>
</ThreadContext.Provider>
</AuthProvider>
</QueryClientProvider>
</I18nProvider>,
);
await waitFor(() => {
expect(
screen.getByRole("button", { name: "Remove /research" }),
).toBeTruthy();
});
expect(screen.getByRole("textbox").textContent).toBe("topic");
});
it("preserves inherit semantics when an agent fetch fails", async () => {
window.sessionStorage.setItem(
draftKey,
JSON.stringify({ version: 1, text: "topic", skillName: "research" }),
);
renderComposer({
agentSkillsLoading: false,
agentSkillNames: undefined,
});
await waitFor(() => {
expect(
screen.getByRole("button", { name: "Remove /research" }),
).toBeTruthy();
});
});
});

View File

@ -6,6 +6,7 @@ import {
canPolishInput,
createGoalRequestState,
findSuggestionTemplatePlaceholder,
filterSkillsForAgent,
finishGoalRequest,
getGoalObjectiveCounter,
getInputSubmitAction,
@ -295,6 +296,31 @@ describe("getLeadingSlashSkillQuery", () => {
});
});
describe("filterSkillsForAgent", () => {
it("keeps all skills when the agent inherits the global catalog", () => {
const skills = [makeSkill("research"), makeSkill("writer")];
expect(filterSkillsForAgent(skills, null)).toEqual(skills);
expect(filterSkillsForAgent(skills, undefined)).toEqual(skills);
});
it("keeps only skills allowed by the active agent", () => {
const skills = [
makeSkill("research"),
makeSkill("writer"),
makeSkill("disabled-writer", false),
];
expect(filterSkillsForAgent(skills, ["writer", "missing"])).toEqual([
makeSkill("writer"),
]);
});
it("treats an empty allowlist as no skills available", () => {
expect(filterSkillsForAgent([makeSkill("research")], [])).toEqual([]);
});
});
describe("getMatchingSkillSuggestions", () => {
it("excludes disabled skills and ranks prefix matches first", () => {
const skills = [