diff --git a/.changeset/fn-7901-insight-thinking-level.md b/.changeset/fn-7901-insight-thinking-level.md
new file mode 100644
index 0000000000..a781b20c6d
--- /dev/null
+++ b/.changeset/fn-7901-insight-thinking-level.md
@@ -0,0 +1,7 @@
+---
+"@runfusion/fusion": minor
+---
+
+summary: Add a persisted Thinking Level selector for manual insight generation.
+category: feature
+dev: Threads insight run thinkingLevel through dashboard API metadata and retry generation.
diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md
index 3c6f868e24..71c443fc78 100644
--- a/docs/dashboard-guide.md
+++ b/docs/dashboard-guide.md
@@ -1107,6 +1107,7 @@ Navigation:
Features:
- Category-based insight browser with run metadata and status indicators
- Manual insight generation plus refresh actions for latest insight runs
+- The model gear beside **Generate Insights** opens a model picker with an inline **Thinking Level** selector. Both the model override and reasoning-effort choice persist in the browser, and each insight run records the selected reasoning effort so retries reuse the same setting.
- Dismiss/archive/unarchive insight records as they age
- Create triage tasks from selected insights directly from the view
diff --git a/packages/dashboard/app/__tests__/insight-model-selector.test.tsx b/packages/dashboard/app/__tests__/insight-model-selector.test.tsx
index deff02259a..ba6eababde 100644
--- a/packages/dashboard/app/__tests__/insight-model-selector.test.tsx
+++ b/packages/dashboard/app/__tests__/insight-model-selector.test.tsx
@@ -20,6 +20,8 @@ import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import * as jestDomMatchers from "@testing-library/jest-dom/matchers";
import { InsightsView } from "../components/InsightsView";
+const mockRunInsights = vi.hoisted(() => vi.fn());
+
// Register jest-dom matchers (setup files not running in this environment)
expect.extend(jestDomMatchers);
@@ -50,6 +52,7 @@ beforeAll(() => {
// Mock useInsights hook
vi.mock("../hooks/useInsights", () => ({
useInsights: () => ({
+
sections: [],
loading: false,
error: null,
@@ -57,7 +60,7 @@ vi.mock("../hooks/useInsights", () => ({
isRunInFlight: false,
runError: null,
refresh: vi.fn(),
- runInsights: vi.fn(),
+ runInsights: mockRunInsights,
dismiss: vi.fn(),
createTask: vi.fn(),
archive: vi.fn(),
@@ -76,10 +79,17 @@ vi.mock("../hooks/useInsights", () => ({
// Mock CustomModelDropdown since it has complex portal behavior
vi.mock("../components/CustomModelDropdown", () => ({
- CustomModelDropdown: ({ value, onChange, placeholder }: any) => (
-
+ CustomModelDropdown: ({ value, onChange, placeholder, thinkingLevel, onThinkingLevelChange, showThinkingLevel, disabled }: any) => (
+
{value || placeholder}
onChange("openai/gpt-4o")} />
+ {showThinkingLevel && (
+
+ {thinkingLevel || "default"}
+ onThinkingLevelChange("high")} />
+ onThinkingLevelChange("")} />
+
+ )}
),
}));
@@ -89,6 +99,7 @@ const mockAddToast = vi.fn();
describe("Insight model selector", () => {
beforeEach(() => {
localStorage.clear();
+ mockRunInsights.mockReset();
});
afterEach(() => {
@@ -143,4 +154,28 @@ describe("Insight model selector", () => {
screen.getByTestId("toggle-model-config").querySelector(".insights-model-indicator"),
).toBeInTheDocument();
});
+
+ it("persists selected thinking level to localStorage", () => {
+ render(
);
+ fireEvent.click(screen.getByTestId("toggle-model-config"));
+ expect(screen.getByTestId("thinking-control")).toBeInTheDocument();
+
+ fireEvent.click(screen.getByTestId("thinking-change"));
+ expect(localStorage.getItem("fusion-insight-thinking")).toBe("high");
+ expect(screen.getByTestId("thinking-value")).toHaveTextContent("high");
+
+ fireEvent.click(screen.getByTestId("thinking-clear"));
+ expect(localStorage.getItem("fusion-insight-thinking")).toBeNull();
+ });
+
+ it("forwards persisted thinking level when generating insights", () => {
+ render(
);
+ fireEvent.click(screen.getByTestId("toggle-model-config"));
+ fireEvent.click(screen.getByTestId("model-change"));
+ fireEvent.click(screen.getByTestId("thinking-change"));
+
+ fireEvent.click(screen.getByTestId("run-insights"));
+
+ expect(mockRunInsights).toHaveBeenCalledWith("openai", "gpt-4o", "high");
+ });
});
diff --git a/packages/dashboard/app/api/legacy.ts b/packages/dashboard/app/api/legacy.ts
index f5a03f1c22..0c592a3d6b 100644
--- a/packages/dashboard/app/api/legacy.ts
+++ b/packages/dashboard/app/api/legacy.ts
@@ -11162,10 +11162,12 @@ export function triggerInsightRun(
projectId?: string,
modelProvider?: string,
modelId?: string,
+ thinkingLevel?: string,
): Promise
{
const body: Record = { trigger, inputMetadata };
if (modelProvider) body.modelProvider = modelProvider;
if (modelId) body.modelId = modelId;
+ if (thinkingLevel) body.thinkingLevel = thinkingLevel;
return api(withProjectId("/insights/run", projectId), {
method: "POST",
body: JSON.stringify(body),
diff --git a/packages/dashboard/app/components/InsightsView.tsx b/packages/dashboard/app/components/InsightsView.tsx
index be3234b606..2db0ecb0e9 100644
--- a/packages/dashboard/app/components/InsightsView.tsx
+++ b/packages/dashboard/app/components/InsightsView.tsx
@@ -93,6 +93,9 @@ export function InsightsView({ projectId, addToast, onClose, onCreateTask, model
const [selectedModel, setSelectedModel] = useState(
() => localStorage.getItem("fusion-insight-model") ?? ""
);
+ const [selectedThinking, setSelectedThinking] = useState(
+ () => localStorage.getItem("fusion-insight-thinking") ?? ""
+ );
// Fetch models internally if not provided via prop
const [fetchedModels, setFetchedModels] = useState([]);
@@ -166,6 +169,15 @@ export function InsightsView({ projectId, addToast, onClose, onCreateTask, model
}
}, []);
+ const handleThinkingChange = useCallback((value: string) => {
+ setSelectedThinking(value);
+ if (value) {
+ localStorage.setItem("fusion-insight-thinking", value);
+ } else {
+ localStorage.removeItem("fusion-insight-thinking");
+ }
+ }, []);
+
const populatedSections = useMemo(
() => sections.filter((section) => section.items.length > 0),
[sections],
@@ -234,7 +246,7 @@ export function InsightsView({ projectId, addToast, onClose, onCreateTask, model
}
}
- await runInsights(modelProvider, modelId);
+ await runInsights(modelProvider, modelId, selectedThinking || undefined);
setStatusMessage(t("insights.generationStarted", "Insight generation started"));
setStatusType("success");
addToast(t("insights.generationStarted", "Insight generation started"), "success");
@@ -250,7 +262,7 @@ export function InsightsView({ projectId, addToast, onClose, onCreateTask, model
setStatusType("error");
addToast(message, "error");
}
- }, [runInsights, addToast, selectedModel, t]);
+ }, [runInsights, addToast, selectedModel, selectedThinking, t]);
const handleDismiss = useCallback(
async (id: string, title: string) => {
@@ -577,6 +589,10 @@ export function InsightsView({ projectId, addToast, onClose, onCreateTask, model
{showModelConfig && (
+ {/*
+ FNXC:Insights-ThinkingLevel 2026-07-12-19:24:
+ The insight model-config popover must expose the shared inline reasoning-effort selector and persist the operator's choice next to the model override so Generate Insights can send a real per-run selection.
+ */}
{t("insights.model", "Model")}
@@ -592,6 +608,10 @@ export function InsightsView({ projectId, addToast, onClose, onCreateTask, model
favoriteModels={favoriteModels}
onToggleFavorite={handleToggleProviderFavorite}
onToggleModelFavorite={handleToggleModelFavorite}
+ showThinkingLevel
+ thinkingLevel={selectedThinking}
+ onThinkingLevelChange={handleThinkingChange}
+ defaultThinkingLevel="off"
/>
)}
diff --git a/packages/dashboard/app/hooks/__tests__/useInsights.test.ts b/packages/dashboard/app/hooks/__tests__/useInsights.test.ts
index 9e9afddc32..b2a0304f87 100644
--- a/packages/dashboard/app/hooks/__tests__/useInsights.test.ts
+++ b/packages/dashboard/app/hooks/__tests__/useInsights.test.ts
@@ -379,13 +379,47 @@ describe("useInsights", () => {
await result.current.runInsights();
});
- expect(mockTriggerInsightRun).toHaveBeenCalledWith("manual", undefined, "project-1", undefined, undefined);
+ expect(mockTriggerInsightRun).toHaveBeenCalledWith("manual", undefined, "project-1", undefined, undefined, undefined);
expect(result.current.latestRun?.status).toBe("completed");
expect(result.current.isRunInFlight).toBe(false);
expect(mockFetchInsights.mock.calls.length).toBeGreaterThanOrEqual(2);
expect(mockFetchInsightRuns.mock.calls.length).toBeGreaterThanOrEqual(2);
});
+ it("should forward a selected thinking level into manual insight runs", async () => {
+ const runningRun = {
+ id: "RUN-THINKING",
+ projectId: "project-1",
+ trigger: "manual" as const,
+ status: "running" as const,
+ summary: null,
+ error: null,
+ insightsCreated: 0,
+ insightsUpdated: 0,
+ inputMetadata: {},
+ outputMetadata: {},
+ createdAt: "2024-01-01T00:00:00Z",
+ startedAt: "2024-01-01T00:00:10Z",
+ completedAt: null,
+ };
+
+ mockFetchInsights.mockResolvedValue({ insights: [], count: 0 });
+ mockFetchInsightRuns.mockResolvedValue({ runs: [] });
+ mockTriggerInsightRun.mockResolvedValue(runningRun);
+
+ const { result } = renderHook(() => useInsights("project-1"));
+
+ await waitFor(() => {
+ expect(result.current.loading).toBe(false);
+ });
+
+ await act(async () => {
+ await result.current.runInsights("anthropic", "claude-sonnet-4-5", "high");
+ });
+
+ expect(mockTriggerInsightRun).toHaveBeenCalledWith("manual", undefined, "project-1", "anthropic", "claude-sonnet-4-5", "high");
+ });
+
it("should surface failed run errors from triggerInsightRun response", async () => {
const failedRun = {
id: "RUN-1",
diff --git a/packages/dashboard/app/hooks/useInsights.ts b/packages/dashboard/app/hooks/useInsights.ts
index 34b89e2091..11ad3b1edb 100644
--- a/packages/dashboard/app/hooks/useInsights.ts
+++ b/packages/dashboard/app/hooks/useInsights.ts
@@ -103,7 +103,7 @@ export interface UseInsightsResult {
// Actions
refresh: () => Promise;
- runInsights: (modelProvider?: string, modelId?: string) => Promise;
+ runInsights: (modelProvider?: string, modelId?: string, thinkingLevel?: string) => Promise;
dismiss: (id: string) => Promise;
createTask: (id: string) => Promise<{ title: string; description: string } | null>;
archive: (id: string) => Promise;
@@ -216,12 +216,12 @@ export function useInsights(projectId?: string): UseInsightsResult {
}, []);
// Run insights generation
- const runInsights = useCallback(async (modelProvider?: string, modelId?: string) => {
+ const runInsights = useCallback(async (modelProvider?: string, modelId?: string, thinkingLevel?: string) => {
setIsRunInFlight(true);
setRunError(null);
try {
- const run = await triggerInsightRun("manual", undefined, projectId, modelProvider, modelId);
+ const run = await triggerInsightRun("manual", undefined, projectId, modelProvider, modelId, thinkingLevel);
setLatestRun(run);
if (run.status === "completed") {
diff --git a/packages/dashboard/src/__tests__/insights-routes.test.ts b/packages/dashboard/src/__tests__/insights-routes.test.ts
index a0f102e445..e8ca6e88aa 100644
--- a/packages/dashboard/src/__tests__/insights-routes.test.ts
+++ b/packages/dashboard/src/__tests__/insights-routes.test.ts
@@ -15,6 +15,7 @@ const piMocks = vi.hoisted(() => ({
createFnAgent: vi.fn(),
promptWithFallback: vi.fn(),
resolveMcpServersForStore: vi.fn(),
+ resolvePlanningThinkingLevel: vi.fn(),
}));
const resolverMocks = vi.hoisted(() => ({
@@ -56,6 +57,7 @@ vi.mock("@fusion/engine", () => ({
createFnAgent: piMocks.createFnAgent,
promptWithFallback: piMocks.promptWithFallback,
resolveMcpServersForStore: piMocks.resolveMcpServersForStore,
+ resolvePlanningThinkingLevel: piMocks.resolvePlanningThinkingLevel,
}));
/*
@@ -206,6 +208,7 @@ describe("Insights routes", () => {
}));
piMocks.promptWithFallback.mockResolvedValue(undefined);
piMocks.resolveMcpServersForStore.mockResolvedValue({ servers: [] });
+ piMocks.resolvePlanningThinkingLevel.mockImplementation((_settings: unknown, thinkingLevel?: string) => thinkingLevel);
});
afterEach(() => {
@@ -565,6 +568,56 @@ describe("Insights routes", () => {
expect(persisted?.inputMetadata).toEqual({ source: "route-test" });
});
+ it("POST /api/insights/run accepts and stores a valid thinkingLevel", async () => {
+ const res = await request(
+ app,
+ "POST",
+ "/api/insights/run",
+ JSON.stringify({ trigger: "manual", thinkingLevel: "high" }),
+ { "Content-Type": "application/json" },
+ );
+
+ expect(res.status).toBe(201);
+ const run = res.body as { id: string; inputMetadata: { metadata?: Record } };
+ expect(run.inputMetadata.metadata).toMatchObject({ thinkingLevel: "high" });
+ expect(storeA.getInsightStore().getRun(run.id)?.inputMetadata?.metadata).toMatchObject({ thinkingLevel: "high" });
+ expect(piMocks.resolvePlanningThinkingLevel).toHaveBeenCalledWith(expect.any(Object), "high");
+ expect(piMocks.createFnAgent).toHaveBeenCalledWith(
+ expect.objectContaining({
+ defaultThinkingLevel: "high",
+ }),
+ );
+ });
+
+ it("POST /api/insights/run rejects an invalid thinkingLevel", async () => {
+ const res = await request(
+ app,
+ "POST",
+ "/api/insights/run",
+ JSON.stringify({ trigger: "manual", thinkingLevel: "turbo" }),
+ { "Content-Type": "application/json" },
+ );
+
+ expect(res.status).toBe(400);
+ expect((res.body as { error: string }).error).toContain("Invalid thinkingLevel");
+ expect(storeA.getInsightStore().listRuns({})).toHaveLength(0);
+ });
+
+ it("POST /api/insights/run omits thinkingLevel metadata when inheriting defaults", async () => {
+ const res = await request(
+ app,
+ "POST",
+ "/api/insights/run",
+ JSON.stringify({ trigger: "manual" }),
+ { "Content-Type": "application/json" },
+ );
+
+ expect(res.status).toBe(201);
+ const run = res.body as { inputMetadata: { metadata?: Record } };
+ expect(run.inputMetadata.metadata).toBeUndefined();
+ expect(piMocks.resolvePlanningThinkingLevel).toHaveBeenCalledWith(expect.any(Object), undefined);
+ });
+
it("POST /api/insights/run marks run failed when AI execution throws", async () => {
piMocks.promptWithFallback.mockRejectedValue(new Error("AI blew up"));
@@ -696,6 +749,39 @@ describe("Insights routes", () => {
);
});
+ it("POST /api/insights/runs/:id/retry preserves the original run's thinkingLevel", async () => {
+ piMocks.promptWithFallback.mockRejectedValue(new Error("HTTP 503"));
+ const failedRes = await request(
+ app,
+ "POST",
+ "/api/insights/run",
+ JSON.stringify({
+ trigger: "manual",
+ thinkingLevel: "xhigh",
+ }),
+ { "Content-Type": "application/json" },
+ );
+ expect(failedRes.status).toBe(201);
+ const failedRun = failedRes.body as { id: string; inputMetadata: { metadata?: Record } };
+ expect(failedRun.inputMetadata.metadata).toMatchObject({ thinkingLevel: "xhigh" });
+
+ piMocks.promptWithFallback.mockResolvedValue(undefined);
+ piMocks.createFnAgent.mockClear();
+ piMocks.resolvePlanningThinkingLevel.mockClear();
+
+ const retriedRes = await request(app, "POST", `/api/insights/runs/${failedRun.id}/retry`, JSON.stringify({}), {
+ "Content-Type": "application/json",
+ });
+ expect(retriedRes.status).toBe(201);
+
+ expect(piMocks.resolvePlanningThinkingLevel).toHaveBeenCalledWith(expect.any(Object), "xhigh");
+ expect(piMocks.createFnAgent).toHaveBeenCalledWith(
+ expect.objectContaining({
+ defaultThinkingLevel: "xhigh",
+ }),
+ );
+ });
+
it("POST /api/insights/run passes explicit model override to createFnAgent", async () => {
const res = await request(
app,
diff --git a/packages/dashboard/src/insights-routes.ts b/packages/dashboard/src/insights-routes.ts
index 0050d3316e..ea1822aca9 100644
--- a/packages/dashboard/src/insights-routes.ts
+++ b/packages/dashboard/src/insights-routes.ts
@@ -17,6 +17,7 @@ import type { TaskStore } from "@fusion/core";
import {
InsightLifecycleError,
InsightStore,
+ THINKING_LEVELS,
executeInsightRunLifecycle,
resolvePlanningSettingsModel,
retryInsightRunLifecycle,
@@ -28,6 +29,7 @@ import {
type InsightRunListOptions,
type InsightRunStatus,
type Settings,
+ type ThinkingLevel,
} from "@fusion/core";
import {
ApiError,
@@ -41,7 +43,7 @@ import {
startInsightRunSweeper,
sweepStaleInsightRuns,
} from "./insight-run-sweeper.js";
-import { createFnAgent, promptWithFallback, resolveMcpServersForStore } from "@fusion/engine";
+import { createFnAgent, promptWithFallback, resolveMcpServersForStore, resolvePlanningThinkingLevel } from "@fusion/engine";
/**
* Re-throws an error as an ApiError, converting unknown errors to internal errors.
@@ -133,6 +135,7 @@ async function executeInsightAttempt(params: {
settings: Settings;
modelProvider?: string;
modelId?: string;
+ thinkingLevel?: ThinkingLevel;
}): Promise<{ summary: string; insightsCreated: number; insightsUpdated: number }> {
const {
readWorkingMemory,
@@ -157,6 +160,7 @@ async function executeInsightAttempt(params: {
const hasCustomModel = params.modelProvider && params.modelId;
const fallbackProvider = hasCustomModel ? settingsProvider : undefined;
const fallbackModelId = hasCustomModel ? settingsModelId : undefined;
+ const effectiveThinkingLevel = resolvePlanningThinkingLevel(params.settings, params.thinkingLevel);
const existingInsights = await readInsightsMemory(params.rootDir);
const mcpServers = (await resolveMcpServersForStore(params.taskStore ?? {})).servers;
@@ -172,6 +176,11 @@ async function executeInsightAttempt(params: {
defaultModelId: finalModelId,
fallbackProvider,
fallbackModelId,
+ /*
+ * FNXC:Insights-ThinkingLevel 2026-07-12-19:24:
+ * Insight generation now persists a per-run reasoning-effort selection in inputMetadata.metadata.thinkingLevel and resolves it through the planning lane so manual runs and retries honor the same operator choice.
+ */
+ defaultThinkingLevel: effectiveThinkingLevel,
systemPrompt: [
"You extract durable project insights from working memory notes.",
"Return only valid JSON that matches the requested schema.",
@@ -385,20 +394,33 @@ export function createInsightsRouter(store: TaskStore): Router {
const settings = await taskStore.getSettings();
const rawProvider = typeof req.body.modelProvider === "string" ? req.body.modelProvider.trim() : undefined;
const rawModelId = typeof req.body.modelId === "string" ? req.body.modelId.trim() : undefined;
+ const rawThinkingLevel = typeof req.body.thinkingLevel === "string" ? req.body.thinkingLevel.trim() : undefined;
+ const thinkingLevel = rawThinkingLevel
+ ? THINKING_LEVELS.includes(rawThinkingLevel as ThinkingLevel)
+ ? rawThinkingLevel as ThinkingLevel
+ : undefined
+ : undefined;
+ if (rawThinkingLevel && !thinkingLevel) {
+ throw badRequest(`Invalid thinkingLevel: ${rawThinkingLevel}`);
+ }
// Require both provider and model ID together — partial values are discarded
const modelProvider = rawProvider && rawModelId ? rawProvider : undefined;
const modelId = rawProvider && rawModelId ? rawModelId : undefined;
const controller = new AbortController();
- // Stash model selection in inputMetadata.metadata so retries can recover it
+ /*
+ * FNXC:Insights-ThinkingLevel 2026-07-12-19:24:
+ * Insight runs store the operator's model and reasoning-effort selection in inputMetadata.metadata instead of adding schema columns, because retries must recover exactly the per-run values used for generation.
+ */
const inputMetadata = typeof req.body.inputMetadata === "object" && req.body.inputMetadata !== null
? { ...req.body.inputMetadata }
: {};
- if (modelProvider || modelId) {
+ if (modelProvider || modelId || thinkingLevel) {
inputMetadata.metadata = {
...(typeof inputMetadata.metadata === "object" && inputMetadata.metadata !== null ? inputMetadata.metadata : {}),
...(modelProvider ? { modelProvider } : {}),
...(modelId ? { modelId } : {}),
+ ...(thinkingLevel ? { thinkingLevel } : {}),
};
}
@@ -434,6 +456,7 @@ export function createInsightsRouter(store: TaskStore): Router {
settings,
modelProvider,
modelId,
+ thinkingLevel,
});
},
});
@@ -612,7 +635,7 @@ export function createInsightsRouter(store: TaskStore): Router {
const settings = await taskStore.getSettings();
const controller = new AbortController();
- // Recover model selection from the original run's inputMetadata
+ // Recover model and reasoning-effort selection from the original run's inputMetadata.
const originalMetadata = existing.inputMetadata?.metadata;
const retryModelProvider = typeof (originalMetadata as Record | undefined)?.modelProvider === "string"
? (originalMetadata as Record).modelProvider as string
@@ -620,6 +643,10 @@ export function createInsightsRouter(store: TaskStore): Router {
const retryModelId = typeof (originalMetadata as Record | undefined)?.modelId === "string"
? (originalMetadata as Record).modelId as string
: undefined;
+ const retryThinkingLevel = typeof (originalMetadata as Record | undefined)?.thinkingLevel === "string"
+ && THINKING_LEVELS.includes((originalMetadata as Record).thinkingLevel as ThinkingLevel)
+ ? (originalMetadata as Record).thinkingLevel as ThinkingLevel
+ : undefined;
const { run } = await retryInsightRunLifecycle({
store,
@@ -640,6 +667,7 @@ export function createInsightsRouter(store: TaskStore): Router {
settings,
modelProvider: retryModelProvider,
modelId: retryModelId,
+ thinkingLevel: retryThinkingLevel,
});
},
});
diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts
index d0e23407a6..bfcfbd9743 100644
--- a/packages/engine/src/index.ts
+++ b/packages/engine/src/index.ts
@@ -858,6 +858,7 @@ export {
describeAgentModel,
resolveExecutorThinkingLevel,
resolveExecutorFallbackThinkingLevel,
+ resolvePlanningThinkingLevel,
resolvePlanningFallbackThinkingLevel,
resolveValidatorFallbackThinkingLevel,
resolveTitleSummarizerFallbackThinkingLevel,