From 5fd678d619f32437b5fa5e2dfac642d6110ccd79 Mon Sep 17 00:00:00 2001 From: Timothy Laurent Date: Tue, 5 May 2026 22:24:38 -0700 Subject: [PATCH] fix(dashboard): resolve default model from settings for mission interview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mission interview route passed modelProvider/modelId from the request body directly without resolving the configured default model from settings. When "Use default" was selected, both values were undefined, causing createFnAgent to use pi's internal fallback instead of the user's configured default (e.g. zai/glm-5.1). This produced "AI returned no valid JSON" errors. Use resolvePlanningSettingsModel() to resolve the effective model from the settings hierarchy (planning-specific → project → global defaults), with explicit request overrides still taking precedence. Closes #48 Co-Authored-By: Claude Opus 4.6 --- .../src/__tests__/mission-e2e.test.ts | 157 ++++++++++++++++++ packages/dashboard/src/mission-routes.ts | 12 +- 2 files changed, 166 insertions(+), 3 deletions(-) diff --git a/packages/dashboard/src/__tests__/mission-e2e.test.ts b/packages/dashboard/src/__tests__/mission-e2e.test.ts index 65bfa2dc3..f71d84c2e 100644 --- a/packages/dashboard/src/__tests__/mission-e2e.test.ts +++ b/packages/dashboard/src/__tests__/mission-e2e.test.ts @@ -2878,6 +2878,163 @@ describe("Mission API", () => { lockedByTab: "other-tab", }); }); + + it("POST /api/missions/interview/start resolves default model from settings when no override provided", async () => { + // Configure scoped store with default model settings + scopedStore = { + getRootDir: vi.fn().mockReturnValue(scopedRootDir), + getSettings: vi.fn().mockResolvedValue({ + promptOverrides: {}, + defaultProvider: "zai", + defaultModelId: "glm-5.1", + }), + getMissionStore: vi.fn().mockReturnValue(createMockMissionStore()), + } as unknown as TaskStore; + vi.spyOn(projectStoreResolver, "getOrCreateProjectStore").mockResolvedValue(scopedStore); + + const createSpy = vi + .spyOn(missionInterviewModule, "createMissionInterviewSession") + .mockResolvedValueOnce("resolved-model-session-id"); + + const { app } = buildApp(); + const res = await request( + app, + "POST", + `/api/missions/interview/start?projectId=${projectId}`, + JSON.stringify({ missionTitle: "Default Model Mission" }), + { "content-type": "application/json" } + ); + + expect(res.status).toBe(201); + // The route should resolve the default model from settings and pass it through + expect(createSpy).toHaveBeenCalledWith( + expect.any(String), + "Default Model Mission", + scopedRootDir, + {}, + "zai", + "glm-5.1", + ); + }); + + it("POST /api/missions/interview/start uses planning-specific model over global default", async () => { + // Configure scoped store with both planning-specific and global defaults + scopedStore = { + getRootDir: vi.fn().mockReturnValue(scopedRootDir), + getSettings: vi.fn().mockResolvedValue({ + promptOverrides: {}, + planningProvider: "anthropic", + planningModelId: "claude-sonnet-4-5", + defaultProvider: "zai", + defaultModelId: "glm-5.1", + }), + getMissionStore: vi.fn().mockReturnValue(createMockMissionStore()), + } as unknown as TaskStore; + vi.spyOn(projectStoreResolver, "getOrCreateProjectStore").mockResolvedValue(scopedStore); + + const createSpy = vi + .spyOn(missionInterviewModule, "createMissionInterviewSession") + .mockResolvedValueOnce("planning-model-session-id"); + + const { app } = buildApp(); + const res = await request( + app, + "POST", + `/api/missions/interview/start?projectId=${projectId}`, + JSON.stringify({ missionTitle: "Planning Model Mission" }), + { "content-type": "application/json" } + ); + + expect(res.status).toBe(201); + // Planning-specific model should take priority over global default + expect(createSpy).toHaveBeenCalledWith( + expect.any(String), + "Planning Model Mission", + scopedRootDir, + {}, + "anthropic", + "claude-sonnet-4-5", + ); + }); + + it("POST /api/missions/interview/start explicit model override takes precedence over settings defaults", async () => { + // Configure scoped store with default model settings + scopedStore = { + getRootDir: vi.fn().mockReturnValue(scopedRootDir), + getSettings: vi.fn().mockResolvedValue({ + promptOverrides: {}, + defaultProvider: "zai", + defaultModelId: "glm-5.1", + }), + getMissionStore: vi.fn().mockReturnValue(createMockMissionStore()), + } as unknown as TaskStore; + vi.spyOn(projectStoreResolver, "getOrCreateProjectStore").mockResolvedValue(scopedStore); + + const createSpy = vi + .spyOn(missionInterviewModule, "createMissionInterviewSession") + .mockResolvedValueOnce("override-session-id"); + + const { app } = buildApp(); + // Send explicit model override in request body + const res = await request( + app, + "POST", + `/api/missions/interview/start?projectId=${projectId}`, + JSON.stringify({ + missionTitle: "Override Mission", + modelProvider: "openai", + modelId: "gpt-4o", + }), + { "content-type": "application/json" } + ); + + expect(res.status).toBe(201); + // Explicit override should win over settings defaults + expect(createSpy).toHaveBeenCalledWith( + expect.any(String), + "Override Mission", + scopedRootDir, + {}, + "openai", + "gpt-4o", + ); + }); + + it("POST /api/missions/interview/start passes undefined model when no defaults configured", async () => { + // Settings with no model configuration at all (the "no defaults" case) + scopedStore = { + getRootDir: vi.fn().mockReturnValue(scopedRootDir), + getSettings: vi.fn().mockResolvedValue({ + promptOverrides: {}, + }), + getMissionStore: vi.fn().mockReturnValue(createMockMissionStore()), + } as unknown as TaskStore; + vi.spyOn(projectStoreResolver, "getOrCreateProjectStore").mockResolvedValue(scopedStore); + + const createSpy = vi + .spyOn(missionInterviewModule, "createMissionInterviewSession") + .mockResolvedValueOnce("no-defaults-session-id"); + + const { app } = buildApp(); + const res = await request( + app, + "POST", + `/api/missions/interview/start?projectId=${projectId}`, + JSON.stringify({ missionTitle: "No Defaults Mission" }), + { "content-type": "application/json" } + ); + + expect(res.status).toBe(201); + // When no defaults are configured, provider/model should be undefined + expect(createSpy).toHaveBeenCalledWith( + expect.any(String), + "No Defaults Mission", + scopedRootDir, + {}, + undefined, + undefined, + ); + }); }); // ── Regression: Generated ID format acceptance ───────────────────────── diff --git a/packages/dashboard/src/mission-routes.ts b/packages/dashboard/src/mission-routes.ts index 1dd758094..5345ff03a 100644 --- a/packages/dashboard/src/mission-routes.ts +++ b/packages/dashboard/src/mission-routes.ts @@ -14,7 +14,7 @@ import { Router, type Request, type Response, type NextFunction } from "express"; import { AsyncLocalStorage } from "node:async_hooks"; -import { TaskStore } from "@fusion/core"; +import { TaskStore, resolvePlanningSettingsModel } from "@fusion/core"; import { getOrCreateProjectStore } from "./project-store-resolver.js"; import type { Mission, @@ -411,13 +411,19 @@ export function createMissionRouter( const { createMissionInterviewSession } = await import("./mission-interview.js"); + // Resolve effective model: explicit override wins, then fall back to + // planning settings chain (planning-specific → project defaults → global defaults). + const effectiveModel = resolvePlanningSettingsModel(settings); + const resolvedProvider = modelProvider ?? effectiveModel.provider; + const resolvedModelId = modelId ?? effectiveModel.modelId; + const sessionId = await createMissionInterviewSession( ip, missionTitle.trim(), rootDir, settings.promptOverrides, - modelProvider, - modelId, + resolvedProvider, + resolvedModelId, ); res.status(201).json({ sessionId }); } catch (err: unknown) {