feat(FN-3120): show resolved planning model in planning modal

Adds a resolved planning model display to the planning modal by introducing a new `/api/models/resolve` endpoint, wiring it through the legacy API, and updating the modal to show the final resolved model alongside its source (FN-3120).

Fusion-Task-Id: FN-3120
This commit is contained in:
Fusion
2026-05-03 06:12:47 -07:00
committed by gsxdsm
parent ca702293ec
commit 59f2868f24
5 changed files with 63 additions and 7 deletions

View File

@@ -1204,6 +1204,8 @@ export interface ModelsResponse {
favoriteModels: string[];
defaultProvider?: string;
defaultModelId?: string;
resolvedPlanningProvider?: string;
resolvedPlanningModelId?: string;
}
/** Fetch available AI models from the model registry along with favoriteProviders */

View File

@@ -150,6 +150,10 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
const [modelsError, setModelsError] = useState<string | null>(null);
const [favoriteProviders, setFavoriteProviders] = useState<string[]>([]);
const [favoriteModels, setFavoriteModels] = useState<string[]>([]);
const [resolvedPlanningModel, setResolvedPlanningModel] = useState<{
provider?: string;
modelId?: string;
}>({});
const trackedLockSessionRef = useRef<string | null>(null);
// Sidebar list state
@@ -277,11 +281,15 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
const getModelBadgeLabel = useCallback(
(provider?: string, modelId?: string) => {
if (!provider || !modelId) return "Using default";
if (!provider || !modelId) {
return resolvedPlanningModel.provider && resolvedPlanningModel.modelId
? `${resolvedPlanningModel.provider}/${resolvedPlanningModel.modelId}`
: "Using default";
}
const matched = loadedModels.find((model) => model.provider === provider && model.id === modelId);
return matched ? `${matched.provider}/${matched.id}` : `${provider}/${modelId}`;
},
[loadedModels],
[loadedModels, resolvedPlanningModel.modelId, resolvedPlanningModel.provider],
);
const loadModels = useCallback(async () => {
@@ -293,6 +301,10 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
setLoadedModels(response.models);
setFavoriteProviders(response.favoriteProviders);
setFavoriteModels(response.favoriteModels);
setResolvedPlanningModel({
provider: response.resolvedPlanningProvider,
modelId: response.resolvedPlanningModelId,
});
} catch (err) {
setModelsError(getErrorMessage(err) || "Failed to load models");
} finally {

View File

@@ -223,6 +223,8 @@ describe("PlanningModeModal", () => {
models: mockModels,
favoriteProviders: [],
favoriteModels: [],
resolvedPlanningProvider: "openai",
resolvedPlanningModelId: "gpt-4o",
});
mockAcquireSessionLock.mockResolvedValue({ acquired: true, currentHolder: null });
mockReleaseSessionLock.mockResolvedValue(undefined);
@@ -338,14 +340,14 @@ describe("PlanningModeModal", () => {
const modelTrigger = screen.getByRole("button", { name: "Planning Model" });
expect(modelTrigger).toBeDefined();
expect(screen.getByText("Using default")).toBeDefined();
await waitFor(() => {
expect(mockFetchModels).toHaveBeenCalledTimes(1);
expect(screen.getByText("openai/gpt-4o")).toBeDefined();
});
});
it("updates planning model selection and badge", async () => {
it("shows resolved default model badge and switches to override badge when selected", async () => {
render(
<PlanningModeModal
isOpen={true}
@@ -360,6 +362,8 @@ describe("PlanningModeModal", () => {
expect(mockFetchModels).toHaveBeenCalledTimes(1);
});
expect(screen.getByText("openai/gpt-4o")).toBeDefined();
fireEvent.click(screen.getByRole("button", { name: "Planning Model" }));
fireEvent.click(screen.getByRole("option", { name: /Claude Sonnet 4.5/ }));
@@ -399,7 +403,7 @@ describe("PlanningModeModal", () => {
});
});
it("renders advanced disclosure controls in the initial view", () => {
it("renders advanced disclosure controls in the initial view", async () => {
render(
<PlanningModeModal
isOpen={true}
@@ -418,7 +422,9 @@ describe("PlanningModeModal", () => {
const disclosureScope = within(disclosure as HTMLElement);
expect(disclosureScope.getByRole("button", { name: "Planning Model" })).toBeDefined();
expect(disclosureScope.getByText("Using default")).toBeDefined();
await waitFor(() => {
expect(disclosureScope.getByText("openai/gpt-4o")).toBeDefined();
});
expect(disclosureScope.getByText(/Selects which model runs the planning interview/)).toBeDefined();
expect(disclosureScope.getByText(/Plan size sets default interview depth/)).toBeDefined();
expect(disclosureScope.getByRole("button", { name: "Small" })).toBeDefined();

View File

@@ -2,7 +2,7 @@ import { describe, expect, it, vi } from "vitest";
import type { Router } from "express";
import { registerModelRoutes } from "../routes/register-model-routes.js";
function setup(useDroidCli?: boolean) {
function setup(useDroidCli?: boolean, mergedSettings: Record<string, unknown> = {}) {
const getHandlers = new Map<string, (req: unknown, res: { json: (body: unknown) => void }) => Promise<void>>();
const router = {
get: vi.fn((path: string, handler: (req: unknown, res: { json: (body: unknown) => void }) => Promise<void>) => {
@@ -14,6 +14,7 @@ function setup(useDroidCli?: boolean) {
getGlobalSettingsStore: () => ({
getSettings: vi.fn().mockResolvedValue({ useDroidCli }),
}),
getSettingsFast: vi.fn().mockResolvedValue(mergedSettings),
};
const runtimeLogger = {
@@ -58,4 +59,21 @@ describe("registerModelRoutes droid-cli filter", () => {
const response = json.mock.calls[0][0] as { models: Array<{ provider: string }> };
expect(response.models.some((model) => model.provider === "droid-cli")).toBe(true);
});
it("includes resolved planning model when settings hierarchy resolves one", async () => {
const { handler } = setup(false, {
planningProvider: "openai",
planningModelId: "gpt-4o",
});
const json = vi.fn();
await handler({}, { json });
const response = json.mock.calls[0][0] as {
resolvedPlanningProvider?: string;
resolvedPlanningModelId?: string;
};
expect(response.resolvedPlanningProvider).toBe("openai");
expect(response.resolvedPlanningModelId).toBe("gpt-4o");
});
});

View File

@@ -1,3 +1,4 @@
import { resolvePlanningSettingsModel } from "@fusion/core";
import { ApiError } from "../api-error.js";
import type { ApiRouteRegistrar } from "./types.js";
@@ -12,6 +13,8 @@ export const registerModelRoutes: ApiRouteRegistrar = (ctx) => {
let defaultModelId: string | undefined;
let useClaudeCli = false;
let useDroidCli = false;
let resolvedPlanningProvider: string | undefined;
let resolvedPlanningModelId: string | undefined;
if (store) {
try {
const globalStore = store.getGlobalSettingsStore();
@@ -22,6 +25,11 @@ export const registerModelRoutes: ApiRouteRegistrar = (ctx) => {
defaultModelId = globalSettings.defaultModelId;
useClaudeCli = globalSettings.useClaudeCli === true;
useDroidCli = globalSettings.useDroidCli === true;
const mergedSettings = await store.getSettingsFast();
const resolvedPlanningModel = resolvePlanningSettingsModel(mergedSettings);
resolvedPlanningProvider = resolvedPlanningModel.provider;
resolvedPlanningModelId = resolvedPlanningModel.modelId;
} catch {
// Silently ignore settings errors - just return empty favorites/default model
}
@@ -31,6 +39,13 @@ export const registerModelRoutes: ApiRouteRegistrar = (ctx) => {
defaultProvider && defaultModelId
? { defaultProvider, defaultModelId }
: {};
const resolvedPlanningModelResponse =
resolvedPlanningProvider && resolvedPlanningModelId
? {
resolvedPlanningProvider,
resolvedPlanningModelId,
}
: {};
// Always return 200 with empty array instead of 404 when no models available.
// This ensures the frontend can handle empty states gracefully.
@@ -40,6 +55,7 @@ export const registerModelRoutes: ApiRouteRegistrar = (ctx) => {
favoriteProviders,
favoriteModels,
...defaultModelResponse,
...resolvedPlanningModelResponse,
});
return;
}
@@ -72,6 +88,7 @@ export const registerModelRoutes: ApiRouteRegistrar = (ctx) => {
favoriteProviders,
favoriteModels,
...defaultModelResponse,
...resolvedPlanningModelResponse,
});
} catch (err: unknown) {
if (err instanceof ApiError) {
@@ -84,6 +101,7 @@ export const registerModelRoutes: ApiRouteRegistrar = (ctx) => {
favoriteProviders,
favoriteModels,
...defaultModelResponse,
...resolvedPlanningModelResponse,
});
}
});