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:
7
.changeset/fn-8534-planning-workflow-model.md
Normal file
7
.changeset/fn-8534-planning-workflow-model.md
Normal 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.
|
||||
@@ -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`
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -506,6 +506,7 @@ export {
|
||||
export {
|
||||
resolveEffectiveSettings,
|
||||
resolveEffectiveSettingsDetailed,
|
||||
resolveEffectiveSettingsDetailedById,
|
||||
resolveProjectWorkflowModelLaneBaseline,
|
||||
resolveEffectiveSettingsById,
|
||||
resolveOptionalReviewRevisionBudget,
|
||||
|
||||
@@ -570,6 +570,7 @@ export {
|
||||
export {
|
||||
resolveEffectiveSettings,
|
||||
resolveEffectiveSettingsDetailed,
|
||||
resolveEffectiveSettingsDetailedById,
|
||||
resolveProjectWorkflowModelLaneBaseline,
|
||||
resolveEffectiveSettingsById,
|
||||
resolveOptionalReviewRevisionBudget,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user