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) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-19 21:04:43 -07:00
parent 76ec2b4576
commit 8d5c3b0811
6 changed files with 178 additions and 15 deletions

View File

@@ -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).

View File

@@ -1,6 +1,6 @@
import { useState, useCallback, useEffect, useRef } from "react"; import { useState, useCallback, useEffect, useRef } from "react";
import { useTranslation } from "react-i18next"; 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 { getErrorMessage, THINKING_LEVELS } from "@fusion/core";
import { import {
startMissionInterview, startMissionInterview,
@@ -11,6 +11,7 @@ import {
fetchAiSession, fetchAiSession,
parseConversationHistory, parseConversationHistory,
fetchModels, fetchModels,
fetchSettings,
updateGlobalSettings, updateGlobalSettings,
type MissionPlanSummary, type MissionPlanSummary,
type ConversationHistoryEntry, type ConversationHistoryEntry,
@@ -154,9 +155,29 @@ export function MissionInterviewModal({
const [modelsError, setModelsError] = useState<string | null>(null); const [modelsError, setModelsError] = useState<string | null>(null);
const [favoriteProviders, setFavoriteProviders] = useState<string[]>([]); const [favoriteProviders, setFavoriteProviders] = useState<string[]>([]);
const [favoriteModels, setFavoriteModels] = useState<string[]>([]); const [favoriteModels, setFavoriteModels] = useState<string[]>([]);
const [settings, setSettings] = useState<Pick<Settings, "defaultThinkingLevel">>();
const modelSelectionValue = getModelSelectionValue(modelProvider, modelId); 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 // Load models on mount
useEffect(() => { useEffect(() => {
const load = async () => { const load = async () => {
@@ -892,7 +913,7 @@ export function MissionInterviewModal({
showThinkingLevel showThinkingLevel
thinkingLevel={thinkingLevel} thinkingLevel={thinkingLevel}
onThinkingLevelChange={(level) => setThinkingLevel(THINKING_LEVELS.includes(level as ThinkingLevel) ? (level as ThinkingLevel) : "")} onThinkingLevelChange={(level) => setThinkingLevel(THINKING_LEVELS.includes(level as ThinkingLevel) ? (level as ThinkingLevel) : "")}
defaultThinkingLevel="off" defaultThinkingLevel={settings?.defaultThinkingLevel ?? "off"}
/> />
{modelsError && ( {modelsError && (
<div className="form-hint form-hint-error"> <div className="form-hint form-hint-error">

View File

@@ -18,6 +18,7 @@ const mockAcquireSessionLock = vi.fn();
const mockReleaseSessionLock = vi.fn(); const mockReleaseSessionLock = vi.fn();
const mockForceAcquireSessionLock = vi.fn(); const mockForceAcquireSessionLock = vi.fn();
const mockFetchModels = vi.fn(); const mockFetchModels = vi.fn();
const mockFetchSettings = vi.fn();
vi.mock("../../api", () => ({ vi.mock("../../api", () => ({
startMissionInterview: (...args: any[]) => mockStartMissionInterview(...args), startMissionInterview: (...args: any[]) => mockStartMissionInterview(...args),
@@ -32,6 +33,7 @@ vi.mock("../../api", () => ({
releaseSessionLock: (...args: any[]) => mockReleaseSessionLock(...args), releaseSessionLock: (...args: any[]) => mockReleaseSessionLock(...args),
forceAcquireSessionLock: (...args: any[]) => mockForceAcquireSessionLock(...args), forceAcquireSessionLock: (...args: any[]) => mockForceAcquireSessionLock(...args),
fetchModels: (...args: any[]) => mockFetchModels(...args), fetchModels: (...args: any[]) => mockFetchModels(...args),
fetchSettings: (...args: any[]) => mockFetchSettings(...args),
})); }));
const mockGetMissionGoal = vi.fn(() => ""); const mockGetMissionGoal = vi.fn(() => "");
@@ -149,6 +151,7 @@ describe("MissionInterviewModal", () => {
mockReleaseSessionLock.mockResolvedValue(undefined); mockReleaseSessionLock.mockResolvedValue(undefined);
mockForceAcquireSessionLock.mockResolvedValue({ acquired: true, currentHolder: null }); mockForceAcquireSessionLock.mockResolvedValue({ acquired: true, currentHolder: null });
mockFetchModels.mockResolvedValue({ models: [], favoriteProviders: [], favoriteModels: [] }); mockFetchModels.mockResolvedValue({ models: [], favoriteProviders: [], favoriteModels: [] });
mockFetchSettings.mockResolvedValue({ defaultThinkingLevel: "off" });
localStorage.removeItem("floating-window:mission-interview"); localStorage.removeItem("floating-window:mission-interview");
vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback: FrameRequestCallback) => { vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback: FrameRequestCallback) => {
callback(0); callback(0);
@@ -183,6 +186,16 @@ describe("MissionInterviewModal", () => {
Object.defineProperty(element, "releasePointerCapture", { configurable: true, value: vi.fn() }); 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", () => { it("renders mission interview inside a floating desktop workspace", () => {
setViewport(1200, 900); setViewport(1200, 900);

View File

@@ -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<typeof import("@fusion/engine")>();
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");
});
});

View File

@@ -1293,17 +1293,16 @@ export async function createMissionInterviewSession(
promptOverrides?: PromptOverrideMap, promptOverrides?: PromptOverrideMap,
modelProvider?: string, modelProvider?: string,
modelId?: string, modelId?: string,
thinkingLevelOrProjectId?: ThinkingLevel | string | null, thinkingLevel?: ThinkingLevel,
projectIdOrPluginRunner?: string | null | SkillPluginRunner, projectId?: string | null,
pluginRunnerMaybe?: SkillPluginRunner, pluginRunner?: SkillPluginRunner,
): Promise<string> { ): Promise<string> {
const thinkingLevel = THINKING_LEVELS.includes(thinkingLevelOrProjectId as ThinkingLevel) /*
? (thinkingLevelOrProjectId as ThinkingLevel) FNXC:MissionInterview 2026-07-19-20:46:
: undefined; FN-8414 / GitHub #2356 removed the legacy positional overload because an omitted
const projectId = thinkingLevel thinkingLevel shifted projectId into pluginRunner. Keep this fixed tail so TypeScript
? (projectIdOrPluginRunner as string | null | undefined) rejects a project-id string where a SkillPluginRunner is required.
: (thinkingLevelOrProjectId as string | null | undefined); */
const pluginRunner = (thinkingLevel ? pluginRunnerMaybe : projectIdOrPluginRunner) as SkillPluginRunner | undefined;
if (!checkRateLimit(ip)) { if (!checkRateLimit(ip)) {
const resetTime = getRateLimitResetTime(ip); const resetTime = getRateLimitResetTime(ip);
throw new RateLimitError( throw new RateLimitError(

View File

@@ -15,8 +15,8 @@
import { Router, type Request, type Response, type NextFunction } from "express"; import { Router, type Request, type Response, type NextFunction } from "express";
import { AsyncLocalStorage } from "node:async_hooks"; import { AsyncLocalStorage } from "node:async_hooks";
import { TaskStore, resolvePlanningSettingsModel, AgentStore, THINKING_LEVELS } from "@fusion/core"; import { TaskStore, resolvePlanningSettingsModel, AgentStore, THINKING_LEVELS } from "@fusion/core";
import type { Goal, ThinkingLevel } from "@fusion/core"; import type { Goal, Settings, ThinkingLevel } from "@fusion/core";
import { listEligibleExecutorAgents } from "@fusion/engine"; import { listEligibleExecutorAgents, resolvePlanningThinkingLevel } from "@fusion/engine";
import { import {
getScopedStore as resolveScopedRequestStore, getScopedStore as resolveScopedRequestStore,
getProjectContext as resolveSharedProjectContext, getProjectContext as resolveSharedProjectContext,
@@ -64,6 +64,14 @@ import {
import type { AiSessionStore } from "./ai-session-store.js"; import type { AiSessionStore } from "./ai-session-store.js";
import { resolveBranchAssignmentContext, resolveBranchSelection } from "./routes/branch-selection.js"; import { resolveBranchAssignmentContext, resolveBranchSelection } from "./routes/branch-selection.js";
/** Resolve the mission-start override through the planning settings hierarchy. */
export function resolveMissionInterviewThinkingLevel(
settings: Partial<Settings> | undefined,
thinkingLevel: ThinkingLevel | undefined,
): ThinkingLevel | undefined {
return resolvePlanningThinkingLevel(settings, thinkingLevel) as ThinkingLevel | undefined;
}
// ── Validation Utilities ──────────────────────────────────────────────────── // ── Validation Utilities ────────────────────────────────────────────────────
function validateMissionId(id: string): boolean { function validateMissionId(id: string): boolean {
@@ -558,6 +566,13 @@ export function createMissionRouter(
const effectiveModel = resolvePlanningSettingsModel(settings); const effectiveModel = resolvePlanningSettingsModel(settings);
const resolvedProvider = modelProvider ?? effectiveModel.provider; const resolvedProvider = modelProvider ?? effectiveModel.provider;
const resolvedModelId = modelId ?? effectiveModel.modelId; 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( const sessionId = await createMissionInterviewSession(
ip, ip,
@@ -567,7 +582,7 @@ export function createMissionRouter(
settings.promptOverrides, settings.promptOverrides,
resolvedProvider, resolvedProvider,
resolvedModelId, resolvedModelId,
validatedThinkingLevel, resolvedThinkingLevel,
projectId ?? null, projectId ?? null,
pluginRunner, pluginRunner,
); );