FN-8534: honor workflow planning-model precedence

Honor selected workflow planning models for Planning Mode sessions.

- Resolve selected workflow planning lanes against the project baseline.
- Route complete request and workflow pairs through canonical planning-model resolution.
- Cover new and draft planning sessions, test mode, and settings documentation.

Files changed:
 .changeset/fn-8534-planning-workflow-model.md      |  7 ++
 docs/settings-reference.md                         |  2 +
 .../__tests__/workflow-settings-resolver.test.ts   | 73 ++++++++++++++++
 packages/core/src/index.gate.ts                    |  1 +
 packages/core/src/index.ts                         |  1 +
 packages/core/src/workflow-settings-resolver.ts    | 80 +++++++++++-------
 .../src/__tests__/routes-planning.test.ts          | 97 ++++++++++++++++++++++
 .../src/routes/register-planning-subtask-routes.ts | 48 ++++++++---
 8 files changed, 267 insertions(+), 42 deletions(-)

Fusion-Task-Id: FN-8534

Fusion-Task-Lineage: e6b5b546-b9f1-49c5-b680-90f24e856458

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-23 08:53:06 -07:00
parent 575171c54c
commit d2e41e490f
8 changed files with 267 additions and 42 deletions

View File

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

View File

@@ -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`

View File

@@ -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({

View File

@@ -506,6 +506,7 @@ export {
export {
resolveEffectiveSettings,
resolveEffectiveSettingsDetailed,
resolveEffectiveSettingsDetailedById,
resolveProjectWorkflowModelLaneBaseline,
resolveEffectiveSettingsById,
resolveOptionalReviewRevisionBudget,

View File

@@ -570,6 +570,7 @@ export {
export {
resolveEffectiveSettings,
resolveEffectiveSettingsDetailed,
resolveEffectiveSettingsDetailedById,
resolveProjectWorkflowModelLaneBaseline,
resolveEffectiveSettingsById,
resolveOptionalReviewRevisionBudget,

View File

@@ -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<string, WorkflowIr>,
): Promise<EffectiveSettingsResult> {
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<string, unknown> = {};
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<string, unknown> = {};
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 {

View File

@@ -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<typeof vi.fn>).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(),

View File

@@ -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<string, unknown> = {};
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