FN-7123: use workflow settings for task models
Task model displays now resolve task-scoped workflow settings consistently with execution. - Add a shared workflow settings overlay helper for stored workflow values and declaration defaults. - Expose task effective settings through the dashboard API and consume them in TaskDetail model surfaces. - Cover core overlay behavior, route output, and Workflow tab model rendering with regression tests. - Document workflow model lane precedence and add a published package changeset. Files changed: .changeset/fn-7123-workflow-tab-project-model.md | 7 ++ docs/settings-reference.md | 14 ++- .../__tests__/effective-settings-overlay.test.ts | 70 ++++++++++++++ packages/core/src/effective-settings-overlay.ts | 27 ++++++ packages/core/src/index.ts | 4 + packages/dashboard/app/api/legacy.ts | 5 + .../dashboard/app/components/TaskDetailModal.tsx | 13 ++- ...skDetailModal.models-progress-workflow.test.tsx | 46 +++++++++ .../__tests__/TaskDetailModal.test-helpers.ts | 1 + .../__tests__/WorkflowResultsTab.test.tsx | 33 +++++++ packages/dashboard/app/test/mockApi.ts | 9 ++ .../task-effective-settings-route.test.ts | 107 +++++++++++++++++++++ .../src/routes/register-task-workflow-routes.ts | 21 ++++ packages/engine/src/effective-settings.ts | 25 ++--- 14 files changed, 355 insertions(+), 27 deletions(-) Fusion-Task-Id: FN-7123 Fusion-Task-Lineage: 5fa9d338-c886-44aa-bdba-0d361b3602d5 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-7123-workflow-tab-project-model.md
Normal file
7
.changeset/fn-7123-workflow-tab-project-model.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: The task Workflow tab now shows the configured project Executor/Reviewer/Planning model instead of "Default".
|
||||
category: fix
|
||||
dev: Task-detail model display now overlays the task's effective workflow setting values (where the moved per-phase model lanes live) onto getSettingsFast() via a shared core applyWorkflowSettingsOverlay helper and a new GET /api/tasks/:id/effective-settings endpoint. Engine mergeEffectiveSettings reuses the same helper unchanged. FN-7123.
|
||||
@@ -266,11 +266,15 @@ Actions. It has two tabs:
|
||||
controls. Edits batch and commit through a single **Save** in the Values tab.
|
||||
|
||||
**How values resolve.** The engine resolves *effective settings* per task as
|
||||
`stored value ?? declaration default`. A built-in workflow with no stored value
|
||||
falls back to the declaration default, which is byte-equal to the legacy project
|
||||
default — so an untuned project behaves exactly as before. Switching a project to a
|
||||
**new** custom workflow starts that workflow from its own declaration defaults, not
|
||||
the project's prior customized values.
|
||||
`stored value ?? declaration default`. The task-detail Workflow, Chat, and Agent
|
||||
Log model displays use the same per-task effective workflow values, so configured
|
||||
Plan/Triage, Executor, Reviewer, and fallback lanes match what task execution
|
||||
will use instead of falling back to the ambient project settings response. A
|
||||
built-in workflow with no stored value falls back to the declaration default,
|
||||
which is byte-equal to the legacy project default — so an untuned project behaves
|
||||
exactly as before. Switching a project to a **new** custom workflow starts that
|
||||
workflow from its own declaration defaults, not the project's prior customized
|
||||
values.
|
||||
|
||||
**Built-in prompt overrides.** Built-in workflow prompt/gate node text has a similar project-scoped persistence model, but it is separate from workflow settings: prompt overrides are stored per `(workflowId, nodeId, projectId)` and resolve as `stored prompt ?? shipped prompt`. Resetting a prompt deletes the stored node override and restores the built-in IR text; graph structure and setting declarations remain read-only for built-ins. See [Workflow Steps → Overriding built-in workflow prompts](./workflow-steps.md#overriding-built-in-workflow-prompts).
|
||||
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { Settings } from "../types.js";
|
||||
import { applyWorkflowSettingsOverlay } from "../effective-settings-overlay.js";
|
||||
|
||||
describe("applyWorkflowSettingsOverlay", () => {
|
||||
it("applies the two-tier workflow settings overlay without mutating base settings", () => {
|
||||
const base = {
|
||||
executionProvider: "base-executor",
|
||||
executionModelId: "base-executor-model",
|
||||
validatorProvider: "base-validator",
|
||||
planningProvider: "base-planning",
|
||||
workflowStepTimeoutMs: 10_000,
|
||||
} as Partial<Settings>;
|
||||
|
||||
const merged = applyWorkflowSettingsOverlay(base, {
|
||||
effective: {
|
||||
executionProvider: "workflow-executor",
|
||||
executionModelId: "workflow-executor-model",
|
||||
validatorProvider: "workflow-validator",
|
||||
validatorModelId: "workflow-validator-model",
|
||||
planningProvider: "workflow-planner",
|
||||
planningModelId: "workflow-planner-model",
|
||||
planningFallbackProvider: "workflow-planner-fallback",
|
||||
planningFallbackModelId: "workflow-planner-fallback-model",
|
||||
validatorFallbackProvider: "workflow-validator-fallback",
|
||||
validatorFallbackModelId: "workflow-validator-fallback-model",
|
||||
workflowStepTimeoutMs: 360_000,
|
||||
runStepsInNewSessions: false,
|
||||
maxParallelSteps: undefined,
|
||||
},
|
||||
storedKeys: new Set([
|
||||
"executionProvider",
|
||||
"executionModelId",
|
||||
"validatorProvider",
|
||||
"validatorModelId",
|
||||
"planningProvider",
|
||||
"planningModelId",
|
||||
"planningFallbackProvider",
|
||||
"planningFallbackModelId",
|
||||
"validatorFallbackProvider",
|
||||
"validatorFallbackModelId",
|
||||
]),
|
||||
});
|
||||
|
||||
expect(merged).not.toBe(base);
|
||||
expect(base).toEqual({
|
||||
executionProvider: "base-executor",
|
||||
executionModelId: "base-executor-model",
|
||||
validatorProvider: "base-validator",
|
||||
planningProvider: "base-planning",
|
||||
workflowStepTimeoutMs: 10_000,
|
||||
});
|
||||
expect(merged).toMatchObject({
|
||||
executionProvider: "workflow-executor",
|
||||
executionModelId: "workflow-executor-model",
|
||||
validatorProvider: "workflow-validator",
|
||||
validatorModelId: "workflow-validator-model",
|
||||
planningProvider: "workflow-planner",
|
||||
planningModelId: "workflow-planner-model",
|
||||
planningFallbackProvider: "workflow-planner-fallback",
|
||||
planningFallbackModelId: "workflow-planner-fallback-model",
|
||||
validatorFallbackProvider: "workflow-validator-fallback",
|
||||
validatorFallbackModelId: "workflow-validator-fallback-model",
|
||||
workflowStepTimeoutMs: 10_000,
|
||||
runStepsInNewSessions: false,
|
||||
});
|
||||
expect("maxParallelSteps" in merged).toBe(false);
|
||||
});
|
||||
});
|
||||
27
packages/core/src/effective-settings-overlay.ts
Normal file
27
packages/core/src/effective-settings-overlay.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { Settings } from "./types.js";
|
||||
|
||||
export interface WorkflowSettingsOverlayInput {
|
||||
effective: Record<string, unknown>;
|
||||
storedKeys: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* FNXC:ModelResolution 2026-06-27-10:52:
|
||||
* Per-task workflow setting values are where the moved model lanes now live, so the engine execution path and dashboard task-detail display must share one overlay rule. Stored workflow values override base settings while declaration defaults only fill missing base keys, ensuring the Workflow tab shows the same model the engine runs for FN-7123.
|
||||
*/
|
||||
export function applyWorkflowSettingsOverlay<T extends Partial<Settings>>(
|
||||
base: T,
|
||||
detailed: WorkflowSettingsOverlayInput,
|
||||
): T {
|
||||
const merged: Record<string, unknown> = { ...base };
|
||||
for (const key of Object.keys(detailed.effective)) {
|
||||
const value = detailed.effective[key];
|
||||
if (value === undefined) continue;
|
||||
if (detailed.storedKeys.has(key)) {
|
||||
merged[key] = value;
|
||||
} else if (merged[key] === undefined) {
|
||||
merged[key] = value;
|
||||
}
|
||||
}
|
||||
return merged as T;
|
||||
}
|
||||
@@ -395,6 +395,10 @@ export {
|
||||
type EffectiveSettingsResult,
|
||||
type EffectiveSettingsTaskRef,
|
||||
} from "./workflow-settings-resolver.js";
|
||||
export {
|
||||
applyWorkflowSettingsOverlay,
|
||||
type WorkflowSettingsOverlayInput,
|
||||
} from "./effective-settings-overlay.js";
|
||||
|
||||
// ── Engine wiring (set by @fusion/engine at module load) ────────────
|
||||
export {
|
||||
|
||||
@@ -844,6 +844,11 @@ export function fetchSettings(projectId?: string, options?: FetchOptions): Promi
|
||||
return dedupe(path, () => api<Settings>(path), options);
|
||||
}
|
||||
|
||||
export function fetchTaskEffectiveSettings(taskId: string, projectId?: string, options?: FetchOptions): Promise<Settings> {
|
||||
const path = withProjectId(`/tasks/${taskId}/effective-settings`, projectId);
|
||||
return dedupe(path, () => api<Settings>(path), options);
|
||||
}
|
||||
|
||||
export function updateSettings(settings: Partial<Settings>, projectId?: string): Promise<Settings> {
|
||||
return api<Settings>(withProjectId("/settings", projectId), {
|
||||
method: "PUT",
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
} from "@fusion/core";
|
||||
import { isNearDuplicateCanonicalInactive } from "../../../core/src/near-duplicate-canonical";
|
||||
import { resolveEffectiveAutoMerge } from "../../../core/src/task-merge";
|
||||
import { uploadAttachment, deleteAttachment, updateTask, repairOverlapBlocker, pauseTask, unpauseTask, fetchTaskDetail, fetchSettings, fetchGlobalSettings, requestSpecRevision, rebuildTaskSpec, approvePlan, rejectPlan, refineTask, fetchWorkflowResults, assignTask, fetchAgents, fetchAgent, refreshPrStatus, fetchBoardWorkflows, updateTaskCustomFields, summarizeTitle, api } from "../api";
|
||||
import { uploadAttachment, deleteAttachment, updateTask, repairOverlapBlocker, pauseTask, unpauseTask, fetchTaskDetail, fetchSettings, fetchTaskEffectiveSettings, fetchGlobalSettings, requestSpecRevision, rebuildTaskSpec, approvePlan, rejectPlan, refineTask, fetchWorkflowResults, assignTask, fetchAgents, fetchAgent, refreshPrStatus, fetchBoardWorkflows, updateTaskCustomFields, summarizeTitle, api } from "../api";
|
||||
import type { WorkflowFieldDefinition, CustomFieldRejection } from "../api";
|
||||
import { ApiRequestError } from "../api";
|
||||
import { TaskFieldsSection } from "./TaskFieldsSection";
|
||||
@@ -978,10 +978,15 @@ export function TaskDetailContent({
|
||||
}
|
||||
}, [githubTrackingEnabledDraft, workingTask.githubTracking?.enabled]);
|
||||
|
||||
// Load merged settings for effective model resolution
|
||||
// Load task-scoped settings for effective model resolution.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
fetchSettings(projectId)
|
||||
/*
|
||||
FNXC:ModelResolution 2026-06-27-10:52:
|
||||
Task-detail model displays are task-scoped because project model lanes moved into workflow setting values. Fetch the effective settings for the selected task so Workflow, Chat, Agent Log, and Model editor surfaces resolve the same Executor/Reviewer/Planning models the engine uses.
|
||||
*/
|
||||
fetchTaskEffectiveSettings(task.id, projectId)
|
||||
.catch(() => fetchSettings(projectId))
|
||||
.then((s) => {
|
||||
if (!cancelled) setSettings(s);
|
||||
})
|
||||
@@ -996,7 +1001,7 @@ export function TaskDetailContent({
|
||||
if (!cancelled) setGlobalSettings(null);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [projectId]);
|
||||
}, [projectId, task.id]);
|
||||
|
||||
// Load workflow results when workflow tab is active
|
||||
useEffect(() => {
|
||||
|
||||
@@ -116,6 +116,52 @@ describe("TaskDetailModal", () => {
|
||||
return container.querySelector("[data-testid='agent-log-model-header']") as HTMLElement;
|
||||
}
|
||||
|
||||
it("uses task effective settings success path for Agent Log model display", async () => {
|
||||
const { fetchTaskEffectiveSettings, fetchSettings } = await import("../../api");
|
||||
const { useAgentLogs } = await import("../../hooks/useAgentLogs");
|
||||
|
||||
vi.mocked(fetchTaskEffectiveSettings).mockResolvedValueOnce({
|
||||
modelPresets: [],
|
||||
autoSelectModelPreset: false,
|
||||
defaultPresetBySize: {},
|
||||
executionProvider: "overlay-executor",
|
||||
executionModelId: "overlay-executor-model",
|
||||
validatorProvider: "overlay-reviewer",
|
||||
validatorModelId: "overlay-reviewer-model",
|
||||
planningProvider: "overlay-planner",
|
||||
planningModelId: "overlay-planner-model",
|
||||
} as any);
|
||||
vi.mocked(useAgentLogs).mockReturnValue({
|
||||
entries: [mockLogEntry],
|
||||
loading: false,
|
||||
clear: vi.fn(),
|
||||
loadMore: vi.fn(async () => {}),
|
||||
hasMore: false,
|
||||
total: null,
|
||||
loadingMore: false,
|
||||
});
|
||||
|
||||
const { container } = render(
|
||||
<TaskDetailModal
|
||||
initialTab="definition"
|
||||
task={makeTask({ prompt: "# Hello\n\nContent" })}
|
||||
onClose={noop}
|
||||
onMoveTask={noopMove}
|
||||
onDeleteTask={noopDelete}
|
||||
onMergeTask={noopMerge}
|
||||
onOpenDetail={noopOpenDetail}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
const header = await openAgentLogAndExpandModelDetails(container);
|
||||
await waitFor(() => expect(header.textContent).toContain("overlay-executor/overlay-executor-model"));
|
||||
expect(header.textContent).toContain("overlay-reviewer/overlay-reviewer-model");
|
||||
expect(header.textContent).toContain("overlay-planner/overlay-planner-model");
|
||||
expect(header.textContent).not.toContain("fallback-provider/fallback-model");
|
||||
expect(fetchSettings).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows resolved executor from settings when task has no explicit executor override", async () => {
|
||||
const { container } = await setupModelTest({
|
||||
defaultProvider: "anthropic",
|
||||
|
||||
@@ -110,6 +110,7 @@ vi.mock("lucide-react", () => ({
|
||||
ClipboardCheck: () => null,
|
||||
ListChecks: () => null,
|
||||
Code2: () => null,
|
||||
Cpu: () => null,
|
||||
Bell: () => null,
|
||||
}));
|
||||
|
||||
|
||||
@@ -586,6 +586,39 @@ describe("WorkflowResultsTab", () => {
|
||||
expect(screen.getByTestId("workflow-model-setting-reviewer")).not.toHaveTextContent("configured-reviewer/configured-reviewer-model");
|
||||
});
|
||||
|
||||
it("shows workflow-overlaid project model lanes when the task has no explicit overrides", async () => {
|
||||
const taskWithoutOverrides = {
|
||||
...baseTask,
|
||||
modelProvider: null,
|
||||
modelId: null,
|
||||
validatorModelProvider: null,
|
||||
validatorModelId: null,
|
||||
planningModelProvider: null,
|
||||
planningModelId: null,
|
||||
} as Task;
|
||||
const workflowOverlaidSettings = {
|
||||
...mockSettings,
|
||||
executionProvider: "workflow-executor",
|
||||
executionModelId: "workflow-executor-model",
|
||||
validatorProvider: "workflow-reviewer",
|
||||
validatorModelId: "workflow-reviewer-model",
|
||||
planningProvider: "workflow-planner",
|
||||
planningModelId: "workflow-planner-model",
|
||||
} as Settings;
|
||||
|
||||
render(<WorkflowResultsTab taskId="FN-001" task={taskWithoutOverrides} settings={workflowOverlaidSettings} results={mockResults} />);
|
||||
|
||||
await screen.findByTestId("workflow-state-summary-name");
|
||||
fireEvent.click(screen.getByTestId("workflow-model-settings-toggle"));
|
||||
|
||||
await waitFor(() => expect(screen.getByTestId("workflow-model-setting-executor")).toHaveTextContent("workflow-executor/workflow-executor-model"));
|
||||
expect(screen.getByTestId("workflow-model-setting-reviewer")).toHaveTextContent("workflow-reviewer/workflow-reviewer-model");
|
||||
expect(screen.getByTestId("workflow-model-setting-planning")).toHaveTextContent("workflow-planner/workflow-planner-model");
|
||||
expect(screen.getByTestId("workflow-model-setting-executor")).not.toHaveTextContent("Default");
|
||||
expect(screen.getByTestId("workflow-model-setting-reviewer")).not.toHaveTextContent("Default");
|
||||
expect(screen.getByTestId("workflow-model-setting-planning")).not.toHaveTextContent("Default");
|
||||
});
|
||||
|
||||
it("shows effective model settings and default fallbacks", async () => {
|
||||
const { rerender } = render(
|
||||
<WorkflowResultsTab taskId="FN-001" task={baseTask} settings={mockSettings} results={mockResults} />,
|
||||
|
||||
@@ -27,6 +27,7 @@ function getFallback(name: string): AnyFn {
|
||||
export const dashboardApiMocks: Record<string, AnyFn> = {
|
||||
fetchTasks: vi.fn(async () => []),
|
||||
fetchSettings: vi.fn(async () => ({})),
|
||||
fetchTaskEffectiveSettings: vi.fn().mockRejectedValue(new Error("fetchTaskEffectiveSettings: use fetchSettings mock")),
|
||||
updateSettings: vi.fn(async () => ({})),
|
||||
fetchGlobalSettings: vi.fn(async () => ({})),
|
||||
fetchAuthStatus: vi.fn(async () => ({ providers: [] })),
|
||||
@@ -64,5 +65,13 @@ export async function createDashboardApiMock(
|
||||
|
||||
export function resetDashboardApiMockState(): void {
|
||||
Object.values(dashboardApiMocks).forEach((fn) => fn.mockReset());
|
||||
dashboardApiMocks.fetchTasks.mockResolvedValue([]);
|
||||
dashboardApiMocks.fetchSettings.mockResolvedValue({});
|
||||
dashboardApiMocks.fetchTaskEffectiveSettings.mockRejectedValue(new Error("fetchTaskEffectiveSettings: use fetchSettings mock"));
|
||||
dashboardApiMocks.updateSettings.mockResolvedValue({});
|
||||
dashboardApiMocks.fetchGlobalSettings.mockResolvedValue({});
|
||||
dashboardApiMocks.fetchAuthStatus.mockResolvedValue({ providers: [] });
|
||||
dashboardApiMocks.fetchModels.mockResolvedValue({ models: [], favoriteProviders: [], favoriteModels: [] });
|
||||
dashboardApiMocks.fetchUnreadCount.mockResolvedValue({ unreadCount: 0 });
|
||||
for (const fn of fallbackFns.values()) fn.mockReset();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { EventEmitter } from "node:events";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { Settings, TaskStore } from "@fusion/core";
|
||||
import { createServer } from "../server.js";
|
||||
import { request } from "../test-request.js";
|
||||
|
||||
class MockStore extends EventEmitter {
|
||||
private workflowSelections = new Map<string, { workflowId: string; stepIds: string[] }>();
|
||||
private workflowValues = new Map<string, Record<string, unknown>>();
|
||||
|
||||
getRootDir(): string { return "/repo"; }
|
||||
getFusionDir(): string { return "/repo/.fusion"; }
|
||||
getDatabase() { return { exec: vi.fn(), prepare: vi.fn().mockReturnValue({ run: vi.fn().mockReturnValue({ changes: 0 }), all: vi.fn().mockReturnValue([]), get: vi.fn() }) }; }
|
||||
getSettings = vi.fn(async () => this.getSettingsFast());
|
||||
getSettingsFast = vi.fn(async (): Promise<Settings> => ({
|
||||
defaultProvider: "base-default-provider",
|
||||
defaultModelId: "base-default-model",
|
||||
workflowStepTimeoutMs: 360_000,
|
||||
runStepsInNewSessions: false,
|
||||
} as Settings));
|
||||
getTaskWorkflowSelection = vi.fn((taskId: string) => this.workflowSelections.get(taskId));
|
||||
getWorkflowDefinition = vi.fn(async () => undefined);
|
||||
getWorkflowSettingValues = vi.fn((workflowId: string, projectId: string) => this.workflowValues.get(`${workflowId}::${projectId}`) ?? {});
|
||||
getWorkflowSettingsProjectId = vi.fn(() => "default");
|
||||
|
||||
setSelection(taskId: string, workflowId: string): void {
|
||||
this.workflowSelections.set(taskId, { workflowId, stepIds: [] });
|
||||
}
|
||||
|
||||
setValues(workflowId: string, values: Record<string, unknown>): void {
|
||||
this.workflowValues.set(`${workflowId}::default`, values);
|
||||
}
|
||||
}
|
||||
|
||||
function createApp(store = new MockStore()) {
|
||||
return { app: createServer(store as unknown as TaskStore), store };
|
||||
}
|
||||
|
||||
describe("GET /tasks/:id/effective-settings", () => {
|
||||
it("overlays stored workflow model lanes that base settings do not expose", async () => {
|
||||
const { app, store } = createApp();
|
||||
store.setSelection("FN-1", "builtin:coding");
|
||||
store.setValues("builtin:coding", {
|
||||
executionProvider: "openai",
|
||||
executionModelId: "gpt-4o",
|
||||
validatorProvider: "anthropic",
|
||||
validatorModelId: "claude-3-7-sonnet",
|
||||
planningProvider: "google",
|
||||
planningModelId: "gemini-2.5-pro",
|
||||
});
|
||||
|
||||
const base = await request(app, "GET", "/api/settings");
|
||||
expect(base.status).toBe(200);
|
||||
expect(base.body).not.toHaveProperty("executionProvider");
|
||||
expect(base.body).not.toHaveProperty("validatorProvider");
|
||||
expect(base.body).not.toHaveProperty("planningProvider");
|
||||
|
||||
const effective = await request(app, "GET", "/api/tasks/FN-1/effective-settings");
|
||||
expect(effective.status).toBe(200);
|
||||
expect(effective.body).toMatchObject({
|
||||
executionProvider: "openai",
|
||||
executionModelId: "gpt-4o",
|
||||
validatorProvider: "anthropic",
|
||||
validatorModelId: "claude-3-7-sonnet",
|
||||
planningProvider: "google",
|
||||
planningModelId: "gemini-2.5-pro",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps default-only workflow settings from clobbering base values", async () => {
|
||||
const { app, store } = createApp();
|
||||
store.setSelection("FN-2", "builtin:coding");
|
||||
store.getSettingsFast.mockResolvedValueOnce({ workflowStepTimeoutMs: 12_345 } as Settings);
|
||||
|
||||
const effective = await request(app, "GET", "/api/tasks/FN-2/effective-settings");
|
||||
expect(effective.status).toBe(200);
|
||||
expect(effective.body).toMatchObject({ workflowStepTimeoutMs: 12_345 });
|
||||
expect(effective.body).not.toHaveProperty("executionProvider");
|
||||
});
|
||||
|
||||
it("falls through to base/default settings when no stored workflow lane exists", async () => {
|
||||
const { app, store } = createApp();
|
||||
store.setSelection("FN-3", "builtin:coding");
|
||||
|
||||
const effective = await request(app, "GET", "/api/tasks/FN-3/effective-settings");
|
||||
expect(effective.status).toBe(200);
|
||||
expect(effective.body).toMatchObject({
|
||||
defaultProvider: "base-default-provider",
|
||||
defaultModelId: "base-default-model",
|
||||
});
|
||||
expect(effective.body).not.toHaveProperty("executionProvider");
|
||||
});
|
||||
|
||||
it("degrades an unknown task to base-compatible effective settings", async () => {
|
||||
const { app } = createApp();
|
||||
|
||||
const effective = await request(app, "GET", "/api/tasks/FN-MISSING/effective-settings");
|
||||
expect(effective.status).toBe(200);
|
||||
expect(effective.body).toMatchObject({
|
||||
defaultProvider: "base-default-provider",
|
||||
defaultModelId: "base-default-model",
|
||||
});
|
||||
expect(effective.body).not.toHaveProperty("executionProvider");
|
||||
});
|
||||
});
|
||||
@@ -27,6 +27,8 @@ import {
|
||||
resolveTitleSummarizerSettingsModel,
|
||||
validateNodeOverrideChange,
|
||||
canAgentTakeImplementationTaskForExplicitRouting,
|
||||
applyWorkflowSettingsOverlay,
|
||||
resolveEffectiveSettingsDetailed,
|
||||
formatRoleMismatchReason,
|
||||
getCurrentRepo,
|
||||
findDuplicateMatches,
|
||||
@@ -2199,6 +2201,25 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
|
||||
}
|
||||
});
|
||||
|
||||
// Get task-scoped settings with effective workflow-setting values overlaid.
|
||||
router.get("/tasks/:id/effective-settings", async (req, res) => {
|
||||
try {
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const base = await scopedStore.getSettingsFast();
|
||||
const detailed = await resolveEffectiveSettingsDetailed(scopedStore, { id: req.params.id });
|
||||
/*
|
||||
* FNXC:ModelResolution 2026-06-27-10:52:
|
||||
* Task-detail model displays need the same task-scoped workflow model lanes as execution. This route overlays the task's workflow setting values onto getSettingsFast() so moved Execution/Reviewer/Planning lanes render instead of the base "Default" fallback.
|
||||
*/
|
||||
res.json(applyWorkflowSettingsOverlay(base, detailed));
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
}
|
||||
rethrowAsApiError(err);
|
||||
}
|
||||
});
|
||||
|
||||
// Get single task with prompt content
|
||||
router.get("/tasks/:id", async (req, res) => {
|
||||
try {
|
||||
|
||||
@@ -19,7 +19,12 @@
|
||||
* defaults), so this helper is a thin store-coupled wrapper that also never throws.
|
||||
*/
|
||||
|
||||
import { resolveEffectiveSettingsDetailed, type Settings, type TaskStore } from "@fusion/core";
|
||||
import {
|
||||
applyWorkflowSettingsOverlay,
|
||||
resolveEffectiveSettingsDetailed,
|
||||
type Settings,
|
||||
type TaskStore,
|
||||
} from "@fusion/core";
|
||||
|
||||
/** The minimal task shape the resolver needs. Task carries no projectId field —
|
||||
* the project key is derived from the store. */
|
||||
@@ -43,29 +48,13 @@ export async function mergeEffectiveSettings<T extends Partial<Settings>>(
|
||||
task: EffectiveSettingsTask,
|
||||
base: T,
|
||||
): Promise<T> {
|
||||
let effective: Record<string, unknown>;
|
||||
let storedKeys: Set<string>;
|
||||
try {
|
||||
const detailed = await resolveEffectiveSettingsDetailed(
|
||||
store as Parameters<typeof resolveEffectiveSettingsDetailed>[0],
|
||||
task,
|
||||
);
|
||||
effective = detailed.effective;
|
||||
storedKeys = detailed.storedKeys;
|
||||
return applyWorkflowSettingsOverlay(base, detailed);
|
||||
} catch {
|
||||
return base;
|
||||
}
|
||||
const merged: Record<string, unknown> = { ...base };
|
||||
for (const key of Object.keys(effective)) {
|
||||
const value = effective[key];
|
||||
if (value === undefined) continue;
|
||||
if (storedKeys.has(key)) {
|
||||
// Stored workflow value: always overrides the base.
|
||||
merged[key] = value;
|
||||
} else if (merged[key] === undefined) {
|
||||
// Declaration default: only fills when the base lacks the key (post-migration).
|
||||
merged[key] = value;
|
||||
}
|
||||
}
|
||||
return merged as T;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user