diff --git a/.changeset/fn-8534-planning-workflow-model.md b/.changeset/fn-8534-planning-workflow-model.md new file mode 100644 index 0000000000..1507983716 --- /dev/null +++ b/.changeset/fn-8534-planning-workflow-model.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Honor selected workflow planning models in Planning Mode. +category: fix +dev: Planning Mode now composes selected-workflow model lanes before canonical model resolution. diff --git a/docs/settings-reference.md b/docs/settings-reference.md index 12cc212cfd..67166bf534 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -1072,6 +1072,8 @@ The three GPT-5.6 codenamed OpenAI Codex variants (`gpt-5.6-luna`, `gpt-5.6-sol` 6. Global `defaultProvider` + `defaultModelId` 7. Automatic provider/model resolution +Planning Mode uses this same complete-pair order for both a newly started session and an existing draft. When a workflow is selected in Planning Mode, its effective planning lane is loaded as the selected-workflow value; a complete request-level pair remains first, and incomplete/blank pairs are skipped rather than mixed with another level. Test mode still forces `mock` / `scripted` after resolution. + ### Executor model 1. Per-task `modelProvider` + `modelId` diff --git a/packages/core/src/__tests__/workflow-settings-resolver.test.ts b/packages/core/src/__tests__/workflow-settings-resolver.test.ts index cbeff38904..9f66cd3928 100644 --- a/packages/core/src/__tests__/workflow-settings-resolver.test.ts +++ b/packages/core/src/__tests__/workflow-settings-resolver.test.ts @@ -5,6 +5,7 @@ import type { WorkflowIr } from "../workflow-ir-types.js"; import { resolveEffectiveSettings, resolveEffectiveSettingsById, + resolveEffectiveSettingsDetailedById, resolveOptionalReviewRevisionBudget, resolveEffectivePlannerOversightLevel, type WorkflowSettingsResolverStore, @@ -441,6 +442,78 @@ describe("resolveEffectiveSettings (per-task)", () => { }); }); +describe("resolveEffectiveSettingsDetailedById", () => { + it("keeps the default workflow's stored planning lane as the project baseline", async () => { + const store = makeStore({ + defaultWorkflowId: "builtin:coding", + values: { + "builtin:coding::proj-9": { + planningProvider: "project-provider", + planningModelId: "project-model", + }, + }, + }); + + const result = await resolveEffectiveSettingsDetailedById(store, "builtin:coding", "proj-9"); + expect(result.effective).toMatchObject({ + planningProvider: "project-provider", + planningModelId: "project-model", + }); + expect(result.effective.selectedWorkflowModelLanes).toBeUndefined(); + }); + + it("keeps a distinct workflow pair separate from the project baseline", async () => { + const customWithPlanningLane: WorkflowIr = { + ...CUSTOM_NO_SETTINGS, + settings: BUILTIN_WORKFLOW_SETTINGS.filter((setting) => + ["planningProvider", "planningModelId"].includes(setting.id)), + }; + const store = makeStore({ + defaultWorkflowId: "builtin:coding", + defs: { "wf-custom": { ir: customWithPlanningLane } }, + values: { + "builtin:coding::proj-9": { + planningProvider: "project-provider", + planningModelId: "project-model", + }, + "wf-custom::proj-9": { + planningProvider: "workflow-provider", + planningModelId: "workflow-model", + }, + }, + }); + + const result = await resolveEffectiveSettingsDetailedById(store, "wf-custom", "proj-9"); + expect(result.effective).toMatchObject({ + planningProvider: "project-provider", + planningModelId: "project-model", + selectedWorkflowModelLanes: { + planningProvider: "workflow-provider", + planningModelId: "workflow-model", + }, + }); + }); + + it("keeps blank and incomplete selected workflow lanes inheritable", async () => { + const customWithPlanningLane: WorkflowIr = { + ...CUSTOM_NO_SETTINGS, + settings: BUILTIN_WORKFLOW_SETTINGS.filter((setting) => + ["planningProvider", "planningModelId"].includes(setting.id)), + }; + const store = makeStore({ + defaultWorkflowId: "builtin:coding", + defs: { "wf-custom": { ir: customWithPlanningLane } }, + values: { + "wf-custom::proj-9": { planningProvider: "workflow-provider" }, + }, + }); + + const result = await resolveEffectiveSettingsDetailedById(store, "wf-custom", "proj-9"); + expect(result.effective.selectedWorkflowModelLanes).toEqual({ planningProvider: "workflow-provider" }); + expect(result.effective.planningModelId).toBeUndefined(); + }); +}); + describe("resolveEffectiveSettingsById", () => { it("resolves declarations + stored values for an explicit (workflowId, projectId)", async () => { const store = makeStore({ diff --git a/packages/core/src/index.gate.ts b/packages/core/src/index.gate.ts index ea89d7cde3..03d790ea7a 100644 --- a/packages/core/src/index.gate.ts +++ b/packages/core/src/index.gate.ts @@ -506,6 +506,7 @@ export { export { resolveEffectiveSettings, resolveEffectiveSettingsDetailed, + resolveEffectiveSettingsDetailedById, resolveProjectWorkflowModelLaneBaseline, resolveEffectiveSettingsById, resolveOptionalReviewRevisionBudget, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e61f38fd3d..35a600f4bc 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -570,6 +570,7 @@ export { export { resolveEffectiveSettings, resolveEffectiveSettingsDetailed, + resolveEffectiveSettingsDetailedById, resolveProjectWorkflowModelLaneBaseline, resolveEffectiveSettingsById, resolveOptionalReviewRevisionBudget, diff --git a/packages/core/src/workflow-settings-resolver.ts b/packages/core/src/workflow-settings-resolver.ts index 6f44ed1734..fc4948badf 100644 --- a/packages/core/src/workflow-settings-resolver.ts +++ b/packages/core/src/workflow-settings-resolver.ts @@ -249,6 +249,55 @@ export async function resolveEffectiveSettingsById( return (await effectiveFrom(store, ir, workflowId, projectId)).effective; } +/** + * Resolve effective settings for an explicit workflow selection while retaining + * the project-wide model-lane baseline used by task-based resolution. + * + * FNXC:PlanningModelPrecedence 2026-07-22-14:00: + * Planning Mode names a workflow before a task exists, so it must compose that + * selection exactly as a task would: stored Project Models remain the project + * baseline and the selected workflow's lanes stay in `selectedWorkflowModelLanes`. + * This preserves complete-pair precedence in the canonical model resolver and + * prevents a provider from one settings tier combining with a model from another. + */ +export async function resolveEffectiveSettingsDetailedById( + store: WorkflowSettingsResolverStore, + workflowId: string, + projectId: string, + irCache?: Map, +): Promise { + const effectiveWorkflowId = workflowId || "builtin:coding"; + const ir = await resolveWorkflowIrById(store, effectiveWorkflowId, irCache); + const selected = await effectiveFrom(store, ir, effectiveWorkflowId, projectId); + + const projectBaselineWorkflowId = await projectWorkflowModelLaneWorkflowId(store); + if (projectBaselineWorkflowId === effectiveWorkflowId) return selected; + + const projectBaseline = await projectWorkflowModelLaneBaseline( + store, + projectId, + irCache, + projectBaselineWorkflowId, + ); + const effective = { ...selected.effective }; + const storedKeys = new Set(selected.storedKeys); + const selectedWorkflowModelLanes: Record = {}; + for (const id of PROJECT_WORKFLOW_MODEL_LANE_SETTING_IDS) { + if (Object.prototype.hasOwnProperty.call(selected.effective, id)) { + selectedWorkflowModelLanes[id] = selected.effective[id]; + delete effective[id]; + storedKeys.delete(id); + } + if (!projectBaseline.storedKeys.has(id)) continue; + effective[id] = projectBaseline.effective[id]; + storedKeys.add(id); + } + if (Object.keys(selectedWorkflowModelLanes).length > 0) { + effective.selectedWorkflowModelLanes = selectedWorkflowModelLanes; + } + return { effective, storedKeys }; +} + /** The minimal task identity the per-task resolver reads. Task carries no * projectId field — the project key comes from the store. */ export interface EffectiveSettingsTaskRef { @@ -293,42 +342,15 @@ export async function resolveEffectiveSettingsDetailed( workflowId = undefined; } const effectiveWorkflowId = workflowId || "builtin:coding"; - const ir = await resolveWorkflowIrForTask(store, task.id, irCache); let projectId: string; try { projectId = store.getWorkflowSettingsProjectId(); } catch { // Degrade to declaration defaults (empty stored map) on identity failure. - // Keep the resolved workflowId so builtin graphs still pick up the catalog fallback. + const ir = await resolveWorkflowIrForTask(store, task.id, irCache); return effectiveFrom(store, ir, effectiveWorkflowId, ""); } - const selected = await effectiveFrom(store, ir, effectiveWorkflowId, projectId); - - const projectBaselineWorkflowId = await projectWorkflowModelLaneWorkflowId(store); - if (projectBaselineWorkflowId === effectiveWorkflowId) return selected; - const projectBaseline = await projectWorkflowModelLaneBaseline( - store, - projectId, - irCache, - projectBaselineWorkflowId, - ); - const effective = { ...selected.effective }; - const storedKeys = new Set(selected.storedKeys); - const selectedWorkflowModelLanes: Record = {}; - for (const id of PROJECT_WORKFLOW_MODEL_LANE_SETTING_IDS) { - if (Object.prototype.hasOwnProperty.call(selected.effective, id)) { - selectedWorkflowModelLanes[id] = selected.effective[id]; - delete effective[id]; - storedKeys.delete(id); - } - if (!projectBaseline.storedKeys.has(id)) continue; - effective[id] = projectBaseline.effective[id]; - storedKeys.add(id); - } - if (Object.keys(selectedWorkflowModelLanes).length > 0) { - effective.selectedWorkflowModelLanes = selectedWorkflowModelLanes; - } - return { effective, storedKeys }; + return resolveEffectiveSettingsDetailedById(store, effectiveWorkflowId, projectId, irCache); } function isPlannerOversightLevel(value: unknown): value is PlannerOversightLevel { diff --git a/packages/dashboard/src/__tests__/routes-planning.test.ts b/packages/dashboard/src/__tests__/routes-planning.test.ts index bc32ee5a3e..185f38faba 100644 --- a/packages/dashboard/src/__tests__/routes-planning.test.ts +++ b/packages/dashboard/src/__tests__/routes-planning.test.ts @@ -1204,6 +1204,103 @@ describe("Planning Mode Routes", () => { }); }); + it("uses a selected workflow planning pair for new and existing draft starts", async () => { + const workflowId = "wf-planning-lane"; + const workflowIr = { + version: "v2", + name: "Planning lane workflow", + columns: [{ id: "todo", name: "Todo", traits: [] }], + nodes: [{ id: "start", kind: "start" }, { id: "end", kind: "end" }], + edges: [{ from: "start", to: "end" }], + settings: [ + { id: "planningProvider", name: "Planning provider", type: "string" }, + { id: "planningModelId", name: "Planning model", type: "string" }, + ], + }; + store = createMockStore({ + getSettings: vi.fn().mockResolvedValue({}), + getDefaultWorkflowId: vi.fn().mockResolvedValue("builtin:coding"), + getWorkflowDefinition: vi.fn(async (id: string) => id === workflowId ? { ir: workflowIr } : undefined), + getWorkflowSettingValues: vi.fn((id: string) => id === workflowId + ? { planningProvider: "workflow-provider", planningModelId: "workflow-model" } + : {}), + getWorkflowSettingsProjectId: vi.fn(() => "default"), + }); + const createFnAgentSpy = vi.fn(async () => ({ + session: { state: { messages: [] }, prompt: vi.fn(), dispose: vi.fn() }, + })); + __setCreateFnAgent(createFnAgentSpy as any); + + const newSession = await REQUEST( + buildApp(), + "POST", + "/api/planning/start-streaming", + JSON.stringify({ initialPlan: "New workflow planning session", workflowId }), + { "Content-Type": "application/json" }, + ); + expect(newSession.status).toBe(201); + + const draft = await REQUEST( + buildApp(), + "POST", + "/api/planning/create-draft", + JSON.stringify({ initialPlan: "Existing workflow planning session" }), + { "Content-Type": "application/json" }, + ); + const existingSession = await REQUEST( + buildApp(), + "POST", + "/api/planning/start-streaming", + JSON.stringify({ + initialPlan: "Existing workflow planning session", + existingSessionId: draft.body.sessionId, + workflowId, + }), + { "Content-Type": "application/json" }, + ); + expect(existingSession.status).toBe(201); + + await connectPlanningStreamUntilComplete(newSession.body.sessionId); + await connectPlanningStreamUntilComplete(existingSession.body.sessionId); + await vi.waitFor(() => { + expect(createFnAgentSpy).toHaveBeenCalledTimes(2); + }); + for (const [options] of createFnAgentSpy.mock.calls) { + expect(options).toEqual(expect.objectContaining({ + defaultProvider: "workflow-provider", + defaultModelId: "workflow-model", + })); + } + }); + + it("keeps test mode forced to mock despite a complete request override", async () => { + const createFnAgentSpy = vi.fn(async () => ({ + session: { state: { messages: [] }, prompt: vi.fn(), dispose: vi.fn() }, + })); + __setCreateFnAgent(createFnAgentSpy as any); + (store.getSettings as ReturnType).mockResolvedValueOnce({ testMode: true }); + + const res = await REQUEST( + buildApp(), + "POST", + "/api/planning/start-streaming", + JSON.stringify({ + initialPlan: "Test-mode workflow planning session", + planningModelProvider: "operator-provider", + planningModelId: "operator-model", + }), + { "Content-Type": "application/json" }, + ); + expect(res.status).toBe(201); + await connectPlanningStreamUntilComplete(res.body.sessionId); + await vi.waitFor(() => { + expect(createFnAgentSpy).toHaveBeenCalledWith(expect.objectContaining({ + defaultProvider: "mock", + defaultModelId: "scripted", + })); + }); + }); + it("rejects partial request override (provider only, no modelId)", async () => { const res = await REQUEST( buildApp(), diff --git a/packages/dashboard/src/routes/register-planning-subtask-routes.ts b/packages/dashboard/src/routes/register-planning-subtask-routes.ts index e9454bc535..4fe9c3997c 100644 --- a/packages/dashboard/src/routes/register-planning-subtask-routes.ts +++ b/packages/dashboard/src/routes/register-planning-subtask-routes.ts @@ -1,6 +1,7 @@ import { DEFAULT_TASK_PRIORITY, formatPlanningPlanMd, + resolveEffectiveSettingsDetailedById, resolvePlanningSettingsModel, TASK_PRIORITIES, THINKING_LEVELS, @@ -692,19 +693,40 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann const runtime = planningRuntime(settings); runtime.clarificationEnabled = resolvedClarificationEnabled; - // Resolve planning model using canonical lane hierarchy: - // 1. Request body planning override - // 2. Project/global planning lane - // 3. Project default override - // 4. Global default - const resolvedPlanningSettings = resolvePlanningSettingsModel(settings); - const resolvedPlanningProvider = - (planningModelProvider && planningModelId ? planningModelProvider : undefined) || - resolvedPlanningSettings.provider; - - const resolvedPlanningModelId = - (planningModelProvider && planningModelId ? planningModelId : undefined) || - resolvedPlanningSettings.modelId; + /* + * FNXC:PlanningModelPrecedence 2026-07-22-14:15: + * A Planning Mode workflow exists before its task, so load its effective + * settings explicitly and retain selected lanes as a lower-precedence + * overlay. One canonical resolver receives the complete request pair, + * which keeps new and persisted-draft starts atomic and preserves test-mode + * forcing instead of allowing the request branch to bypass it. + */ + const selectedWorkflowId = workflowId as string | undefined; + let workflowSettings: Record = {}; + if (selectedWorkflowId) { + try { + const workflowSettingsProjectId = projectId ?? scopedStore.getWorkflowSettingsProjectId(); + workflowSettings = (await resolveEffectiveSettingsDetailedById( + scopedStore, + selectedWorkflowId, + workflowSettingsProjectId, + )).effective; + } catch { + // The route's established fail-soft settings behavior falls back to + // project/global values when workflow lookup cannot be completed. + workflowSettings = {}; + } + } + const hasExplicitPlanningPair = Boolean(planningModelProvider && planningModelId); + const resolvedPlanningSettings = resolvePlanningSettingsModel({ + ...settings, + ...workflowSettings, + ...(hasExplicitPlanningPair + ? { planningProvider: planningModelProvider, planningModelId } + : {}), + }); + const resolvedPlanningProvider = resolvedPlanningSettings.provider; + const resolvedPlanningModelId = resolvedPlanningSettings.modelId; if (existingSessionId) { // Defeat the start-before-debounced-sync race: the textarea contents