From a022be195a1aaca47aafe0583c8a14b8b3f4b4e4 Mon Sep 17 00:00:00 2001 From: FanouZeng-TT <124567600+FanouZeng-TT@users.noreply.github.com> Date: Wed, 16 Sep 2026 23:13:44 +0800 Subject: [PATCH] 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 * 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 --------- Co-authored-by: Claude Code --- .../[agent_name]/chats/[thread_id]/page.tsx | 4 +- .../components/workspace/input-box-helpers.ts | 12 ++ .../src/components/workspace/input-box.tsx | 32 ++- ...put-box-agent-skill-hydration.dom.test.tsx | 187 ++++++++++++++++++ .../workspace/input-box-helpers.test.ts | 26 +++ 5 files changed, 253 insertions(+), 8 deletions(-) create mode 100644 frontend/tests/unit/components/workspace/input-box-agent-skill-hydration.dom.test.tsx diff --git a/frontend/src/app/workspace/agents/[agent_name]/chats/[thread_id]/page.tsx b/frontend/src/app/workspace/agents/[agent_name]/chats/[thread_id]/page.tsx index 72106e034..50817656b 100644 --- a/frontend/src/app/workspace/agents/[agent_name]/chats/[thread_id]/page.tsx +++ b/frontend/src/app/workspace/agents/[agent_name]/chats/[thread_id]/page.tsx @@ -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={ diff --git a/frontend/src/components/workspace/input-box-helpers.ts b/frontend/src/components/workspace/input-box-helpers.ts index 2798c50a0..5373f6235 100644 --- a/frontend/src/components/workspace/input-box-helpers.ts +++ b/frontend/src/components/workspace/input-box-helpers.ts @@ -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, diff --git a/frontend/src/components/workspace/input-box.tsx b/frontend/src/components/workspace/input-box.tsx index 3481f2ce8..dbbd6174b 100644 --- a/frontend/src/components/workspace/input-box.tsx +++ b/frontend/src/components/workspace/input-box.tsx @@ -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, "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 = diff --git a/frontend/tests/unit/components/workspace/input-box-agent-skill-hydration.dom.test.tsx b/frontend/tests/unit/components/workspace/input-box-agent-skill-hydration.dom.test.tsx new file mode 100644 index 000000000..b8f822ed4 --- /dev/null +++ b/frontend/tests/unit/components/workspace/input-box-agent-skill-hydration.dom.test.tsx @@ -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( + + + + + + + + + + + , + ); +} + +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( + + + + + + + + + + + , + ); + + 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(); + }); + }); +}); diff --git a/frontend/tests/unit/components/workspace/input-box-helpers.test.ts b/frontend/tests/unit/components/workspace/input-box-helpers.test.ts index 7c65edf3c..3529cc7d8 100644 --- a/frontend/tests/unit/components/workspace/input-box-helpers.test.ts +++ b/frontend/tests/unit/components/workspace/input-box-helpers.test.ts @@ -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 = [