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:
@@ -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 */
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user