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:
@@ -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).
|
||||
@@ -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<string | null>(null);
|
||||
const [favoriteProviders, setFavoriteProviders] = useState<string[]>([]);
|
||||
const [favoriteModels, setFavoriteModels] = useState<string[]>([]);
|
||||
const [settings, setSettings] = useState<Pick<Settings, "defaultThinkingLevel">>();
|
||||
|
||||
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 && (
|
||||
<div className="form-hint form-hint-error">
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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<string> {
|
||||
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(
|
||||
|
||||
@@ -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<Settings> | 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,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user