Merge pull request #56 from timothyjlaurent/timothyjlaurent/Anthropic-error
fix: settings scope leak, insights model selector & provider filtering
This commit is contained in:
@@ -3131,6 +3131,25 @@ describe("TaskStore", () => {
|
||||
const settings = await store.getSettingsFast();
|
||||
expect(settings.experimentalFeatures).toEqual({ "fast-feature": true });
|
||||
});
|
||||
|
||||
it("project-level experimentalFeatures does not override global value", async () => {
|
||||
// Set global experimentalFeatures
|
||||
await store.updateGlobalSettings({ experimentalFeatures: { insights: true, roadmap: true } });
|
||||
|
||||
// Simulate stale project-level config with empty experimentalFeatures
|
||||
// (can happen from older clients or direct DB writes)
|
||||
store.getDatabase()
|
||||
.prepare("UPDATE config SET settings = ? WHERE id = 1")
|
||||
.run(JSON.stringify({ experimentalFeatures: {} }));
|
||||
|
||||
// getSettingsFast should ignore the project-level global key
|
||||
const fastSettings = await store.getSettingsFast();
|
||||
expect(fastSettings.experimentalFeatures).toEqual({ insights: true, roadmap: true });
|
||||
|
||||
// getSettings should also ignore the project-level global key
|
||||
const settings = await store.getSettings();
|
||||
expect(settings.experimentalFeatures).toEqual({ insights: true, roadmap: true });
|
||||
});
|
||||
});
|
||||
|
||||
// ── Concurrent stress test ───────────────────────────────────────
|
||||
|
||||
@@ -1690,10 +1690,15 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
this.globalSettingsStore.getSettings(),
|
||||
this.readConfig(),
|
||||
]);
|
||||
// Strip global-only keys from project-level settings so stale project-scoped
|
||||
// values don't override the correct global value during the spread merge.
|
||||
const projectSettings = Object.fromEntries(
|
||||
Object.entries(config.settings ?? {}).filter(([key]) => !isGlobalSettingsKey(key)),
|
||||
);
|
||||
return canonicalizeSettings({
|
||||
...DEFAULT_SETTINGS,
|
||||
...globalSettings,
|
||||
...config.settings,
|
||||
...projectSettings,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1715,7 +1720,17 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
this.db.prepare("SELECT settings FROM config WHERE id = 1").get() as { settings?: string } | undefined,
|
||||
]);
|
||||
|
||||
const projectSettings = row?.settings ? fromJson<Settings>(row.settings) : undefined;
|
||||
const raw = row?.settings ? fromJson<Settings>(row.settings) : undefined;
|
||||
|
||||
// Strip global-only keys from the project-level row so stale project-scoped
|
||||
// values (e.g. an empty experimentalFeatures={}) don't override the correct
|
||||
// global value during the spread merge below. getSettingsByScopeFast() has
|
||||
// always done this; getSettingsFast() was missing the filter.
|
||||
const projectSettings: Partial<Settings> | undefined = raw
|
||||
? (Object.fromEntries(
|
||||
Object.entries(raw).filter(([key]) => !isGlobalSettingsKey(key)),
|
||||
) as Partial<Settings>)
|
||||
: undefined;
|
||||
|
||||
return canonicalizeSettings({
|
||||
...DEFAULT_SETTINGS,
|
||||
|
||||
@@ -82,6 +82,7 @@ import {
|
||||
type GlobalConcurrencyState,
|
||||
type ExecutorStats,
|
||||
type ExecutorState,
|
||||
triggerInsightRun,
|
||||
} from "../api";
|
||||
import type { Task, TaskDetail, BatchStatusResponse, MergeResult } from "@fusion/core";
|
||||
import { clearAuthToken } from "../auth";
|
||||
@@ -1073,6 +1074,55 @@ describe("batchUpdateTaskModels", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("triggerInsightRun", () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
beforeEach(() => {
|
||||
clearAuthToken();
|
||||
localStorage.removeItem("fn.authToken");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
clearAuthToken();
|
||||
localStorage.removeItem("fn.authToken");
|
||||
});
|
||||
|
||||
it("sends POST to /api/insights/run without model params by default", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, { id: "INSR-test", status: "completed" }));
|
||||
|
||||
await triggerInsightRun("manual");
|
||||
|
||||
const call = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
const body = JSON.parse(call[1].body);
|
||||
expect(body).not.toHaveProperty("modelProvider");
|
||||
expect(body).not.toHaveProperty("modelId");
|
||||
expect(body.trigger).toBe("manual");
|
||||
});
|
||||
|
||||
it("includes modelProvider and modelId in POST body when provided", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, { id: "INSR-test", status: "completed" }));
|
||||
|
||||
await triggerInsightRun("manual", undefined, undefined, "openai", "gpt-4o");
|
||||
|
||||
const call = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
const body = JSON.parse(call[1].body);
|
||||
expect(body.modelProvider).toBe("openai");
|
||||
expect(body.modelId).toBe("gpt-4o");
|
||||
});
|
||||
|
||||
it("omits model params when provider is empty string", async () => {
|
||||
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, { id: "INSR-test", status: "completed" }));
|
||||
|
||||
await triggerInsightRun("manual", undefined, undefined, "", "");
|
||||
|
||||
const call = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
const body = JSON.parse(call[1].body);
|
||||
expect(body).not.toHaveProperty("modelProvider");
|
||||
expect(body).not.toHaveProperty("modelId");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe("task review data api wrappers", () => {
|
||||
it("fetchTaskReviewData calls task review endpoint", async () => {
|
||||
|
||||
146
packages/dashboard/app/__tests__/insight-model-selector.test.tsx
Normal file
146
packages/dashboard/app/__tests__/insight-model-selector.test.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Insight Model Selector — TDD Red Phase
|
||||
*
|
||||
* These tests define the contract for the collapsible model-selector gear button
|
||||
* on InsightsView. They are expected to FAIL until the UI is implemented (Task 4).
|
||||
*
|
||||
* Contract:
|
||||
* 1. Gear icon button (data-testid="toggle-model-config") toggles a config row.
|
||||
* 2. Config row (data-testid="model-config") is hidden by default.
|
||||
* 3. CustomModelDropdown (data-testid="model-dropdown") appears inside the row.
|
||||
* 4. Selected model persists to localStorage key "fusion-insight-model".
|
||||
* 5. On mount, the stored model is restored into the dropdown.
|
||||
* 6. A yellow indicator dot (class "insights-model-indicator") appears on the gear
|
||||
* when a non-default model is selected.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, beforeAll, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||
import * as jestDomMatchers from "@testing-library/jest-dom/matchers";
|
||||
import { InsightsView } from "../components/InsightsView";
|
||||
|
||||
// Register jest-dom matchers (setup files not running in this environment)
|
||||
expect.extend(jestDomMatchers);
|
||||
|
||||
// Ensure localStorage is available (jsdom in this environment may not provide it)
|
||||
const localStorageStore: Record<string, string> = {};
|
||||
beforeAll(() => {
|
||||
if (typeof localStorage === "undefined" || typeof localStorage.clear !== "function") {
|
||||
const mock = {
|
||||
getItem: (key: string) => localStorageStore[key] ?? null,
|
||||
setItem: (key: string, value: string) => {
|
||||
localStorageStore[key] = value;
|
||||
},
|
||||
removeItem: (key: string) => {
|
||||
delete localStorageStore[key];
|
||||
},
|
||||
clear: () => {
|
||||
Object.keys(localStorageStore).forEach((k) => delete localStorageStore[k]);
|
||||
},
|
||||
get length() {
|
||||
return Object.keys(localStorageStore).length;
|
||||
},
|
||||
key: (index: number) => Object.keys(localStorageStore)[index] ?? null,
|
||||
};
|
||||
Object.defineProperty(globalThis, "localStorage", { value: mock, writable: true });
|
||||
}
|
||||
});
|
||||
|
||||
// Mock useInsights hook
|
||||
vi.mock("../hooks/useInsights", () => ({
|
||||
useInsights: () => ({
|
||||
sections: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
latestRun: null,
|
||||
isRunInFlight: false,
|
||||
runError: null,
|
||||
refresh: vi.fn(),
|
||||
runInsights: vi.fn(),
|
||||
dismiss: vi.fn(),
|
||||
createTask: vi.fn(),
|
||||
archive: vi.fn(),
|
||||
unarchive: vi.fn(),
|
||||
toggleShowArchived: vi.fn(),
|
||||
dismissStates: new Map(),
|
||||
createTaskStates: new Map(),
|
||||
archiveStates: new Map(),
|
||||
unarchiveStates: new Map(),
|
||||
totalCount: 0,
|
||||
dismissedCount: 0,
|
||||
archivedCount: 0,
|
||||
showArchived: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock CustomModelDropdown since it has complex portal behavior
|
||||
vi.mock("../components/CustomModelDropdown", () => ({
|
||||
CustomModelDropdown: ({ value, onChange, placeholder }: any) => (
|
||||
<div data-testid="model-dropdown">
|
||||
<span data-testid="model-value">{value || placeholder}</span>
|
||||
<button data-testid="model-change" onClick={() => onChange("openai/gpt-4o")} />
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const mockAddToast = vi.fn();
|
||||
|
||||
describe("Insight model selector", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("renders a gear button to toggle model config", () => {
|
||||
render(<InsightsView addToast={mockAddToast} />);
|
||||
expect(screen.getByTestId("toggle-model-config")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not show model config row by default", () => {
|
||||
render(<InsightsView addToast={mockAddToast} />);
|
||||
expect(screen.queryByTestId("model-config")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows model config row when gear is clicked", () => {
|
||||
render(<InsightsView addToast={mockAddToast} />);
|
||||
fireEvent.click(screen.getByTestId("toggle-model-config"));
|
||||
expect(screen.getByTestId("model-config")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("model-dropdown")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("passes models prop to CustomModelDropdown", () => {
|
||||
const models = [
|
||||
{ id: "gpt-4o", provider: "openai", name: "GPT-4o" },
|
||||
];
|
||||
render(<InsightsView addToast={mockAddToast} models={models as any} />);
|
||||
fireEvent.click(screen.getByTestId("toggle-model-config"));
|
||||
expect(screen.getByTestId("model-dropdown")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("persists selected model to localStorage", () => {
|
||||
render(<InsightsView addToast={mockAddToast} />);
|
||||
fireEvent.click(screen.getByTestId("toggle-model-config"));
|
||||
fireEvent.click(screen.getByTestId("model-change"));
|
||||
expect(localStorage.getItem("fusion-insight-model")).toBe("openai/gpt-4o");
|
||||
});
|
||||
|
||||
it("restores model from localStorage on mount", () => {
|
||||
localStorage.setItem("fusion-insight-model", "anthropic/claude-sonnet-4-5");
|
||||
render(<InsightsView addToast={mockAddToast} />);
|
||||
fireEvent.click(screen.getByTestId("toggle-model-config"));
|
||||
expect(screen.getByTestId("model-value")).toHaveTextContent("anthropic/claude-sonnet-4-5");
|
||||
});
|
||||
|
||||
it("shows indicator dot on gear when a model is selected", () => {
|
||||
render(<InsightsView addToast={mockAddToast} />);
|
||||
fireEvent.click(screen.getByTestId("toggle-model-config"));
|
||||
fireEvent.click(screen.getByTestId("model-change"));
|
||||
expect(
|
||||
screen.getByTestId("toggle-model-config").querySelector(".insights-model-indicator"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -8865,10 +8865,15 @@ export function triggerInsightRun(
|
||||
trigger: InsightRunTrigger = "manual",
|
||||
inputMetadata?: InsightRun["inputMetadata"],
|
||||
projectId?: string,
|
||||
modelProvider?: string,
|
||||
modelId?: string,
|
||||
): Promise<InsightRun> {
|
||||
const body: Record<string, unknown> = { trigger, inputMetadata };
|
||||
if (modelProvider) body.modelProvider = modelProvider;
|
||||
if (modelId) body.modelId = modelId;
|
||||
return api<InsightRun>(withProjectId("/insights/run", projectId), {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ trigger, inputMetadata }),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -702,6 +702,8 @@
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-lg);
|
||||
min-width: 140px;
|
||||
max-height: min(70vh, calc(var(--space-2xl) * 17));
|
||||
overflow-y: auto;
|
||||
z-index: 200;
|
||||
padding: var(--space-xs) 0;
|
||||
}
|
||||
|
||||
@@ -44,6 +44,42 @@
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
/* Model configuration row — collapsible, below the action bar */
|
||||
.insights-model-config {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-sm) var(--space-lg);
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--bg-subtle, var(--bg));
|
||||
}
|
||||
|
||||
.insights-model-label {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.insights-model-config .model-combobox {
|
||||
max-width: 320px;
|
||||
}
|
||||
|
||||
/* Gear toggle with active indicator */
|
||||
.insights-model-toggle {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.insights-model-indicator {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: 2px;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--todo, #f59e0b);
|
||||
}
|
||||
|
||||
.insights-view-close {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
@@ -578,4 +614,12 @@
|
||||
.insights-view-count {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.insights-model-config {
|
||||
padding: var(--space-xs) var(--space-md);
|
||||
}
|
||||
|
||||
.insights-model-config .model-combobox {
|
||||
max-width: 200px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,10 @@ import {
|
||||
Archive,
|
||||
ArchiveRestore,
|
||||
Clock,
|
||||
Settings,
|
||||
} from "lucide-react";
|
||||
import { CustomModelDropdown } from "./CustomModelDropdown";
|
||||
import { fetchModels, updateGlobalSettings, type ModelInfo } from "../api";
|
||||
import { useInsights, type InsightSection } from "../hooks/useInsights";
|
||||
import type { InsightCategory } from "@fusion/core";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
@@ -32,6 +35,7 @@ interface InsightsViewProps {
|
||||
addToast: (message: string, type?: ToastType) => void;
|
||||
onClose?: () => void;
|
||||
onCreateTask?: (payload: { insightId: string; title: string; description: string }) => Promise<void>;
|
||||
models?: ModelInfo[];
|
||||
}
|
||||
|
||||
const CATEGORY_ICONS: Record<InsightCategory, React.ComponentType<{ size?: number; className?: string }>> = {
|
||||
@@ -52,7 +56,7 @@ const CATEGORY_ICONS: Record<InsightCategory, React.ComponentType<{ size?: numbe
|
||||
other: Sparkles,
|
||||
};
|
||||
|
||||
export function InsightsView({ projectId, addToast, onClose, onCreateTask }: InsightsViewProps) {
|
||||
export function InsightsView({ projectId, addToast, onClose, onCreateTask, models: modelsProp }: InsightsViewProps) {
|
||||
const {
|
||||
sections,
|
||||
loading,
|
||||
@@ -79,6 +83,83 @@ export function InsightsView({ projectId, addToast, onClose, onCreateTask }: Ins
|
||||
const [statusMessage, setStatusMessage] = useState<string | null>(null);
|
||||
const [statusType, setStatusType] = useState<"success" | "error" | "info">("info");
|
||||
|
||||
const [showModelConfig, setShowModelConfig] = useState(false);
|
||||
const [selectedModel, setSelectedModel] = useState<string>(
|
||||
() => localStorage.getItem("fusion-insight-model") ?? ""
|
||||
);
|
||||
|
||||
// Fetch models internally if not provided via prop
|
||||
const [fetchedModels, setFetchedModels] = useState<ModelInfo[]>([]);
|
||||
const [favoriteProviders, setFavoriteProviders] = useState<string[]>([]);
|
||||
const [favoriteModels, setFavoriteModels] = useState<string[]>([]);
|
||||
const [resolvedPlanningProvider, setResolvedPlanningProvider] = useState<string | undefined>();
|
||||
useEffect(() => {
|
||||
if (modelsProp) return;
|
||||
fetchModels()
|
||||
.then((res) => {
|
||||
setFetchedModels(res.models);
|
||||
setFavoriteProviders(res.favoriteProviders);
|
||||
setFavoriteModels(res.favoriteModels);
|
||||
setResolvedPlanningProvider(res.resolvedPlanningProvider);
|
||||
|
||||
// Clear persisted model override if the model is no longer available
|
||||
const savedModel = localStorage.getItem("fusion-insight-model");
|
||||
if (savedModel) {
|
||||
const available = res.models.some((m) => `${m.provider}/${m.id}` === savedModel);
|
||||
if (!available) {
|
||||
localStorage.removeItem("fusion-insight-model");
|
||||
setSelectedModel("");
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}, [modelsProp]);
|
||||
const models = modelsProp ?? fetchedModels;
|
||||
|
||||
// Auto-promote the resolved planning provider as a favorite when the user
|
||||
// hasn't explicitly starred any providers. This ensures the provider they
|
||||
// actively use always appears at the top of the dropdown.
|
||||
const effectiveFavoriteProviders = useMemo(() => {
|
||||
if (favoriteProviders.length > 0) return favoriteProviders;
|
||||
if (resolvedPlanningProvider) return [resolvedPlanningProvider];
|
||||
return [];
|
||||
}, [favoriteProviders, resolvedPlanningProvider]);
|
||||
|
||||
const handleToggleProviderFavorite = useCallback(async (provider: string) => {
|
||||
const isFavorite = favoriteProviders.includes(provider);
|
||||
const next = isFavorite
|
||||
? favoriteProviders.filter((p) => p !== provider)
|
||||
: [provider, ...favoriteProviders];
|
||||
setFavoriteProviders(next);
|
||||
try {
|
||||
await updateGlobalSettings({ favoriteProviders: next, favoriteModels });
|
||||
} catch {
|
||||
setFavoriteProviders(favoriteProviders);
|
||||
}
|
||||
}, [favoriteProviders, favoriteModels]);
|
||||
|
||||
const handleToggleModelFavorite = useCallback(async (modelId: string) => {
|
||||
const isFavorite = favoriteModels.includes(modelId);
|
||||
const next = isFavorite
|
||||
? favoriteModels.filter((m) => m !== modelId)
|
||||
: [modelId, ...favoriteModels];
|
||||
setFavoriteModels(next);
|
||||
try {
|
||||
await updateGlobalSettings({ favoriteProviders, favoriteModels: next });
|
||||
} catch {
|
||||
setFavoriteModels(favoriteModels);
|
||||
}
|
||||
}, [favoriteModels, favoriteProviders]);
|
||||
|
||||
const handleModelChange = useCallback((value: string) => {
|
||||
setSelectedModel(value);
|
||||
if (value) {
|
||||
localStorage.setItem("fusion-insight-model", value);
|
||||
} else {
|
||||
localStorage.removeItem("fusion-insight-model");
|
||||
}
|
||||
}, []);
|
||||
|
||||
const populatedSections = useMemo(
|
||||
() => sections.filter((section) => section.items.length > 0),
|
||||
[sections],
|
||||
@@ -114,7 +195,22 @@ export function InsightsView({ projectId, addToast, onClose, onCreateTask }: Ins
|
||||
try {
|
||||
setStatusMessage("Generating insights...");
|
||||
setStatusType("info");
|
||||
await runInsights();
|
||||
|
||||
let modelProvider: string | undefined;
|
||||
let modelId: string | undefined;
|
||||
if (selectedModel) {
|
||||
const slashIdx = selectedModel.indexOf("/");
|
||||
if (slashIdx !== -1) {
|
||||
const provider = selectedModel.slice(0, slashIdx);
|
||||
const id = selectedModel.slice(slashIdx + 1);
|
||||
if (provider && id) {
|
||||
modelProvider = provider;
|
||||
modelId = id;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await runInsights(modelProvider, modelId);
|
||||
setStatusMessage("Insight generation started");
|
||||
setStatusType("success");
|
||||
addToast("Insight generation started", "success");
|
||||
@@ -124,7 +220,7 @@ export function InsightsView({ projectId, addToast, onClose, onCreateTask }: Ins
|
||||
setStatusType("error");
|
||||
addToast(message, "error");
|
||||
}
|
||||
}, [runInsights, addToast]);
|
||||
}, [runInsights, addToast, selectedModel]);
|
||||
|
||||
const handleDismiss = useCallback(
|
||||
async (id: string, title: string) => {
|
||||
@@ -396,6 +492,17 @@ export function InsightsView({ projectId, addToast, onClose, onCreateTask }: Ins
|
||||
<RefreshCw size={14} className={loading ? "spin" : ""} />
|
||||
Refresh
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-sm insights-model-toggle"
|
||||
onClick={() => setShowModelConfig((prev) => !prev)}
|
||||
aria-label="Configure insight generation model"
|
||||
aria-expanded={showModelConfig}
|
||||
data-testid="toggle-model-config"
|
||||
title={selectedModel ? `Model: ${selectedModel}` : "Configure model"}
|
||||
>
|
||||
<Settings size={14} />
|
||||
{selectedModel && <span className="insights-model-indicator" />}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={() => void handleRun()}
|
||||
@@ -418,6 +525,27 @@ export function InsightsView({ projectId, addToast, onClose, onCreateTask }: Ins
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showModelConfig && (
|
||||
<div className="insights-model-config" data-testid="model-config">
|
||||
<label htmlFor="insight-model-select" className="insights-model-label">
|
||||
Model
|
||||
</label>
|
||||
<CustomModelDropdown
|
||||
models={models}
|
||||
value={selectedModel}
|
||||
onChange={handleModelChange}
|
||||
placeholder="Use planning default"
|
||||
label="Insight generation model"
|
||||
disabled={isRunInFlight}
|
||||
id="insight-model-select"
|
||||
favoriteProviders={effectiveFavoriteProviders}
|
||||
favoriteModels={favoriteModels}
|
||||
onToggleFavorite={handleToggleProviderFavorite}
|
||||
onToggleModelFavorite={handleToggleModelFavorite}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className="insights-status-region"
|
||||
aria-live="polite"
|
||||
|
||||
@@ -99,7 +99,7 @@ export interface UseInsightsResult {
|
||||
|
||||
// Actions
|
||||
refresh: () => Promise<void>;
|
||||
runInsights: () => Promise<void>;
|
||||
runInsights: (modelProvider?: string, modelId?: string) => Promise<void>;
|
||||
dismiss: (id: string) => Promise<void>;
|
||||
createTask: (id: string) => Promise<{ title: string; description: string } | null>;
|
||||
archive: (id: string) => Promise<void>;
|
||||
@@ -189,12 +189,12 @@ export function useInsights(projectId?: string): UseInsightsResult {
|
||||
}, []);
|
||||
|
||||
// Run insights generation
|
||||
const runInsights = useCallback(async () => {
|
||||
const runInsights = useCallback(async (modelProvider?: string, modelId?: string) => {
|
||||
setIsRunInFlight(true);
|
||||
setRunError(null);
|
||||
|
||||
try {
|
||||
const run = await triggerInsightRun("manual", undefined, projectId);
|
||||
const run = await triggerInsightRun("manual", undefined, projectId, modelProvider, modelId);
|
||||
setLatestRun(run);
|
||||
|
||||
if (run.status === "completed") {
|
||||
|
||||
@@ -385,6 +385,94 @@ describe("Insights routes", () => {
|
||||
expect(retried.lifecycle.retryOfRunId).toBe(retryableRun.id);
|
||||
});
|
||||
|
||||
it("POST /api/insights/runs/:id/retry preserves the original run's custom model", async () => {
|
||||
// Create a run with a custom model that fails with a retryable error
|
||||
piMocks.promptWithFallback.mockRejectedValue(new Error("HTTP 503"));
|
||||
const failedRes = await request(
|
||||
app,
|
||||
"POST",
|
||||
"/api/insights/run",
|
||||
JSON.stringify({
|
||||
trigger: "manual",
|
||||
modelProvider: "openai",
|
||||
modelId: "gpt-4o",
|
||||
}),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
expect(failedRes.status).toBe(201);
|
||||
const failedRun = failedRes.body as { id: string; inputMetadata: Record<string, unknown> };
|
||||
|
||||
// The failed run should have persisted the model info in inputMetadata
|
||||
expect(failedRun.inputMetadata?.metadata).toMatchObject({
|
||||
modelProvider: "openai",
|
||||
modelId: "gpt-4o",
|
||||
});
|
||||
|
||||
// Reset mock so retry succeeds
|
||||
piMocks.promptWithFallback.mockResolvedValue(undefined);
|
||||
piMocks.createFnAgent.mockClear();
|
||||
|
||||
const retriedRes = await request(app, "POST", `/api/insights/runs/${failedRun.id}/retry`, JSON.stringify({}), {
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
expect(retriedRes.status).toBe(201);
|
||||
|
||||
// The retry should pass the original model to createFnAgent
|
||||
expect(piMocks.createFnAgent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
defaultProvider: "openai",
|
||||
defaultModelId: "gpt-4o",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("POST /api/insights/run passes explicit model override to createFnAgent", async () => {
|
||||
const res = await request(
|
||||
app,
|
||||
"POST",
|
||||
"/api/insights/run",
|
||||
JSON.stringify({
|
||||
trigger: "manual",
|
||||
modelProvider: "openai",
|
||||
modelId: "gpt-4o",
|
||||
}),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
expect(res.status).toBe(201);
|
||||
|
||||
// createFnAgent should have been called with the explicit override
|
||||
expect(piMocks.createFnAgent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
defaultProvider: "openai",
|
||||
defaultModelId: "gpt-4o",
|
||||
fallbackProvider: undefined,
|
||||
fallbackModelId: undefined,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("POST /api/insights/run without model override uses settings resolution", async () => {
|
||||
const res = await request(
|
||||
app,
|
||||
"POST",
|
||||
"/api/insights/run",
|
||||
JSON.stringify({
|
||||
trigger: "manual",
|
||||
}),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
expect(res.status).toBe(201);
|
||||
|
||||
// Without override, provider/model come from settings resolution
|
||||
// (which returns undefined when no planning settings are configured)
|
||||
expect(piMocks.createFnAgent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
fallbackProvider: undefined,
|
||||
fallbackModelId: undefined,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("POST /api/insights/:id/create-task returns task-conversion payload", async () => {
|
||||
const insight = storeA.getInsightStore().createInsight("", {
|
||||
title: "Refactor parser",
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
InsightLifecycleError,
|
||||
InsightStore,
|
||||
executeInsightRunLifecycle,
|
||||
resolvePlanningSettingsModel,
|
||||
retryInsightRunLifecycle,
|
||||
type InsightCategory,
|
||||
type MemoryInsightCategory,
|
||||
@@ -26,6 +27,7 @@ import {
|
||||
type InsightRunTrigger,
|
||||
type InsightRunListOptions,
|
||||
type InsightRunStatus,
|
||||
type Settings,
|
||||
} from "@fusion/core";
|
||||
import {
|
||||
ApiError,
|
||||
@@ -104,6 +106,9 @@ async function executeInsightAttempt(params: {
|
||||
runId: string;
|
||||
signal: AbortSignal;
|
||||
insightStore: InsightStore;
|
||||
settings: Settings;
|
||||
modelProvider?: string;
|
||||
modelId?: string;
|
||||
}): Promise<{ summary: string; insightsCreated: number; insightsUpdated: number }> {
|
||||
const {
|
||||
readWorkingMemory,
|
||||
@@ -120,10 +125,23 @@ async function executeInsightAttempt(params: {
|
||||
throw new Error("No working memory to analyze");
|
||||
}
|
||||
|
||||
const { provider: settingsProvider, modelId: settingsModelId } =
|
||||
resolvePlanningSettingsModel(params.settings);
|
||||
|
||||
const finalProvider = params.modelProvider ?? settingsProvider;
|
||||
const finalModelId = params.modelId ?? settingsModelId;
|
||||
const hasCustomModel = params.modelProvider && params.modelId;
|
||||
const fallbackProvider = hasCustomModel ? settingsProvider : undefined;
|
||||
const fallbackModelId = hasCustomModel ? settingsModelId : undefined;
|
||||
|
||||
const existingInsights = await readInsightsMemory(params.rootDir);
|
||||
let responseText = "";
|
||||
const { session } = await createFnAgent({
|
||||
cwd: params.rootDir,
|
||||
defaultProvider: finalProvider,
|
||||
defaultModelId: finalModelId,
|
||||
fallbackProvider,
|
||||
fallbackModelId,
|
||||
systemPrompt: [
|
||||
"You extract durable project insights from working memory notes.",
|
||||
"Return only valid JSON that matches the requested schema.",
|
||||
@@ -309,14 +327,32 @@ export function createInsightsRouter(store: TaskStore): Router {
|
||||
const taskStore = requestContext.getStore();
|
||||
if (!taskStore) throw new ApiError(500, "Store context not available");
|
||||
const rootDir = taskStore.getRootDir();
|
||||
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;
|
||||
// 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
|
||||
const inputMetadata = typeof req.body.inputMetadata === "object" && req.body.inputMetadata !== null
|
||||
? { ...req.body.inputMetadata }
|
||||
: {};
|
||||
if (modelProvider || modelId) {
|
||||
inputMetadata.metadata = {
|
||||
...(typeof inputMetadata.metadata === "object" && inputMetadata.metadata !== null ? inputMetadata.metadata : {}),
|
||||
...(modelProvider ? { modelProvider } : {}),
|
||||
...(modelId ? { modelId } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
const run = await executeInsightRunLifecycle({
|
||||
store: insightStore,
|
||||
projectId,
|
||||
input: {
|
||||
trigger,
|
||||
inputMetadata: req.body.inputMetadata,
|
||||
inputMetadata,
|
||||
},
|
||||
signal: controller.signal,
|
||||
timeoutMs: typeof req.body.timeoutMs === "number" ? req.body.timeoutMs : 120_000,
|
||||
@@ -330,6 +366,9 @@ export function createInsightsRouter(store: TaskStore): Router {
|
||||
runId: run.id,
|
||||
signal,
|
||||
insightStore,
|
||||
settings,
|
||||
modelProvider,
|
||||
modelId,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -474,8 +513,18 @@ export function createInsightsRouter(store: TaskStore): Router {
|
||||
const taskStore = requestContext.getStore();
|
||||
if (!taskStore) throw new ApiError(500, "Store context not available");
|
||||
const rootDir = taskStore.getRootDir();
|
||||
const settings = await taskStore.getSettings();
|
||||
const controller = new AbortController();
|
||||
|
||||
// Recover model 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
|
||||
: undefined;
|
||||
const retryModelId = typeof (originalMetadata as Record<string, unknown> | undefined)?.modelId === "string"
|
||||
? (originalMetadata as Record<string, unknown>).modelId as string
|
||||
: undefined;
|
||||
|
||||
const { run } = await retryInsightRunLifecycle({
|
||||
store,
|
||||
runId: id,
|
||||
@@ -491,6 +540,9 @@ export function createInsightsRouter(store: TaskStore): Router {
|
||||
runId: run.id,
|
||||
signal,
|
||||
insightStore: store,
|
||||
settings,
|
||||
modelProvider: retryModelProvider,
|
||||
modelId: retryModelId,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,7 +1,67 @@
|
||||
import { access, readFile } from "node:fs/promises";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { resolvePlanningSettingsModel } from "@fusion/core";
|
||||
import { ApiError } from "../api-error.js";
|
||||
import type { ApiRouteRegistrar } from "./types.js";
|
||||
|
||||
/**
|
||||
* Read provider names from Fusion's own auth stores (primary + legacy .pi).
|
||||
* These represent providers the user has explicitly configured in Fusion,
|
||||
* as opposed to supplemental credentials inherited from Codex CLI,
|
||||
* Claude Code, or environment variables.
|
||||
*/
|
||||
async function getConfiguredProviderNames(): Promise<Set<string>> {
|
||||
const home = process.env.HOME || process.env.USERPROFILE || homedir();
|
||||
const providers = new Set<string>();
|
||||
|
||||
// Fusion primary + legacy .pi auth files
|
||||
const authPaths = [
|
||||
join(home, ".fusion", "agent", "auth.json"),
|
||||
join(home, ".pi", "agent", "auth.json"),
|
||||
join(home, ".pi", "auth.json"),
|
||||
];
|
||||
|
||||
for (const authPath of authPaths) {
|
||||
try {
|
||||
await access(authPath);
|
||||
const parsed = JSON.parse(await readFile(authPath, "utf-8")) as Record<string, unknown>;
|
||||
for (const key of Object.keys(parsed)) {
|
||||
providers.add(key);
|
||||
}
|
||||
} catch {
|
||||
// Ignore missing or invalid auth files
|
||||
}
|
||||
}
|
||||
|
||||
// Check models.json for providers with inline API keys
|
||||
const modelsPaths = [
|
||||
join(home, ".fusion", "agent", "models.json"),
|
||||
join(home, ".pi", "agent", "models.json"),
|
||||
join(home, ".pi", "models.json"),
|
||||
];
|
||||
for (const modelsPath of modelsPaths) {
|
||||
try {
|
||||
await access(modelsPath);
|
||||
const parsed = JSON.parse(await readFile(modelsPath, "utf-8")) as {
|
||||
providers?: Record<string, { apiKey?: string }>;
|
||||
};
|
||||
const provs = parsed?.providers;
|
||||
if (provs) {
|
||||
for (const [providerId, config] of Object.entries(provs)) {
|
||||
if (config.apiKey) {
|
||||
providers.add(providerId);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore missing or invalid models.json
|
||||
}
|
||||
}
|
||||
|
||||
return providers;
|
||||
}
|
||||
|
||||
export const registerModelRoutes: ApiRouteRegistrar = (ctx) => {
|
||||
const { router, options, store, runtimeLogger } = ctx;
|
||||
|
||||
@@ -93,6 +153,18 @@ export const registerModelRoutes: ApiRouteRegistrar = (ctx) => {
|
||||
models = models.filter((m) => m.provider !== "cursor-cli");
|
||||
}
|
||||
|
||||
// Filter to only providers the user has explicitly configured in Fusion.
|
||||
// getAvailable() checks supplemental credential stores (Codex CLI,
|
||||
// Claude Code, env vars) which surface providers the user may not
|
||||
// have set up in Fusion. We restrict to providers with credentials
|
||||
// in Fusion's own auth stores (primary + legacy .pi + models.json),
|
||||
// plus any providers enabled via settings toggles (Claude CLI, etc.).
|
||||
const configuredProviders = await getConfiguredProviderNames();
|
||||
if (useClaudeCli) configuredProviders.add("pi-claude-cli");
|
||||
if (useDroidCli) configuredProviders.add("droid-cli");
|
||||
if (useLlamaCpp) configuredProviders.add("llama-server");
|
||||
models = models.filter((m) => configuredProviders.has(m.provider));
|
||||
|
||||
res.json({
|
||||
models,
|
||||
favoriteProviders,
|
||||
|
||||
BIN
screenshots/01-insights-view-header.png
Normal file
BIN
screenshots/01-insights-view-header.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 46 KiB |
BIN
screenshots/02-model-config-panel.png
Normal file
BIN
screenshots/02-model-config-panel.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 49 KiB |
BIN
screenshots/03-model-dropdown-filtered.png
Normal file
BIN
screenshots/03-model-dropdown-filtered.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 68 KiB |
BIN
screenshots/04-overflow-menu-experimental-features.png
Normal file
BIN
screenshots/04-overflow-menu-experimental-features.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 60 KiB |
Reference in New Issue
Block a user