From 3e055d8f43b17a221bb9b14f90878f899885a272 Mon Sep 17 00:00:00 2001 From: Chetan Sharma Date: Fri, 19 Jun 2026 15:47:21 +0530 Subject: [PATCH] feat(suggest_agent): stop frontend from fetching when suggestions disabled (#3599) * feat(suggest_agent): stop frontend from fetching when suggestions disabled * wait for suggestions config to load before fetching --------- Co-authored-by: Willem Jiang --- backend/CLAUDE.md | 2 +- backend/app/gateway/routers/suggestions.py | 16 +++++++ backend/tests/test_suggestions_router.py | 14 ++++++ .../src/components/workspace/input-box.tsx | 19 +++++++- frontend/src/core/suggestions/api.ts | 20 +++++++++ frontend/src/core/suggestions/hooks.ts | 11 +++++ frontend/tests/e2e/chat.spec.ts | 43 +++++++++++++++++++ 7 files changed, 123 insertions(+), 2 deletions(-) create mode 100644 frontend/src/core/suggestions/api.ts create mode 100644 frontend/src/core/suggestions/hooks.ts diff --git a/backend/CLAUDE.md b/backend/CLAUDE.md index 60159bc56..bce366da1 100644 --- a/backend/CLAUDE.md +++ b/backend/CLAUDE.md @@ -272,7 +272,7 @@ CORS is same-origin by default when requests enter through nginx on port 2026. S | **Uploads** (`/api/threads/{id}/uploads`) | `POST /` - upload files (auto-converts PDF/PPT/Excel/Word); `GET /list` - list; `DELETE /{filename}` - delete | | **Threads** (`/api/threads/{id}`) | `DELETE /` - remove DeerFlow-managed local thread data after LangGraph thread deletion; unexpected failures are logged server-side and return a generic 500 detail | | **Artifacts** (`/api/threads/{id}/artifacts`) | `GET /{path}` - serve artifacts; active content types (`text/html`, `application/xhtml+xml`, `image/svg+xml`) are always forced as download attachments to reduce XSS risk; `?download=true` still forces download for other file types | -| **Suggestions** (`/api/threads/{id}/suggestions`) | `POST /` - generate follow-up questions; rich list/block model content is normalized and inline reasoning (`...`, including unclosed/truncated blocks from reasoning models like MiniMax-M3) is stripped before JSON parsing | +| **Suggestions** (`/api/suggestions`) | `GET /config` - returns global suggestions config boolean; `POST /threads/{id}/suggestions` - generate follow-up questions; rich list/block model content is normalized and inline reasoning (`...`, including unclosed/truncated blocks from reasoning models like MiniMax-M3) is stripped before JSON parsing | | **Thread Runs** (`/api/threads/{id}/runs`) | `POST /` - create background run; `POST /stream` - create + SSE stream; `POST /wait` - create + block; `GET /` - list runs; `GET /{rid}` - run details; `POST /{rid}/cancel` - cancel; `GET /{rid}/join` - join SSE; `GET /{rid}/messages` - paginated messages `{data, has_more}`; `GET /{rid}/events` - full event stream; `GET /../messages` - thread messages with feedback; `GET /../token-usage` - aggregate tokens | | **Feedback** (`/api/threads/{id}/runs/{rid}/feedback`) | `PUT /` - upsert feedback; `DELETE /` - delete user feedback; `POST /` - create feedback; `GET /` - list feedback; `GET /stats` - aggregate stats; `DELETE /{fid}` - delete specific | | **Runs** (`/api/runs`) | `POST /stream` - stateless run + SSE; `POST /wait` - stateless run + block; `GET /{rid}/messages` - paginated messages by run_id `{data, has_more}` (cursor: `after_seq`/`before_seq`); `GET /{rid}/feedback` - list feedback by run_id | diff --git a/backend/app/gateway/routers/suggestions.py b/backend/app/gateway/routers/suggestions.py index 373a48b38..72b4dca8d 100644 --- a/backend/app/gateway/routers/suggestions.py +++ b/backend/app/gateway/routers/suggestions.py @@ -31,6 +31,10 @@ class SuggestionsResponse(BaseModel): suggestions: list[str] = Field(default_factory=list, description="Suggested follow-up questions") +class SuggestionsConfigResponse(BaseModel): + enabled: bool = Field(..., description="Whether follow-up suggestions are enabled globally") + + # Matches a complete ... block (case-insensitive, spans newlines). _THINK_BLOCK_RE = re.compile(r"]*>.*?", re.IGNORECASE | re.DOTALL) # Matches a dangling, unclosed (model truncated at max_tokens mid-thought). @@ -122,6 +126,18 @@ def _format_conversation(messages: list[SuggestionMessage]) -> str: return "\n".join(parts).strip() +@router.get( + "/suggestions/config", + response_model=SuggestionsConfigResponse, + summary="Get Suggestions Configuration", + description="Returns the global configuration for follow-up suggestions.", +) +async def get_suggestions_config( + config: AppConfig = Depends(get_config), +) -> SuggestionsConfigResponse: + return SuggestionsConfigResponse(enabled=config.suggestions.enabled) + + @router.post( "/threads/{thread_id}/suggestions", response_model=SuggestionsResponse, diff --git a/backend/tests/test_suggestions_router.py b/backend/tests/test_suggestions_router.py index 1a7531fde..6a47d6f8c 100644 --- a/backend/tests/test_suggestions_router.py +++ b/backend/tests/test_suggestions_router.py @@ -192,3 +192,17 @@ def test_generate_suggestions_returns_empty_when_disabled(monkeypatch): assert result.suggestions == [] fake_model.ainvoke.assert_not_called() + + +def test_get_suggestions_config(): + """Ensure the GET /config endpoint correctly returns the boolean state.""" + + # Test when enabled + mock_config_true = SimpleNamespace(suggestions=SimpleNamespace(enabled=True)) + result_true = asyncio.run(suggestions.get_suggestions_config(config=mock_config_true)) + assert result_true.enabled is True + + # Test when disabled + mock_config_false = SimpleNamespace(suggestions=SimpleNamespace(enabled=False)) + result_false = asyncio.run(suggestions.get_suggestions_config(config=mock_config_false)) + assert result_false.enabled is False diff --git a/frontend/src/components/workspace/input-box.tsx b/frontend/src/components/workspace/input-box.tsx index ad28a4a88..a89dd79b2 100644 --- a/frontend/src/components/workspace/input-box.tsx +++ b/frontend/src/components/workspace/input-box.tsx @@ -62,6 +62,7 @@ import { useI18n } from "@/core/i18n/hooks"; import { useModels } from "@/core/models/hooks"; import type { Skill } from "@/core/skills"; import { useSkills } from "@/core/skills/hooks"; +import { useSuggestionsConfig } from "@/core/suggestions/hooks"; import type { AgentThreadContext } from "@/core/threads"; import { textOfMessage } from "@/core/threads/utils"; import { cn } from "@/lib/utils"; @@ -203,6 +204,7 @@ export function InputBox({ const textareaRef = useRef(null); const [followups, setFollowups] = useState([]); + const { data: suggestionsConfig } = useSuggestionsConfig(); const [followupsHidden, setFollowupsHidden] = useState(false); const [followupsLoading, setFollowupsLoading] = useState(false); const [textareaFocused, setTextareaFocused] = useState(false); @@ -524,6 +526,9 @@ export function InputBox({ if (!lastAiId || lastAiId === lastGeneratedForAiIdRef.current) { return; } + if (suggestionsConfig === undefined) { + return; + } lastGeneratedForAiIdRef.current = lastAiId; const recent = messagesRef.current @@ -540,6 +545,11 @@ export function InputBox({ return; } + if (!suggestionsConfig?.enabled) { + setFollowups([]); + return; + } + const controller = new AbortController(); setFollowupsHidden(false); setFollowupsLoading(true); @@ -576,7 +586,14 @@ export function InputBox({ }); return () => controller.abort(); - }, [context.model_name, disabled, isMock, status, threadId]); + }, [ + context.model_name, + disabled, + isMock, + status, + threadId, + suggestionsConfig?.enabled, + ]); return (
{ + const response = await fetch(`${getBackendBaseURL()}/api/suggestions/config`); + if (!response.ok) { + if (response.status === 404) { + // Fallback to true if the backend is older + return { enabled: true }; + } + throw new Error( + `Failed to load suggestions config: ${response.statusText}`, + ); + } + return response.json() as Promise; +} diff --git a/frontend/src/core/suggestions/hooks.ts b/frontend/src/core/suggestions/hooks.ts new file mode 100644 index 000000000..250d1bee8 --- /dev/null +++ b/frontend/src/core/suggestions/hooks.ts @@ -0,0 +1,11 @@ +import { useQuery } from "@tanstack/react-query"; + +import { loadSuggestionsConfig } from "./api"; + +export function useSuggestionsConfig() { + return useQuery({ + queryKey: ["suggestionsConfig"], + queryFn: loadSuggestionsConfig, + staleTime: Infinity, + }); +} diff --git a/frontend/tests/e2e/chat.spec.ts b/frontend/tests/e2e/chat.spec.ts index 50ab3c871..237a80844 100644 --- a/frontend/tests/e2e/chat.spec.ts +++ b/frontend/tests/e2e/chat.spec.ts @@ -310,4 +310,47 @@ test.describe("Chat workspace", () => { }); await expect(promptForm.getByText("report.docx")).toBeHidden(); }); + + test("does not fetch follow-up suggestions when disabled in config", async ({ + page, + }) => { + await page.route("**/api/suggestions/config", (route) => { + return route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ enabled: false }), + }); + }); + + let suggestionsFetched = false; + await page.route("**/api/threads/*/suggestions", (route) => { + suggestionsFetched = true; + return route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ suggestions: [] }), + }); + }); + + let streamCalled = false; + await page.route("**/runs/stream", (route) => { + streamCalled = true; + return handleRunStream(route); + }); + + await page.goto("/workspace/chats/new"); + + const textarea = page.getByPlaceholder(/how can i assist you/i); + await expect(textarea).toBeVisible({ timeout: 15_000 }); + + await textarea.fill("Hello"); + await textarea.press("Enter"); + + await expect.poll(() => streamCalled, { timeout: 10_000 }).toBeTruthy(); + await expect(page.getByText("Hello from DeerFlow!")).toBeVisible({ + timeout: 10_000, + }); + await page.waitForTimeout(1000); + expect(suggestionsFetched).toBe(false); + }); });