From 8d5c3b0811fd76a1ea46025f45259754192b83d1 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sun, 19 Jul 2026 21:04:43 -0700 Subject: [PATCH] FN-8414: fix mission interview thinking-level binding Fix mission interviews that omit a thinking level while preserving project and plugin bindings. - Remove the ambiguous session positional overload. - Resolve omitted thinking levels from planning settings and display the configured default. - Cover omitted and explicit thinking-level session starts with regression tests. - Add a patch changeset for the crash fix. Files changed: ...n-8414-mission-interview-thinking-level-bind.md | 7 ++ .../app/components/MissionInterviewModal.tsx | 25 ++++- .../__tests__/MissionInterviewModal.test.tsx | 13 +++ .../mission-interview-thinking-level-bind.test.ts | 108 +++++++++++++++++++++ packages/dashboard/src/mission-interview.ts | 19 ++-- packages/dashboard/src/mission-routes.ts | 21 +++- 6 files changed, 178 insertions(+), 15 deletions(-) Fusion-Task-Id: FN-8414 Fusion-Task-Lineage: d3be2cd6-9221-4e1d-a27a-c5fbe04f9200 Co-authored-by: Fusion (runfusion.ai) --- ...4-mission-interview-thinking-level-bind.md | 7 ++ .../app/components/MissionInterviewModal.tsx | 25 +++- .../__tests__/MissionInterviewModal.test.tsx | 13 +++ ...sion-interview-thinking-level-bind.test.ts | 108 ++++++++++++++++++ packages/dashboard/src/mission-interview.ts | 19 ++- packages/dashboard/src/mission-routes.ts | 21 +++- 6 files changed, 178 insertions(+), 15 deletions(-) create mode 100644 .changeset/fn-8414-mission-interview-thinking-level-bind.md create mode 100644 packages/dashboard/src/__tests__/mission-interview-thinking-level-bind.test.ts diff --git a/.changeset/fn-8414-mission-interview-thinking-level-bind.md b/.changeset/fn-8414-mission-interview-thinking-level-bind.md new file mode 100644 index 0000000000..7ea1404fcc --- /dev/null +++ b/.changeset/fn-8414-mission-interview-thinking-level-bind.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Fix mission interview start crashing when thinking level is left at Default. +category: fix +dev: Retire createMissionInterviewSession positional overload; resolve planning thinkingLevel from settings when body omits it; preserve projectId/pluginRunner binding (GitHub #2356 / FN-8414). diff --git a/packages/dashboard/app/components/MissionInterviewModal.tsx b/packages/dashboard/app/components/MissionInterviewModal.tsx index 4bdab3de8e..1226aa40e6 100644 --- a/packages/dashboard/app/components/MissionInterviewModal.tsx +++ b/packages/dashboard/app/components/MissionInterviewModal.tsx @@ -1,6 +1,6 @@ import { useState, useCallback, useEffect, useRef } from "react"; import { useTranslation } from "react-i18next"; -import type { PlanningQuestion, ThinkingLevel } from "@fusion/core"; +import type { PlanningQuestion, Settings, ThinkingLevel } from "@fusion/core"; import { getErrorMessage, THINKING_LEVELS } from "@fusion/core"; import { startMissionInterview, @@ -11,6 +11,7 @@ import { fetchAiSession, parseConversationHistory, fetchModels, + fetchSettings, updateGlobalSettings, type MissionPlanSummary, type ConversationHistoryEntry, @@ -154,9 +155,29 @@ export function MissionInterviewModal({ const [modelsError, setModelsError] = useState(null); const [favoriteProviders, setFavoriteProviders] = useState([]); const [favoriteModels, setFavoriteModels] = useState([]); + const [settings, setSettings] = useState>(); const modelSelectionValue = getModelSelectionValue(modelProvider, modelId); + /* + FNXC:MissionInterview 2026-07-19-20:46: + FN-8414 / GitHub #2356 requires the untouched mission interview picker to show + the configured thinking default instead of a hardcoded "off" placeholder. + Server-side planning resolution remains authoritative when the request omits it. + */ + useEffect(() => { + let cancelled = false; + setSettings(undefined); + void fetchSettings(projectId) + .then((loadedSettings) => { + if (!cancelled) setSettings(loadedSettings); + }) + .catch(() => undefined); + return () => { + cancelled = true; + }; + }, [projectId]); + // Load models on mount useEffect(() => { const load = async () => { @@ -892,7 +913,7 @@ export function MissionInterviewModal({ showThinkingLevel thinkingLevel={thinkingLevel} onThinkingLevelChange={(level) => setThinkingLevel(THINKING_LEVELS.includes(level as ThinkingLevel) ? (level as ThinkingLevel) : "")} - defaultThinkingLevel="off" + defaultThinkingLevel={settings?.defaultThinkingLevel ?? "off"} /> {modelsError && (
diff --git a/packages/dashboard/app/components/__tests__/MissionInterviewModal.test.tsx b/packages/dashboard/app/components/__tests__/MissionInterviewModal.test.tsx index 1e2c5e0dcc..fdfc98f758 100644 --- a/packages/dashboard/app/components/__tests__/MissionInterviewModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/MissionInterviewModal.test.tsx @@ -18,6 +18,7 @@ const mockAcquireSessionLock = vi.fn(); const mockReleaseSessionLock = vi.fn(); const mockForceAcquireSessionLock = vi.fn(); const mockFetchModels = vi.fn(); +const mockFetchSettings = vi.fn(); vi.mock("../../api", () => ({ startMissionInterview: (...args: any[]) => mockStartMissionInterview(...args), @@ -32,6 +33,7 @@ vi.mock("../../api", () => ({ releaseSessionLock: (...args: any[]) => mockReleaseSessionLock(...args), forceAcquireSessionLock: (...args: any[]) => mockForceAcquireSessionLock(...args), fetchModels: (...args: any[]) => mockFetchModels(...args), + fetchSettings: (...args: any[]) => mockFetchSettings(...args), })); const mockGetMissionGoal = vi.fn(() => ""); @@ -149,6 +151,7 @@ describe("MissionInterviewModal", () => { mockReleaseSessionLock.mockResolvedValue(undefined); mockForceAcquireSessionLock.mockResolvedValue({ acquired: true, currentHolder: null }); mockFetchModels.mockResolvedValue({ models: [], favoriteProviders: [], favoriteModels: [] }); + mockFetchSettings.mockResolvedValue({ defaultThinkingLevel: "off" }); localStorage.removeItem("floating-window:mission-interview"); vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback: FrameRequestCallback) => { callback(0); @@ -183,6 +186,16 @@ describe("MissionInterviewModal", () => { Object.defineProperty(element, "releasePointerCapture", { configurable: true, value: vi.fn() }); } + it("renders the configured thinking level as the mission interview default", async () => { + mockFetchSettings.mockResolvedValue({ defaultThinkingLevel: "high" }); + + renderModal(); + + await waitFor(() => { + expect(screen.getByTestId("custom-model-dropdown-thinking-badge")).toHaveTextContent("Default (high)"); + }); + }); + it("renders mission interview inside a floating desktop workspace", () => { setViewport(1200, 900); diff --git a/packages/dashboard/src/__tests__/mission-interview-thinking-level-bind.test.ts b/packages/dashboard/src/__tests__/mission-interview-thinking-level-bind.test.ts new file mode 100644 index 0000000000..8dabad6023 --- /dev/null +++ b/packages/dashboard/src/__tests__/mission-interview-thinking-level-bind.test.ts @@ -0,0 +1,108 @@ +// @vitest-environment node + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { THINKING_LEVELS, type ThinkingLevel } from "@fusion/core"; + +const { buildSessionSkillContextSyncMock } = vi.hoisted(() => ({ + buildSessionSkillContextSyncMock: vi.fn(() => ({ skillSelectionContext: undefined })), +})); + +vi.mock("@fusion/engine", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + buildSessionSkillContextSync: buildSessionSkillContextSyncMock, + resolveMcpServersForStore: vi.fn(async () => ({ servers: [] })), + createFnAgent: vi.fn(async () => ({ + session: { + state: { messages: [] as Array<{ role: string; content: string }> }, + prompt: vi.fn(async function (this: { state: { messages: Array<{ role: string; content: string }> } }) { + this.state.messages.push({ + role: "assistant", + content: JSON.stringify({ + type: "question", + data: { id: "scope", type: "text", question: "What should this mission accomplish?" }, + }), + }); + }), + dispose: vi.fn(), + }, + })), + }; +}); + +vi.mock("../planning-board-tools.js", () => ({ + createPlanningBoardTools: vi.fn(() => []), +})); + +import { + __resetMissionInterviewState, + createMissionInterviewSession, + getMissionInterviewSession, +} from "../mission-interview.js"; +import { resolveMissionInterviewThinkingLevel } from "../mission-routes.js"; + +const store = {} as never; +const pluginRunner = { getPluginSkills: vi.fn(() => []) }; + +async function createSession(thinkingLevel: ThinkingLevel | undefined, projectId: string | null) { + const sessionId = await createMissionInterviewSession( + "127.0.0.1", + "Plan reliable interviews", + "/tmp/fusion-dashboard-test", + store, + undefined, + undefined, + undefined, + thinkingLevel, + projectId, + pluginRunner, + ); + await vi.waitFor(() => expect(buildSessionSkillContextSyncMock).toHaveBeenCalled()); + return getMissionInterviewSession(sessionId); +} + +describe("mission interview thinking-level argument binding", () => { + beforeEach(() => { + __resetMissionInterviewState(); + buildSessionSkillContextSyncMock.mockClear(); + pluginRunner.getPluginSkills.mockClear(); + }); + + afterEach(() => { + __resetMissionInterviewState(); + }); + + it("preserves the plugin runner and project scope when thinkingLevel is omitted", async () => { + const session = await createSession(undefined, "proj-1"); + + expect(session?.projectId).toBe("proj-1"); + expect(session?.pluginRunner).toBe(pluginRunner); + expect(session?.error).toBeUndefined(); + expect(buildSessionSkillContextSyncMock).toHaveBeenCalledWith(null, "executor", "/tmp/fusion-dashboard-test", pluginRunner); + expect(session?.error).not.toBe("pluginRunner.getPluginSkills is not a function"); + }); + + it("does not silently discard a real plugin runner for an unscoped omitted-thinking start", async () => { + const session = await createSession(undefined, null); + + expect(session?.projectId).toBeNull(); + expect(session?.pluginRunner).toBe(pluginRunner); + expect(session?.error).toBeUndefined(); + expect(buildSessionSkillContextSyncMock).toHaveBeenCalledWith(null, "executor", "/tmp/fusion-dashboard-test", pluginRunner); + }); + + it.each(THINKING_LEVELS)("keeps plugin runner and project scope bound for %s", async (thinkingLevel) => { + const session = await createSession(thinkingLevel, "proj-1"); + + expect(session?.thinkingLevel).toBe(thinkingLevel); + expect(session?.projectId).toBe("proj-1"); + expect(session?.pluginRunner).toBe(pluginRunner); + expect(buildSessionSkillContextSyncMock).toHaveBeenCalledWith(null, "executor", "/tmp/fusion-dashboard-test", pluginRunner); + }); + + it("uses planning settings only when the request omits thinkingLevel", () => { + expect(resolveMissionInterviewThinkingLevel({ planningThinkingLevel: "high", defaultThinkingLevel: "low" }, undefined)).toBe("high"); + expect(resolveMissionInterviewThinkingLevel({ planningThinkingLevel: "high" }, "minimal")).toBe("minimal"); + }); +}); diff --git a/packages/dashboard/src/mission-interview.ts b/packages/dashboard/src/mission-interview.ts index 55b7f788d8..78accf819a 100644 --- a/packages/dashboard/src/mission-interview.ts +++ b/packages/dashboard/src/mission-interview.ts @@ -1293,17 +1293,16 @@ export async function createMissionInterviewSession( promptOverrides?: PromptOverrideMap, modelProvider?: string, modelId?: string, - thinkingLevelOrProjectId?: ThinkingLevel | string | null, - projectIdOrPluginRunner?: string | null | SkillPluginRunner, - pluginRunnerMaybe?: SkillPluginRunner, + thinkingLevel?: ThinkingLevel, + projectId?: string | null, + pluginRunner?: SkillPluginRunner, ): Promise { - const thinkingLevel = THINKING_LEVELS.includes(thinkingLevelOrProjectId as ThinkingLevel) - ? (thinkingLevelOrProjectId as ThinkingLevel) - : undefined; - const projectId = thinkingLevel - ? (projectIdOrPluginRunner as string | null | undefined) - : (thinkingLevelOrProjectId as string | null | undefined); - const pluginRunner = (thinkingLevel ? pluginRunnerMaybe : projectIdOrPluginRunner) as SkillPluginRunner | undefined; + /* + FNXC:MissionInterview 2026-07-19-20:46: + FN-8414 / GitHub #2356 removed the legacy positional overload because an omitted + thinkingLevel shifted projectId into pluginRunner. Keep this fixed tail so TypeScript + rejects a project-id string where a SkillPluginRunner is required. + */ if (!checkRateLimit(ip)) { const resetTime = getRateLimitResetTime(ip); throw new RateLimitError( diff --git a/packages/dashboard/src/mission-routes.ts b/packages/dashboard/src/mission-routes.ts index 2968e73ce1..b10944fb73 100644 --- a/packages/dashboard/src/mission-routes.ts +++ b/packages/dashboard/src/mission-routes.ts @@ -15,8 +15,8 @@ import { Router, type Request, type Response, type NextFunction } from "express"; import { AsyncLocalStorage } from "node:async_hooks"; import { TaskStore, resolvePlanningSettingsModel, AgentStore, THINKING_LEVELS } from "@fusion/core"; -import type { Goal, ThinkingLevel } from "@fusion/core"; -import { listEligibleExecutorAgents } from "@fusion/engine"; +import type { Goal, Settings, ThinkingLevel } from "@fusion/core"; +import { listEligibleExecutorAgents, resolvePlanningThinkingLevel } from "@fusion/engine"; import { getScopedStore as resolveScopedRequestStore, getProjectContext as resolveSharedProjectContext, @@ -64,6 +64,14 @@ import { import type { AiSessionStore } from "./ai-session-store.js"; import { resolveBranchAssignmentContext, resolveBranchSelection } from "./routes/branch-selection.js"; +/** Resolve the mission-start override through the planning settings hierarchy. */ +export function resolveMissionInterviewThinkingLevel( + settings: Partial | undefined, + thinkingLevel: ThinkingLevel | undefined, +): ThinkingLevel | undefined { + return resolvePlanningThinkingLevel(settings, thinkingLevel) as ThinkingLevel | undefined; +} + // ── Validation Utilities ──────────────────────────────────────────────────── function validateMissionId(id: string): boolean { @@ -558,6 +566,13 @@ export function createMissionRouter( const effectiveModel = resolvePlanningSettingsModel(settings); const resolvedProvider = modelProvider ?? effectiveModel.provider; const resolvedModelId = modelId ?? effectiveModel.modelId; + /* + FNXC:MissionInterview 2026-07-19-20:46: + FN-8414 / GitHub #2356 makes omitted request thinkingLevel inherit the planning + settings hierarchy, matching model resolution above. The fixed session signature + then preserves projectId and pluginRunner instead of shifting positional arguments. + */ + const resolvedThinkingLevel = resolveMissionInterviewThinkingLevel(settings, validatedThinkingLevel); const sessionId = await createMissionInterviewSession( ip, @@ -567,7 +582,7 @@ export function createMissionRouter( settings.promptOverrides, resolvedProvider, resolvedModelId, - validatedThinkingLevel, + resolvedThinkingLevel, projectId ?? null, pluginRunner, );