FN-7901: persist thinkingLevel for insight model selection

Adds a persisted Thinking Level (reasoning-effort) selector to manual insight generation, threading the selection through the dashboard API, insight run metadata, and retries.

- Add inline Thinking Level selector to the InsightsView model-config popover, persisted to localStorage (fusion-insight-thinking)
- Thread thinkingLevel through triggerInsightRun (legacy API client) and useInsights.runInsights
- Validate and store thinkingLevel in insight run inputMetadata.metadata on the POST /insights/run route; resolve it via resolvePlanningThinkingLevel for the actual generation call
- Recover and reapply the original run's thinkingLevel on retry (retryInsightRunLifecycle) so retries reuse the same reasoning-effort setting
- Export resolvePlanningThinkingLevel from @fusion/engine
- Document the new Thinking Level selector in docs/dashboard-guide.md
- Add a minor changeset for @runfusion/fusion

Files changed:
 .changeset/fn-7901-insight-thinking-level.md       |  7 ++
 docs/dashboard-guide.md                            |  1 +
 .../app/__tests__/insight-model-selector.test.tsx  | 41 ++++++++++-
 packages/dashboard/app/api/legacy.ts               |  2 +
 packages/dashboard/app/components/InsightsView.tsx | 24 +++++-
 .../app/hooks/__tests__/useInsights.test.ts        | 36 ++++++++-
 packages/dashboard/app/hooks/useInsights.ts        |  6 +-
 .../src/__tests__/insights-routes.test.ts          | 86 ++++++++++++++++++++++
 packages/dashboard/src/insights-routes.ts          | 36 ++++++++-
 packages/engine/src/index.ts                       |  1 +
 10 files changed, 227 insertions(+), 13 deletions(-)

Fusion-Task-Id: FN-7901

Fusion-Task-Lineage: a6249526-e97d-403e-b853-e497d16f425b

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-12 19:39:42 -07:00
parent b98314923c
commit 7a51f95b38
10 changed files with 227 additions and 13 deletions

View File

@@ -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.

View File

@@ -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

View File

@@ -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) => (
<div data-testid="model-dropdown">
CustomModelDropdown: ({ value, onChange, placeholder, thinkingLevel, onThinkingLevelChange, showThinkingLevel, disabled }: any) => (
<div data-testid="model-dropdown" data-disabled={disabled ? "true" : "false"}>
<span data-testid="model-value">{value || placeholder}</span>
<button data-testid="model-change" onClick={() => onChange("openai/gpt-4o")} />
{showThinkingLevel && (
<div data-testid="thinking-control">
<span data-testid="thinking-value">{thinkingLevel || "default"}</span>
<button data-testid="thinking-change" onClick={() => onThinkingLevelChange("high")} />
<button data-testid="thinking-clear" onClick={() => onThinkingLevelChange("")} />
</div>
)}
</div>
),
}));
@@ -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(<InsightsView addToast={mockAddToast} />);
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(<InsightsView addToast={mockAddToast} />);
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");
});
});

View File

@@ -11162,10 +11162,12 @@ export function triggerInsightRun(
projectId?: string,
modelProvider?: string,
modelId?: string,
thinkingLevel?: string,
): Promise<InsightRun> {
const body: Record<string, unknown> = { trigger, inputMetadata };
if (modelProvider) body.modelProvider = modelProvider;
if (modelId) body.modelId = modelId;
if (thinkingLevel) body.thinkingLevel = thinkingLevel;
return api<InsightRun>(withProjectId("/insights/run", projectId), {
method: "POST",
body: JSON.stringify(body),

View File

@@ -93,6 +93,9 @@ export function InsightsView({ projectId, addToast, onClose, onCreateTask, model
const [selectedModel, setSelectedModel] = useState<string>(
() => localStorage.getItem("fusion-insight-model") ?? ""
);
const [selectedThinking, setSelectedThinking] = useState<string>(
() => localStorage.getItem("fusion-insight-thinking") ?? ""
);
// Fetch models internally if not provided via prop
const [fetchedModels, setFetchedModels] = useState<ModelInfo[]>([]);
@@ -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 && (
<div className="insights-model-config" data-testid="model-config">
{/*
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.
*/}
<label htmlFor="insight-model-select" className="insights-model-label">
{t("insights.model", "Model")}
</label>
@@ -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"
/>
</div>
)}

View File

@@ -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",

View File

@@ -103,7 +103,7 @@ export interface UseInsightsResult {
// Actions
refresh: () => Promise<void>;
runInsights: (modelProvider?: string, modelId?: string) => Promise<void>;
runInsights: (modelProvider?: string, modelId?: string, thinkingLevel?: string) => Promise<void>;
dismiss: (id: string) => Promise<void>;
createTask: (id: string) => Promise<{ title: string; description: string } | null>;
archive: (id: string) => Promise<void>;
@@ -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") {

View File

@@ -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<string, unknown> } };
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<string, unknown> } };
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<string, unknown> } };
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,

View File

@@ -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<string, unknown> | undefined)?.modelProvider === "string"
? (originalMetadata as Record<string, unknown>).modelProvider as string
@@ -620,6 +643,10 @@ export function createInsightsRouter(store: TaskStore): Router {
const retryModelId = typeof (originalMetadata as Record<string, unknown> | undefined)?.modelId === "string"
? (originalMetadata as Record<string, unknown>).modelId as string
: undefined;
const retryThinkingLevel = typeof (originalMetadata as Record<string, unknown> | undefined)?.thinkingLevel === "string"
&& THINKING_LEVELS.includes((originalMetadata as Record<string, unknown>).thinkingLevel as ThinkingLevel)
? (originalMetadata as Record<string, unknown>).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,
});
},
});

View File

@@ -858,6 +858,7 @@ export {
describeAgentModel,
resolveExecutorThinkingLevel,
resolveExecutorFallbackThinkingLevel,
resolvePlanningThinkingLevel,
resolvePlanningFallbackThinkingLevel,
resolveValidatorFallbackThinkingLevel,
resolveTitleSummarizerFallbackThinkingLevel,