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