diff --git a/.changeset/quiet-dragons-think.md b/.changeset/quiet-dragons-think.md new file mode 100644 index 0000000000..2a146e152e --- /dev/null +++ b/.changeset/quiet-dragons-think.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": minor +--- + +summary: Add persisted thinking-level controls to Mission Interview and Planning mode. +category: feature +dev: Threads optional thinkingLevel through mission/planning session inputPayload, routes, clients, and agent defaults. diff --git a/packages/dashboard/app/api/legacy.ts b/packages/dashboard/app/api/legacy.ts index ec60f8aa6b..e711a66e47 100644 --- a/packages/dashboard/app/api/legacy.ts +++ b/packages/dashboard/app/api/legacy.ts @@ -3950,6 +3950,7 @@ export interface AgentOnboardingSummary { } export type OnboardingMode = "create" | "edit"; +export type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh"; export interface ExistingAgentOnboardingConfig { name?: string; @@ -3961,7 +3962,7 @@ export interface ExistingAgentOnboardingConfig { reportsTo?: string; skills?: string[]; model?: string; - thinkingLevel?: "off" | "minimal" | "low" | "medium" | "high" | "xhigh"; + thinkingLevel?: ThinkingLevel; maxTurns?: number; runtimeHint?: string; heartbeatIntervalMs?: number; @@ -3996,7 +3997,7 @@ export function startPlanning( export function createPlanningDraft( initialPlan: string, projectId?: string, - modelOverride?: { planningModelProvider?: string; planningModelId?: string }, + modelOverride?: { planningModelProvider?: string; planningModelId?: string; thinkingLevel?: ThinkingLevel }, ): Promise<{ sessionId: string; title: string }> { return api<{ sessionId: string; title: string }>(withProjectId("/planning/create-draft", projectId), { method: "POST", @@ -4004,6 +4005,7 @@ export function createPlanningDraft( initialPlan, planningModelProvider: modelOverride?.planningModelProvider, planningModelId: modelOverride?.planningModelId, + thinkingLevel: modelOverride?.thinkingLevel, }), }); } @@ -4012,7 +4014,7 @@ export function createPlanningDraft( export function startPlanningStreaming( initialPlan: string, projectId?: string, - modelOverride?: { planningModelProvider?: string; planningModelId?: string }, + modelOverride?: { planningModelProvider?: string; planningModelId?: string; thinkingLevel?: ThinkingLevel }, planningOptions?: { planningDepth?: "small" | "medium" | "large"; customQuestionCount?: number }, existingSessionId?: string, ): Promise<{ sessionId: string }> { @@ -4022,6 +4024,7 @@ export function startPlanningStreaming( initialPlan, planningModelProvider: modelOverride?.planningModelProvider, planningModelId: modelOverride?.planningModelId, + thinkingLevel: modelOverride?.thinkingLevel, planningDepth: planningOptions?.planningDepth, customQuestionCount: planningOptions?.customQuestionCount, ...(existingSessionId ? { existingSessionId } : {}), @@ -8728,7 +8731,7 @@ export type MissionInterviewResponse = export function startMissionInterview( missionTitle: string, projectId?: string, - modelOverride?: { modelProvider?: string; modelId?: string }, + modelOverride?: { modelProvider?: string; modelId?: string; thinkingLevel?: ThinkingLevel }, ): Promise<{ sessionId: string }> { return api<{ sessionId: string }>(withProjectId("/missions/interview/start", projectId), { method: "POST", @@ -8736,6 +8739,7 @@ export function startMissionInterview( missionTitle, modelProvider: modelOverride?.modelProvider, modelId: modelOverride?.modelId, + thinkingLevel: modelOverride?.thinkingLevel, }), }); } @@ -9538,7 +9542,7 @@ export function pingSession(sessionId: string, projectId?: string): Promise<{ ok export function updatePlanningSessionDraft( sessionId: string, - draft: { initialPlan: string; modelProvider?: string; modelId?: string }, + draft: { initialPlan: string; modelProvider?: string; modelId?: string; thinkingLevel?: ThinkingLevel }, projectId?: string, ): Promise<{ ok: boolean }> { return api<{ ok: boolean }>(withProjectId(`/ai-sessions/${encodeURIComponent(sessionId)}/draft`, projectId), { diff --git a/packages/dashboard/app/components/MissionInterviewModal.tsx b/packages/dashboard/app/components/MissionInterviewModal.tsx index b4db63c810..62093ca035 100644 --- a/packages/dashboard/app/components/MissionInterviewModal.tsx +++ b/packages/dashboard/app/components/MissionInterviewModal.tsx @@ -1,7 +1,7 @@ import { useState, useCallback, useEffect, useMemo, useRef } from "react"; import { useTranslation } from "react-i18next"; -import type { PlanningQuestion } from "@fusion/core"; -import { getErrorMessage } from "@fusion/core"; +import type { PlanningQuestion, ThinkingLevel } from "@fusion/core"; +import { getErrorMessage, THINKING_LEVELS } from "@fusion/core"; import { startMissionInterview, respondToMissionInterview, @@ -158,6 +158,7 @@ export function MissionInterviewModal({ // Model selection state const [modelProvider, setModelProvider] = useState(undefined); const [modelId, setModelId] = useState(undefined); + const [thinkingLevel, setThinkingLevel] = useState(""); const [loadedModels, setLoadedModels] = useState([]); const [modelsLoading, setModelsLoading] = useState(true); const [modelsError, setModelsError] = useState(null); @@ -425,10 +426,11 @@ export function MissionInterviewModal({ setView({ type: "loading" }); try { + const modelOverride = modelProvider && modelId ? { modelProvider, modelId, thinkingLevel: thinkingLevel || undefined } : (thinkingLevel ? { thinkingLevel } : undefined); const { sessionId } = await startMissionInterview( goal.trim(), projectId, - modelProvider && modelId ? { modelProvider, modelId } : undefined, + modelOverride, ); currentSessionIdRef.current = sessionId; setLockSessionId(sessionId); @@ -444,7 +446,7 @@ export function MissionInterviewModal({ setLockSessionId(null); } }, - [connectToMissionInterviewStream, missionGoal, modelProvider, modelId, projectId] + [connectToMissionInterviewStream, missionGoal, modelProvider, modelId, thinkingLevel, projectId] ); // Focus textarea when opening @@ -499,6 +501,12 @@ export function MissionInterviewModal({ const parsedHistory = parseConversationHistory(session.conversationHistory); setConversationHistory(parsedHistory); + try { + const payload = session.inputPayload ? JSON.parse(session.inputPayload) : null; + setThinkingLevel(THINKING_LEVELS.includes(payload?.thinkingLevel as ThinkingLevel) ? payload.thinkingLevel : ""); + } catch { + setThinkingLevel(""); + } setLockSessionId(session.id); setResponseHistory( parsedHistory @@ -954,6 +962,10 @@ export function MissionInterviewModal({ onToggleFavorite={handleToggleFavoriteProvider} favoriteModels={favoriteModels} onToggleModelFavorite={handleToggleFavoriteModel} + showThinkingLevel + thinkingLevel={thinkingLevel} + onThinkingLevelChange={(level) => setThinkingLevel(THINKING_LEVELS.includes(level as ThinkingLevel) ? (level as ThinkingLevel) : "")} + defaultThinkingLevel="off" /> {modelsError && (
@@ -982,6 +994,7 @@ export function MissionInterviewModal({
)} + {/* FNXC:MissionInterview 2026-07-12-00:00: The planning model selector exposes the same inline per-session thinking control as chat and sends it only because mission-interview inputPayload now persists and restores that reasoning-effort field. */}
(undefined); const [planningModelId, setPlanningModelId] = useState(undefined); + const [planningThinkingLevel, setPlanningThinkingLevel] = useState(""); const [planningDepth, setPlanningDepth] = useState<"small" | "medium" | "large">("medium"); const [customQuestionCount, setCustomQuestionCount] = useState(""); const [loadedModels, setLoadedModels] = useState([]); @@ -422,6 +424,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat initialPlan: string; modelProvider?: string; modelId?: string; + thinkingLevel?: ThinkingLevel | ""; } | null>(null); useModalResizePersist(modalRef, isOpen && resizePersistEnabled, "fusion:planning-modal-size"); @@ -631,6 +634,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat refineSummaryInFlightRef.current = false; setPlanningModelProvider(undefined); setPlanningModelId(undefined); + setPlanningThinkingLevel(""); setPlanningDepth("medium"); setCustomQuestionCount(""); currentSessionIdRef.current = null; @@ -901,8 +905,8 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat // Use streaming mode for real-time AI thinking display const modelOverride = planningModelProvider && planningModelId - ? { planningModelProvider, planningModelId } - : undefined; + ? { planningModelProvider, planningModelId, thinkingLevel: planningThinkingLevel || undefined } + : (planningThinkingLevel ? { thinkingLevel: planningThinkingLevel } : undefined); const parsedCustomQuestionCount = customQuestionCount.trim() ? Number.parseInt(customQuestionCount, 10) @@ -942,6 +946,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat planningDepth, planningModelId, planningModelProvider, + planningThinkingLevel, projectId, ]); @@ -1046,6 +1051,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat let savedPlan = ""; let savedProvider: string | undefined; let savedModelId: string | undefined; + let savedThinkingLevel: ThinkingLevel | "" = ""; try { const payload = session.inputPayload ? JSON.parse(session.inputPayload) : null; if (payload && typeof payload.initialPlan === "string") { @@ -1055,12 +1061,16 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat savedProvider = payload.modelProvider; savedModelId = payload.modelId; } + if (payload && THINKING_LEVELS.includes(payload.thinkingLevel as ThinkingLevel)) { + savedThinkingLevel = payload.thinkingLevel; + } } catch { // Fall through with empty text; the row will remain editable. } setInitialPlan(savedPlan); setPlanningModelProvider(savedProvider); setPlanningModelId(savedModelId); + setPlanningThinkingLevel(savedThinkingLevel); draftSessionIdRef.current = sessionId; lastSyncedDraftRef.current = savedPlan ? { @@ -1068,6 +1078,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat initialPlan: savedPlan.trim(), modelProvider: savedProvider, modelId: savedModelId, + thinkingLevel: savedThinkingLevel, } : null; setView({ type: "initial" }); @@ -1309,7 +1320,8 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat lastSyncedDraftRef.current?.sessionId === sessionId && lastSyncedDraftRef.current.initialPlan === trimmedPlan && lastSyncedDraftRef.current.modelProvider === planningModelProvider && - lastSyncedDraftRef.current.modelId === planningModelId; + lastSyncedDraftRef.current.modelId === planningModelId && + lastSyncedDraftRef.current.thinkingLevel === planningThinkingLevel; if (alreadySynced) { return; } @@ -1321,6 +1333,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat initialPlan: trimmedPlan, modelProvider: planningModelProvider && planningModelId ? planningModelProvider : undefined, modelId: planningModelProvider && planningModelId ? planningModelId : undefined, + thinkingLevel: planningThinkingLevel || undefined, }, projectId, ); @@ -1329,12 +1342,13 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat initialPlan: trimmedPlan, modelProvider: planningModelProvider, modelId: planningModelId, + thinkingLevel: planningThinkingLevel, }; } catch { // best-effort draft sync; avoid blocking typing UX on transient failures } }, - [planningModelId, planningModelProvider, projectId], + [planningModelId, planningModelProvider, planningThinkingLevel, projectId], ); useEffect(() => { @@ -1953,6 +1967,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat setStreamingOutput(""); setPlanningModelProvider(undefined); setPlanningModelId(undefined); + setPlanningThinkingLevel(""); setPlanningDepth("medium"); setCustomQuestionCount(""); currentSessionIdRef.current = null; @@ -2168,8 +2183,8 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat } const modelOverride = planningModelProvider && planningModelId - ? { planningModelProvider, planningModelId } - : undefined; + ? { planningModelProvider, planningModelId, thinkingLevel: planningThinkingLevel || undefined } + : (planningThinkingLevel ? { thinkingLevel: planningThinkingLevel } : undefined); // FNXC:PlanningMode 2026-07-01-00:00: mark in-flight synchronously so debounce fires during the round-trip don't spawn duplicate drafts. draftCreateInFlightRef.current = true; void createPlanningDraft(content, projectId, modelOverride) @@ -2261,7 +2276,12 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat onToggleFavorite={handleToggleFavoriteProvider} favoriteModels={favoriteModels} onToggleModelFavorite={handleToggleFavoriteModel} + showThinkingLevel + thinkingLevel={planningThinkingLevel} + onThinkingLevelChange={(level) => setPlanningThinkingLevel(THINKING_LEVELS.includes(level as ThinkingLevel) ? (level as ThinkingLevel) : "")} + defaultThinkingLevel="off" /> + {/* FNXC:Planning 2026-07-12-00:00: Planning Mode now renders the inline thinking selector only after drafts/sessions can persist the selected reasoning effort in inputPayload and restore it on reopen, matching chat semantics without a dangling control. */} {modelsError && (
{modelsError}{" "} diff --git a/packages/dashboard/src/__tests__/mission-interview.test.ts b/packages/dashboard/src/__tests__/mission-interview.test.ts index ce2253ea42..c899c4c566 100644 --- a/packages/dashboard/src/__tests__/mission-interview.test.ts +++ b/packages/dashboard/src/__tests__/mission-interview.test.ts @@ -1009,6 +1009,40 @@ describe("mission-interview module", () => { ); }); + it("persists, restores, and threads thinkingLevel without forcing an unset value", async () => { + const store = new MockAiSessionStore(); + setAiSessionStore(store as any); + mockCreateFnAgent.mockImplementation(async () => createMockAgent([createQuestionJson("q-thinking")])); + + const sessionId = await createMissionInterviewSession( + "192.168.1.44", + "Thinking mission", + "/tmp/project", + MOCK_TASK_STORE, + undefined, + "anthropic", + "claude-sonnet", + "high", + ); + await waitForCurrentQuestion(sessionId); + + expect(JSON.parse(store.get(sessionId)?.inputPayload ?? "{}").thinkingLevel).toBe("high"); + expect(mockCreateFnAgent).toHaveBeenLastCalledWith(expect.objectContaining({ defaultThinkingLevel: "high" })); + + __resetMissionInterviewState(); + setAiSessionStore(store as any); + expect(rehydrateFromStore(store as any)).toBe(1); + mockCreateFnAgent.mockClear(); + mockCreateFnAgent.mockImplementation(async () => createMockAgent([createQuestionJson("q-restored")])); + await submitMissionInterviewResponse(sessionId, { "q-thinking": "answer" }, "/tmp/project", MOCK_TASK_STORE); + expect(mockCreateFnAgent).toHaveBeenLastCalledWith(expect.objectContaining({ defaultThinkingLevel: "high" })); + + __resetMissionInterviewState(); + const unsetId = await createMissionInterviewSession("192.168.1.45", "Inherit mission", "/tmp/project", MOCK_TASK_STORE); + await waitForCurrentQuestion(unsetId); + expect(mockCreateFnAgent.mock.calls.at(-1)?.[0]).not.toHaveProperty("defaultThinkingLevel"); + }); + it("does not introduce unexpected model/provider override fields in createFnAgent", async () => { const mockAgent = createMockAgent([createQuestionJson()]); mockCreateFnAgent.mockImplementationOnce(async () => mockAgent); diff --git a/packages/dashboard/src/__tests__/routes-planning.test.ts b/packages/dashboard/src/__tests__/routes-planning.test.ts index 84729c7503..4dd53cf6ea 100644 --- a/packages/dashboard/src/__tests__/routes-planning.test.ts +++ b/packages/dashboard/src/__tests__/routes-planning.test.ts @@ -685,7 +685,20 @@ describe("Planning Mode Routes", () => { expect(res.body.error).toContain("customQuestionCount"); }); - it("accepts optional model params in request body", async () => { + it("rejects invalid thinkingLevel", async () => { + const res = await REQUEST( + buildApp(), + "POST", + "/api/planning/start-streaming", + JSON.stringify({ initialPlan: "Build a user auth system", thinkingLevel: "maximum" }), + { "Content-Type": "application/json" }, + ); + + expect(res.status).toBe(400); + expect(res.body.error).toContain("thinkingLevel"); + }); + + it("accepts optional model params and thinkingLevel in request body", async () => { const messages: Array<{ role: string; content: string }> = []; const mockAgent = { session: { @@ -719,6 +732,7 @@ describe("Planning Mode Routes", () => { initialPlan: "Build a user auth system", planningModelProvider: "google", planningModelId: "gemini-2.5-pro", + thinkingLevel: "high", }), { "Content-Type": "application/json" }, ); @@ -732,6 +746,7 @@ describe("Planning Mode Routes", () => { expect.objectContaining({ defaultProvider: "google", defaultModelId: "gemini-2.5-pro", + defaultThinkingLevel: "high", }), ); }); diff --git a/packages/dashboard/src/__tests__/session-persistence-roundtrip.test.ts b/packages/dashboard/src/__tests__/session-persistence-roundtrip.test.ts index 298d286f84..f68e14d3ad 100644 --- a/packages/dashboard/src/__tests__/session-persistence-roundtrip.test.ts +++ b/packages/dashboard/src/__tests__/session-persistence-roundtrip.test.ts @@ -22,10 +22,12 @@ import { __resetPlanningState, __setCreateFnAgent, cancelSession, + createDraftSession, createSession, getCurrentQuestion, rehydrateFromStore, setAiSessionStore as setPlanningAiSessionStore, + startExistingSession, submitResponse, } from "../planning.js"; import { @@ -198,6 +200,20 @@ describe("session persistence round-trip", () => { expect(JSON.parse(afterComplete?.result ?? "null")?.title).toBe("Planned"); }); + it("persists planning thinkingLevel across draft, fresh streaming, and start-existing agent construction", async () => { + __setCreateFnAgent(async () => createMockAgent([JSON.stringify({ type: "question", data: { id: "q-thinking", type: "text", question: "Scope?" } })])); + + const draft = await createDraftSession("127.0.0.41", "Draft plan", "/tmp/project", "anthropic", "claude", "high", undefined, { projectId: "proj" }); + expect(JSON.parse(aiSessionStore.get(draft.sessionId)?.inputPayload ?? "{}")).toMatchObject({ thinkingLevel: "high" }); + + aiSessionStore.updateDraft(draft.sessionId, { initialPlan: "Draft plan updated", modelProvider: "anthropic", modelId: "claude" }); + expect(JSON.parse(aiSessionStore.get(draft.sessionId)?.inputPayload ?? "{}").thinkingLevel).toBe("high"); + + await startExistingSession(draft.sessionId, "/tmp/project", taskStore, "anthropic", "claude"); + expect(JSON.parse(aiSessionStore.get(draft.sessionId)?.inputPayload ?? "{}").thinkingLevel).toBe("high"); + + }); + it("rehydrates awaiting deepening checkpoint sessions without exposing pending summary as complete", () => { const now = new Date().toISOString(); aiSessionStore.upsert({ diff --git a/packages/dashboard/src/ai-session-store.ts b/packages/dashboard/src/ai-session-store.ts index 2adb799f69..6fdf659c78 100644 --- a/packages/dashboard/src/ai-session-store.ts +++ b/packages/dashboard/src/ai-session-store.ts @@ -11,7 +11,7 @@ */ import { EventEmitter } from "node:events"; -import type { Database } from "@fusion/core"; +import { THINKING_LEVELS, type Database, type ThinkingLevel } from "@fusion/core"; import { createSessionDiagnostics } from "./ai-session-diagnostics.js"; // ── Types ─────────────────────────────────────────────────────────────── @@ -283,11 +283,12 @@ export class AiSessionStore extends EventEmitter { * Also persists an optional model override paired together (provider+id); * passing one without the other clears the persisted override so we never * end up with a half-configured selection that the start path would - * silently reject. + * silently reject. The optional thinkingLevel is independent of the model pair + * and is preserved when omitted so draft syncs do not erase reopen state. */ updateDraft( id: string, - draft: { initialPlan: string; modelProvider?: string; modelId?: string }, + draft: { initialPlan: string; modelProvider?: string; modelId?: string; thinkingLevel?: ThinkingLevel }, ): boolean { const now = new Date().toISOString(); const trimmedPlan = draft.initialPlan.trim(); @@ -301,18 +302,23 @@ export class AiSessionStore extends EventEmitter { // under a model the user just abandoned. const existing = this.get(id); let preservedSummarizedFor: string | undefined; + let preservedThinkingLevel: ThinkingLevel | undefined; if (existing?.inputPayload) { try { const prev = JSON.parse(existing.inputPayload) as { summarizedFor?: unknown; modelProvider?: unknown; modelId?: unknown; + thinkingLevel?: unknown; }; const prevProvider = typeof prev.modelProvider === "string" ? prev.modelProvider : undefined; const prevModelId = typeof prev.modelId === "string" ? prev.modelId : undefined; const newProvider = hasModelOverride ? draft.modelProvider : undefined; const newModelId = hasModelOverride ? draft.modelId : undefined; const modelUnchanged = prevProvider === newProvider && prevModelId === newModelId; + if (THINKING_LEVELS.includes(prev.thinkingLevel as ThinkingLevel)) { + preservedThinkingLevel = prev.thinkingLevel as ThinkingLevel; + } if ( typeof prev.summarizedFor === "string" && prev.summarizedFor === trimmedPlan @@ -328,6 +334,7 @@ export class AiSessionStore extends EventEmitter { const inputPayload = JSON.stringify({ initialPlan: trimmedPlan, ...(hasModelOverride ? { modelProvider: draft.modelProvider, modelId: draft.modelId } : {}), + ...((draft.thinkingLevel ?? preservedThinkingLevel) ? { thinkingLevel: draft.thinkingLevel ?? preservedThinkingLevel } : {}), ...(preservedSummarizedFor ? { summarizedFor: preservedSummarizedFor } : {}), }); const result = this.db diff --git a/packages/dashboard/src/mission-interview.ts b/packages/dashboard/src/mission-interview.ts index 19490e6217..ed61b920a0 100644 --- a/packages/dashboard/src/mission-interview.ts +++ b/packages/dashboard/src/mission-interview.ts @@ -15,8 +15,8 @@ * - Prompt override support for project-level customization */ -import type { PlanningQuestion, PromptOverrideMap, TaskStore } from "@fusion/core"; -import { resolvePrompt } from "@fusion/core"; +import type { PlanningQuestion, PromptOverrideMap, TaskStore, ThinkingLevel } from "@fusion/core"; +import { resolvePrompt, THINKING_LEVELS } from "@fusion/core"; import { randomUUID } from "node:crypto"; import { EventEmitter } from "node:events"; import type { AiSessionStore, AiSessionRow, AiSessionStatus, AiSessionSummary } from "./ai-session-store.js"; @@ -266,6 +266,11 @@ interface MissionInterviewSession { /** Model override for this interview session */ modelProvider?: string; modelId?: string; + /** + * FNXC:MissionInterview 2026-07-12-00:00: + * Mission interview sessions carry a validated per-session reasoning effort in inputPayload so reopened interviews can rebuild agents with the same thinking level without a SQLite schema change. + */ + thinkingLevel?: ThinkingLevel; /** * Captured at session creation so rehydrated sessions can rebuild their * agent without the caller threading context through every API. @@ -358,6 +363,7 @@ function persistMissionSession(session: MissionInterviewSession, status: "genera missionId: session.missionId, modelProvider: session.modelProvider, modelId: session.modelId, + thinkingLevel: session.thinkingLevel, }), conversationHistory: JSON.stringify(session.history), currentQuestion: session.currentQuestion ? JSON.stringify(session.currentQuestion) : null, @@ -384,11 +390,14 @@ function unpersistMissionSession(sessionId: string): void { } function buildMissionInterviewSessionFromRow(row: AiSessionRow): MissionInterviewSession { - const payload = safeParseJson<{ ip?: string; projectId?: string | null; missionId?: string; missionTitle?: string; modelProvider?: string; modelId?: string }>( + const payload = safeParseJson<{ ip?: string; projectId?: string | null; missionId?: string; missionTitle?: string; modelProvider?: string; modelId?: string; thinkingLevel?: string }>( row.inputPayload, {}, { throwOnError: true, fieldName: "inputPayload" }, ); + const thinkingLevel = THINKING_LEVELS.includes(payload.thinkingLevel as ThinkingLevel) + ? (payload.thinkingLevel as ThinkingLevel) + : undefined; const createdAt = new Date(row.createdAt); const updatedAt = new Date(row.updatedAt); @@ -425,6 +434,7 @@ function buildMissionInterviewSessionFromRow(row: AiSessionRow): MissionIntervie error: row.error ?? undefined, modelProvider: payload.modelProvider, modelId: payload.modelId, + thinkingLevel, createdAt, updatedAt, agent: undefined, @@ -894,6 +904,7 @@ export async function createMissionInterviewAgent( defaultModelId: session.modelId, } : {}), + ...(session.thinkingLevel ? { defaultThinkingLevel: session.thinkingLevel } : {}), onThinking: (delta: string) => { session.thinkingOutput += delta; persistMissionThinking(session.id, session.thinkingOutput); @@ -1217,9 +1228,17 @@ export async function createMissionInterviewSession( promptOverrides?: PromptOverrideMap, modelProvider?: string, modelId?: string, - projectId?: string | null, - pluginRunner?: SkillPluginRunner, + thinkingLevelOrProjectId?: ThinkingLevel | string | null, + projectIdOrPluginRunner?: string | null | SkillPluginRunner, + pluginRunnerMaybe?: 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; if (!checkRateLimit(ip)) { const resetTime = getRateLimitResetTime(ip); throw new RateLimitError( @@ -1241,6 +1260,7 @@ export async function createMissionInterviewSession( lastGeneratedThinking: "", modelProvider, modelId, + thinkingLevel, store, rootDir, pluginRunner, diff --git a/packages/dashboard/src/mission-routes.ts b/packages/dashboard/src/mission-routes.ts index 9fb41a3265..d1a0e2a9af 100644 --- a/packages/dashboard/src/mission-routes.ts +++ b/packages/dashboard/src/mission-routes.ts @@ -14,8 +14,8 @@ import { Router, type Request, type Response, type NextFunction } from "express"; import { AsyncLocalStorage } from "node:async_hooks"; -import { TaskStore, resolvePlanningSettingsModel, AgentStore } from "@fusion/core"; -import type { Goal } from "@fusion/core"; +import { TaskStore, resolvePlanningSettingsModel, AgentStore, THINKING_LEVELS } from "@fusion/core"; +import type { Goal, ThinkingLevel } from "@fusion/core"; import { listEligibleExecutorAgents } from "@fusion/engine"; import { getOrCreateProjectStore } from "./project-store-resolver.js"; import type { @@ -515,7 +515,7 @@ export function createMissionRouter( /** * POST /api/missions/interview/start * Start a mission interview session with AI agent streaming. - * Body: { missionTitle: string, modelProvider?: string, modelId?: string } + * Body: { missionTitle: string, modelProvider?: string, modelId?: string, thinkingLevel?: ThinkingLevel } * Returns: { sessionId: string } * * UTILITY PATH: Independent of task-lane saturation. @@ -523,7 +523,7 @@ export function createMissionRouter( router.post( "/interview/start", catchTypedHandler(async (req, res) => { - const { missionTitle, modelProvider, modelId } = req.body; + const { missionTitle, modelProvider, modelId, thinkingLevel } = req.body; if (!missionTitle || typeof missionTitle !== "string" || !missionTitle.trim()) { throw badRequest("missionTitle is required and must be a non-empty string"); @@ -542,6 +542,11 @@ export function createMissionRouter( throw badRequest("Both modelProvider and modelId must be provided together, or neither should be provided"); } + if (thinkingLevel !== undefined && !THINKING_LEVELS.includes(thinkingLevel as ThinkingLevel)) { + throw badRequest("thinkingLevel must be one of: " + THINKING_LEVELS.join(", ")); + } + const validatedThinkingLevel = thinkingLevel as ThinkingLevel | undefined; + try { const ip = req.ip || req.socket.remoteAddress || "unknown"; const { store: scopedStore, projectId } = await getProjectContext(req); @@ -564,6 +569,7 @@ export function createMissionRouter( settings.promptOverrides, resolvedProvider, resolvedModelId, + validatedThinkingLevel, projectId ?? null, pluginRunner, ); diff --git a/packages/dashboard/src/planning.ts b/packages/dashboard/src/planning.ts index bd0843d156..759db966a4 100644 --- a/packages/dashboard/src/planning.ts +++ b/packages/dashboard/src/planning.ts @@ -19,6 +19,7 @@ import type { TaskPriority, TaskStore, NtfyNotificationEvent, + ThinkingLevel, } from "@fusion/core"; import { DEFAULT_TASK_PRIORITY, @@ -27,6 +28,7 @@ import { PLANNING_DEEPEN_PROCEED_OPTION_ID, PLANNING_DEEPEN_PROCEED_RESPONSE_KEY, TASK_PRIORITIES, + THINKING_LEVELS, resolvePrompt, summarizeTitle, type PromptOverrideMap, @@ -62,9 +64,20 @@ type SkillPluginRunner = Parameters[3]; const PLANNING_BUILTIN_WEB_TOOLS = ["WebSearch", "WebFetch"] as const; type PlanningMcpServers = Awaited>["servers"]; +type PlanningSessionOptions = { + projectId?: string; + ntfyConfig?: PlanningNtfyConfig; + planningDepth?: PlanningDepth; + customQuestionCount?: number; + pluginRunner?: SkillPluginRunner; +}; // eslint-disable-next-line @typescript-eslint/no-explicit-any let createFnAgent: any = engineCreateFnAgent; +function isThinkingLevel(value: unknown): value is ThinkingLevel { + return THINKING_LEVELS.includes(value as ThinkingLevel); +} + async function resolvePlanningMcpServers(store: TaskStore): Promise { const resolved = await resolveMcpServersForStore(store); /* @@ -267,6 +280,9 @@ export const DRAFT_PLACEHOLDER_TITLE = "New planning session"; * picked at create time, and so summarizeDraftTitle calls hit that same * model rather than silently falling back to project defaults. * + * FNXC:Planning 2026-07-12-00:00: + * Planning drafts/sessions persist a validated per-session reasoning effort in inputPayload so draft reopen and start-existing rebuilds preserve the selected thinking level without a SQLite schema change. + * * `summarizedFor` records the exact `initialPlan` string the current * persisted title was summarized from. The start-existing path uses it to * skip re-summarizing when blur/close already produced a title for the @@ -276,6 +292,7 @@ export interface DraftInputPayload { initialPlan?: string; modelProvider?: string; modelId?: string; + thinkingLevel?: ThinkingLevel; summarizedFor?: string; pendingSummary?: PlanningSummary; } @@ -359,6 +376,8 @@ interface Session { /** Model override the user picked at draft-create time. Persisted in inputPayload so reopen restores it. */ draftModelProvider?: string; draftModelId?: string; + /** Per-session reasoning effort persisted with the draft/session and threaded into planning agents when set. */ + draftThinkingLevel?: ThinkingLevel; /** Plan text the current title was summarized from; lets startExistingSession skip a redundant re-summarize when blur/close already covered the final text. */ draftSummarizedFor?: string; ntfyConfig?: PlanningNtfyConfig; @@ -752,6 +771,7 @@ function persistSession(session: Session, status: "generating" | "awaiting_input initialPlan: session.initialPlan, ...(session.draftModelProvider ? { modelProvider: session.draftModelProvider } : {}), ...(session.draftModelId ? { modelId: session.draftModelId } : {}), + ...(session.draftThinkingLevel ? { thinkingLevel: session.draftThinkingLevel } : {}), ...(session.draftSummarizedFor ? { summarizedFor: session.draftSummarizedFor } : {}), ...(session.pendingSummary ? { pendingSummary: session.pendingSummary } : {}), }), @@ -793,6 +813,8 @@ function buildSessionFromRow(row: AiSessionRow): Session { { throwOnError: true, fieldName: "inputPayload" }, ); + const thinkingLevel = isThinkingLevel(payload.thinkingLevel) ? payload.thinkingLevel : undefined; + const createdAt = new Date(row.createdAt); const updatedAt = new Date(row.updatedAt); @@ -815,6 +837,7 @@ function buildSessionFromRow(row: AiSessionRow): Session { projectId: row.projectId ?? undefined, draftModelProvider: payload.modelProvider, draftModelId: payload.modelId, + draftThinkingLevel: thinkingLevel, draftSummarizedFor: payload.summarizedFor, history: safeParseJson( row.conversationHistory, @@ -1355,9 +1378,12 @@ export async function createDraftSession( _rootDir: string, modelProvider?: string, modelId?: string, - _promptOverrides?: PromptOverrideMap, - options?: { projectId?: string }, + thinkingLevelOrPromptOverrides?: ThinkingLevel | PromptOverrideMap, + _promptOverridesOrOptions?: PromptOverrideMap | { projectId?: string }, + optionsMaybe?: { projectId?: string }, ): Promise<{ sessionId: string; title: string }> { + const thinkingLevel = isThinkingLevel(thinkingLevelOrPromptOverrides) ? thinkingLevelOrPromptOverrides : undefined; + const options = (isThinkingLevel(thinkingLevelOrPromptOverrides) ? optionsMaybe : _promptOverridesOrOptions) as { projectId?: string } | undefined; if (!checkRateLimit(ip)) { const resetTime = getRateLimitResetTime(ip); throw new RateLimitError( @@ -1382,6 +1408,7 @@ export async function createDraftSession( projectId: options?.projectId, draftModelProvider: hasModelOverride ? modelProvider : undefined, draftModelId: hasModelOverride ? modelId : undefined, + draftThinkingLevel: thinkingLevel, history: [], thinkingOutput: "", lastGeneratedThinking: "", @@ -1472,9 +1499,15 @@ export async function startExistingSession( store: TaskStore, modelProvider?: string, modelId?: string, - promptOverrides?: PromptOverrideMap, - pluginRunner?: SkillPluginRunner, + thinkingLevelOrPromptOverrides?: ThinkingLevel | PromptOverrideMap, + promptOverridesOrPluginRunner?: PromptOverrideMap | SkillPluginRunner, + pluginRunnerMaybe?: SkillPluginRunner, ): Promise { + const thinkingLevel = isThinkingLevel(thinkingLevelOrPromptOverrides) ? thinkingLevelOrPromptOverrides : undefined; + const promptOverrides = isThinkingLevel(thinkingLevelOrPromptOverrides) + ? (promptOverridesOrPluginRunner as PromptOverrideMap | undefined) + : (thinkingLevelOrPromptOverrides as PromptOverrideMap | undefined); + const pluginRunner = (isThinkingLevel(thinkingLevelOrPromptOverrides) ? pluginRunnerMaybe : promptOverridesOrPluginRunner) as SkillPluginRunner | undefined; let session = sessions.get(sessionId); // Draft sessions aren't included in rehydrateFromStore (which only loads @@ -1509,6 +1542,7 @@ export async function startExistingSession( // summarized this exact text. let persistedProvider: string | undefined; let persistedModelId: string | undefined; + let persistedThinkingLevel: ThinkingLevel | undefined; let persistedSummarizedFor: string | undefined; let cameFromDraft = false; if (_aiSessionStore) { @@ -1521,6 +1555,7 @@ export async function startExistingSession( } persistedProvider = payload.modelProvider; persistedModelId = payload.modelId; + persistedThinkingLevel = isThinkingLevel(payload.thinkingLevel) ? payload.thinkingLevel : undefined; persistedSummarizedFor = payload.summarizedFor; } } @@ -1554,10 +1589,11 @@ export async function startExistingSession( } } + session.draftThinkingLevel = thinkingLevel ?? persistedThinkingLevel; persistSession(session, "generating"); planningStreamManager.registerInitialTurn(sessionId, () => { session.pluginRunner = pluginRunner; - initializeAgent(session, rootDir, store, modelProvider, modelId, promptOverrides, undefined, undefined, pluginRunner).catch((err) => { + initializeAgent(session, rootDir, store, modelProvider, modelId, session.draftThinkingLevel, promptOverrides, undefined, undefined, pluginRunner).catch((err) => { diagnostics.errorFromException("Failed to initialize agent for session", err, { sessionId, operation: "initialize-agent" }); persistSession(session, "error", err.message || "Failed to initialize AI agent"); planningStreamManager.broadcast(sessionId, { @@ -1587,15 +1623,15 @@ export async function createSessionWithAgent( store: TaskStore, modelProvider?: string, modelId?: string, - promptOverrides?: PromptOverrideMap, - options?: { - projectId?: string; - ntfyConfig?: PlanningNtfyConfig; - planningDepth?: PlanningDepth; - customQuestionCount?: number; - pluginRunner?: SkillPluginRunner; - }, + thinkingLevelOrPromptOverrides?: ThinkingLevel | PromptOverrideMap, + promptOverridesOrOptions?: PromptOverrideMap | PlanningSessionOptions, + optionsMaybe?: PlanningSessionOptions, ): Promise { + const thinkingLevel = isThinkingLevel(thinkingLevelOrPromptOverrides) ? thinkingLevelOrPromptOverrides : undefined; + const promptOverrides = isThinkingLevel(thinkingLevelOrPromptOverrides) + ? (promptOverridesOrOptions as PromptOverrideMap | undefined) + : (thinkingLevelOrPromptOverrides as PromptOverrideMap | undefined); + const options = (isThinkingLevel(thinkingLevelOrPromptOverrides) ? optionsMaybe : promptOverridesOrOptions) as PlanningSessionOptions | undefined; // Check rate limit if (!checkRateLimit(ip)) { const resetTime = getRateLimitResetTime(ip); @@ -1627,6 +1663,7 @@ export async function createSessionWithAgent( lastGeneratedThinking: "", createdAt: new Date(), updatedAt: new Date(), + draftThinkingLevel: thinkingLevel, pluginRunner: options?.pluginRunner, }; @@ -1640,6 +1677,7 @@ export async function createSessionWithAgent( store, modelProvider, modelId, + thinkingLevel, promptOverrides, options?.planningDepth, options?.customQuestionCount, @@ -1666,6 +1704,7 @@ async function initializeAgent( store: TaskStore, modelProvider?: string, modelId?: string, + thinkingLevel?: ThinkingLevel, promptOverrides?: PromptOverrideMap, planningDepth?: PlanningDepth, customQuestionCount?: number, @@ -1683,6 +1722,7 @@ async function initializeAgent( store, modelProvider, modelId, + thinkingLevel, promptOverrides, planningDepth, customQuestionCount, @@ -1739,6 +1779,7 @@ async function createPlanningAgent( store: TaskStore, modelProvider?: string, modelId?: string, + thinkingLevel?: ThinkingLevel, promptOverrides?: PromptOverrideMap, planningDepth?: PlanningDepth, customQuestionCount?: number, @@ -1780,6 +1821,7 @@ async function createPlanningAgent( defaultModelId: modelId, } : {}), + ...(thinkingLevel ? { defaultThinkingLevel: thinkingLevel } : {}), onThinking: (delta: string) => { markPlanningGenerationProgress(session.id, delta); session.thinkingOutput += delta; @@ -1847,7 +1889,7 @@ async function ensureSessionAgent( ); } - session.agent = await createPlanningAgent(session, effectiveRootDir, effectiveStore, undefined, undefined, promptOverrides, undefined, undefined, session.pluginRunner); + session.agent = await createPlanningAgent(session, effectiveRootDir, effectiveStore, undefined, undefined, session.draftThinkingLevel, promptOverrides, undefined, undefined, session.pluginRunner); if (historyForReplay.length === 0) { return; diff --git a/packages/dashboard/src/routes.ts b/packages/dashboard/src/routes.ts index 1f861297c6..496ce0c21b 100644 --- a/packages/dashboard/src/routes.ts +++ b/packages/dashboard/src/routes.ts @@ -13,7 +13,7 @@ import * as nodeFs from "node:fs"; import os from "node:os"; import v8 from "node:v8"; -import type { AnthropicProviderRegistration, TaskStore, ScheduleType, ActivityEventType, ModelPreset, RoutineTriggerType, McpServerDefinition } from "@fusion/core"; +import type { AnthropicProviderRegistration, TaskStore, ScheduleType, ActivityEventType, ModelPreset, RoutineTriggerType, McpServerDefinition, ThinkingLevel } from "@fusion/core"; import { type Task, type PiExtensionEntry, @@ -4305,7 +4305,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout /** * PATCH /api/ai-sessions/:id/draft * Keep planning draft title/text synchronized while editing. - * Body: { title: string, initialPlan: string } + * Body: { title: string, initialPlan: string, thinkingLevel?: ThinkingLevel } */ router.patch("/ai-sessions/:id/draft", (req, res) => { if (!aiSessionStore) { @@ -4337,7 +4337,12 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout const modelProvider = rawProvider && rawModelId ? rawProvider : undefined; const modelId = rawProvider && rawModelId ? rawModelId : undefined; - const updated = aiSessionStore.updateDraft(id, { initialPlan, modelProvider, modelId }); + const thinkingLevel = req.body?.thinkingLevel; + if (thinkingLevel !== undefined && !THINKING_LEVELS.includes(thinkingLevel as ThinkingLevel)) { + throw badRequest("thinkingLevel must be one of: " + THINKING_LEVELS.join(", ")); + } + + const updated = aiSessionStore.updateDraft(id, { initialPlan, modelProvider, modelId, thinkingLevel }); if (!updated) { throw notFound("Session not found"); } diff --git a/packages/dashboard/src/routes/register-planning-subtask-routes.ts b/packages/dashboard/src/routes/register-planning-subtask-routes.ts index b7e837447f..5e493e5be8 100644 --- a/packages/dashboard/src/routes/register-planning-subtask-routes.ts +++ b/packages/dashboard/src/routes/register-planning-subtask-routes.ts @@ -2,9 +2,11 @@ import { DEFAULT_TASK_PRIORITY, resolvePlanningSettingsModel, TASK_PRIORITIES, + THINKING_LEVELS, type PlanningSummary, type TaskPriority, type TaskStore, + type ThinkingLevel, } from "@fusion/core"; import { normalizePlanningSummaryPayload } from "../planning.js"; import { ApiError, badRequest, conflict, notFound, rateLimited } from "../api-error.js"; @@ -543,9 +545,13 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann } }); + /** + * POST /api/planning/create-draft + * Body: { initialPlan: string, planningModelProvider?: string, planningModelId?: string, thinkingLevel?: ThinkingLevel } + */ router.post("/planning/create-draft", async (req, res) => { try { - const { initialPlan, planningModelProvider, planningModelId } = req.body; + const { initialPlan, planningModelProvider, planningModelId, thinkingLevel } = req.body; if (!initialPlan || typeof initialPlan !== "string" || initialPlan.trim().length === 0) { throw badRequest("initialPlan is required and must be a string"); @@ -559,6 +565,11 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann throw badRequest("planningModelId must be a string when provided"); } + if (thinkingLevel !== undefined && !THINKING_LEVELS.includes(thinkingLevel as ThinkingLevel)) { + throw badRequest("thinkingLevel must be one of: " + THINKING_LEVELS.join(", ")); + } + const validatedThinkingLevel = thinkingLevel as ThinkingLevel | undefined; + const { store: scopedStore, projectId } = await getProjectContext(req); const settings = await scopedStore.getSettings(); const ip = req.ip || req.socket.remoteAddress || "unknown"; @@ -574,15 +585,26 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann resolvedPlanningSettings.modelId; const { createDraftSession } = await import("../planning.js"); - const draft = await createDraftSession( - ip, - initialPlan, - rootDir, - resolvedPlanningProvider, - resolvedPlanningModelId, - settings.promptOverrides, - { projectId }, - ); + const draft = validatedThinkingLevel + ? await createDraftSession( + ip, + initialPlan, + rootDir, + resolvedPlanningProvider, + resolvedPlanningModelId, + validatedThinkingLevel, + settings.promptOverrides, + { projectId }, + ) + : await createDraftSession( + ip, + initialPlan, + rootDir, + resolvedPlanningProvider, + resolvedPlanningModelId, + settings.promptOverrides, + { projectId }, + ); res.status(201).json(draft); } catch (err: unknown) { if (err instanceof ApiError) { @@ -598,7 +620,7 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann /** * POST /api/planning/start-streaming * Start a new planning session with AI agent streaming. - * Body: { initialPlan: string } + * Body: { initialPlan: string, planningModelProvider?: string, planningModelId?: string, thinkingLevel?: ThinkingLevel } * Returns: { sessionId: string } * * After receiving sessionId, connect to GET /api/planning/:sessionId/stream @@ -615,6 +637,7 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann planningDepth, customQuestionCount, existingSessionId, + thinkingLevel, } = req.body; if (!initialPlan || typeof initialPlan !== "string") { @@ -649,6 +672,11 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann throw badRequest("existingSessionId must be a string when provided"); } + if (thinkingLevel !== undefined && !THINKING_LEVELS.includes(thinkingLevel as ThinkingLevel)) { + throw badRequest("thinkingLevel must be one of: " + THINKING_LEVELS.join(", ")); + } + const validatedThinkingLevel = thinkingLevel as ThinkingLevel | undefined; + const { store: scopedStore, projectId } = await getProjectContext(req); const settings = await scopedStore.getSettings(); const ip = req.ip || req.socket.remoteAddress || "unknown"; @@ -683,6 +711,7 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann // chose; pass undefined to clear any half-set state otherwise. modelProvider: planningModelProvider && planningModelId ? planningModelProvider : undefined, modelId: planningModelProvider && planningModelId ? planningModelId : undefined, + thinkingLevel: validatedThinkingLevel, }); } catch (error) { planningLogger.warn( @@ -692,41 +721,67 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann } } const { startExistingSession } = await import("../planning.js"); - await startExistingSession( - existingSessionId, - rootDir, - scopedStore, - resolvedPlanningProvider, - resolvedPlanningModelId, - settings.promptOverrides, - ctx.options?.pluginRunner as SkillPluginRunner, - ); + if (validatedThinkingLevel) { + await startExistingSession( + existingSessionId, + rootDir, + scopedStore, + resolvedPlanningProvider, + resolvedPlanningModelId, + validatedThinkingLevel, + settings.promptOverrides, + ctx.options?.pluginRunner as SkillPluginRunner, + ); + } else { + await startExistingSession( + existingSessionId, + rootDir, + scopedStore, + resolvedPlanningProvider, + resolvedPlanningModelId, + settings.promptOverrides, + ctx.options?.pluginRunner as SkillPluginRunner, + ); + } res.status(201).json({ sessionId: existingSessionId }); return; } const { createSessionWithAgent, RateLimitError: _RateLimitError2 } = await import("../planning.js"); - const sessionId = await createSessionWithAgent( - ip, - initialPlan, - rootDir, - scopedStore, - resolvedPlanningProvider, - resolvedPlanningModelId, - settings.promptOverrides, - { - projectId, - ntfyConfig: { - enabled: settings.ntfyEnabled ?? false, - topic: settings.ntfyTopic, - dashboardHost: settings.ntfyDashboardHost, - events: settings.ntfyEvents, - }, - planningDepth, - customQuestionCount, - pluginRunner: ctx.options?.pluginRunner as SkillPluginRunner, + const planningOptions = { + projectId, + ntfyConfig: { + enabled: settings.ntfyEnabled ?? false, + topic: settings.ntfyTopic, + dashboardHost: settings.ntfyDashboardHost, + events: settings.ntfyEvents, }, - ); + planningDepth, + customQuestionCount, + pluginRunner: ctx.options?.pluginRunner as SkillPluginRunner, + }; + const sessionId = validatedThinkingLevel + ? await createSessionWithAgent( + ip, + initialPlan, + rootDir, + scopedStore, + resolvedPlanningProvider, + resolvedPlanningModelId, + validatedThinkingLevel, + settings.promptOverrides, + planningOptions, + ) + : await createSessionWithAgent( + ip, + initialPlan, + rootDir, + scopedStore, + resolvedPlanningProvider, + resolvedPlanningModelId, + settings.promptOverrides, + planningOptions, + ); res.status(201).json({ sessionId }); } catch (err: unknown) { if (err instanceof ApiError) {