mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-09-19 02:56:17 +00:00
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:
parent
49f2197ba1
commit
a022be195a
@ -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={
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 =
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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 = [
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user