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 = [