diff --git a/.changeset/fn-7967-triage-default-workflow.md b/.changeset/fn-7967-triage-default-workflow.md new file mode 100644 index 0000000000..dd2859e8f9 --- /dev/null +++ b/.changeset/fn-7967-triage-default-workflow.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Honor custom project workflow defaults in triage guidance. +category: fix +dev: `triageDefaultWorkflowId` and `triageDecisionOnlyWorkflowId` now accept custom IDs; unset/default triage routing inherits `config.settings.defaultWorkflowId`. diff --git a/docs/settings-reference.md b/docs/settings-reference.md index 10a18d5919..98b056594c 100644 --- a/docs/settings-reference.md +++ b/docs/settings-reference.md @@ -370,8 +370,8 @@ The built-in workflows also declare triage/spec policy settings that were **not* | `triageSubtaskFileScopeThreshold` | `20` | File Scope entry count that signals broad work. | | `triageSubtaskRemediationBatchThreshold` | `30` | Large remediation batch threshold. | | `triageNoCommitsDecisionVerbs` | all seven built-ins | Decision-only verbs: Decide, Evaluate, Verify, Confirm, Audit, Review whether, Investigate and report. | -| `triageDecisionOnlyWorkflowId` | `builtin:quick-fix` | Preferred workflow for decision-only/no-commit tasks when the user explicitly requests that routing or the agent is creating the task. | -| `triageDefaultWorkflowId` | `builtin:coding` | Default workflow for standard coding tasks and for existing tasks without an explicit user-requested or creator-owned workflow selection. | +| `triageDecisionOnlyWorkflowId` | `builtin:quick-fix` | Preferred built-in or custom workflow for decision-only/no-commit tasks when the user explicitly requests that routing or the agent is creating the task. | +| `triageDefaultWorkflowId` | empty (inherits project default) | Accepts any valid built-in or custom workflow id as a triage override. Empty (and legacy `builtin:coding`) inherits `config.settings.defaultWorkflowId`; the triage prompt falls back to `builtin:coding` only when neither is configured. | | `leanPlanning` | `false` | Workflow-native fast-mode policy: select the lean `planning-fast` prompt variant instead of the full triage spec prompt. | | `autoApproveSpec` | `false` | Legacy compatibility setting. Workflow Plan Review now owns optional pre-execution AI plan approval. | | `planReviewMaxRevisions` | unset | Workflow-native Plan Review/spec revision cap. Unset/empty means unbounded automatic replans; a non-negative integer caps attempts; `0` disables automatic Plan Review revision. | diff --git a/packages/core/src/__tests__/builtin-workflow-settings-triage.test.ts b/packages/core/src/__tests__/builtin-workflow-settings-triage.test.ts index 93c2c1a9f0..60b5ff3ad6 100644 --- a/packages/core/src/__tests__/builtin-workflow-settings-triage.test.ts +++ b/packages/core/src/__tests__/builtin-workflow-settings-triage.test.ts @@ -8,6 +8,10 @@ import { renderTriagePolicyPlaceholders, } from "../builtin-workflow-settings.js"; import { MOVED_SETTINGS_KEYS } from "../moved-settings.js"; +import { + resolveEffectiveSettingValues, + validateSettingValuePatch, +} from "../workflow-settings.js"; const expectedDefaults: Record = { triageProactiveSubtaskSplittingEnabled: { type: "boolean", default: true }, @@ -24,8 +28,8 @@ const expectedDefaults: Record = { type: "multi-enum", default: ["Decide", "Evaluate", "Verify", "Confirm", "Audit", "Review whether", "Investigate and report"], }, - triageDecisionOnlyWorkflowId: { type: "enum", default: "builtin:quick-fix" }, - triageDefaultWorkflowId: { type: "enum", default: "builtin:coding" }, + triageDecisionOnlyWorkflowId: { type: "string", default: "builtin:quick-fix" }, + triageDefaultWorkflowId: { type: "string", default: "" }, leanPlanning: { type: "boolean", default: false }, autoApproveSpec: { type: "boolean", default: false }, }; @@ -197,6 +201,23 @@ describe("workflow-native built-in workflow settings", () => { ).toBe(false); }); + it("accepts custom triage workflow ids and retains them as effective values", () => { + const patch = validateSettingValuePatch(BUILTIN_TRIAGE_POLICY_SETTINGS, { + triageDefaultWorkflowId: "WF-005", + triageDecisionOnlyWorkflowId: "WF-009", + }); + + expect(patch.rejections).toEqual([]); + expect(patch.accepted).toMatchObject({ + triageDefaultWorkflowId: "WF-005", + triageDecisionOnlyWorkflowId: "WF-009", + }); + expect(resolveEffectiveSettingValues(BUILTIN_TRIAGE_POLICY_SETTINGS, patch.accepted)).toMatchObject({ + triageDefaultWorkflowId: "WF-005", + triageDecisionOnlyWorkflowId: "WF-009", + }); + }); + it("renders placeholders from resolved settings and rejects dangling tokens", () => { const prompt = [ "Size S (<{{triageSizeSmallMaxHours}}h)", @@ -217,6 +238,24 @@ describe("workflow-native built-in workflow settings", () => { expect(() => renderTriagePolicyPlaceholders("{{unknownTriageToken}}", {})).toThrow(/Unresolved triage policy placeholder/); }); + it("renders the triage default workflow from project settings unless explicitly overridden", () => { + const prompt = "Keep the project default workflow (`{{triageDefaultWorkflowId}}`)"; + + expect(renderTriagePolicyPlaceholders(prompt, { defaultWorkflowId: "WF-005" })).toContain("`WF-005`"); + expect(renderTriagePolicyPlaceholders(prompt, { + triageDefaultWorkflowId: "builtin:coding", + defaultWorkflowId: "WF-005", + } as never)).toContain("`WF-005`"); + expect(renderTriagePolicyPlaceholders(prompt, { + triageDefaultWorkflowId: "WF-009", + defaultWorkflowId: "WF-005", + } as never)).toContain("`WF-009`"); + + const fallback = renderTriagePolicyPlaceholders(prompt, {}); + expect(fallback).toContain("`builtin:coding`"); + expect(fallback).not.toContain("{{"); + }); + it("renders proactive splitting policy as enabled by default", () => { const rendered = renderTriagePolicyPlaceholders("{{triageProactiveSubtaskSplittingEnabled}}", {}); diff --git a/packages/core/src/builtin-workflow-settings.ts b/packages/core/src/builtin-workflow-settings.ts index 8a410bd5bd..a7f4e336ec 100644 --- a/packages/core/src/builtin-workflow-settings.ts +++ b/packages/core/src/builtin-workflow-settings.ts @@ -406,24 +406,17 @@ export const BUILTIN_TRIAGE_POLICY_SETTINGS: WorkflowSettingDefinition[] = [ { id: "triageDecisionOnlyWorkflowId", name: "Triage decision-only workflow", - type: "enum", + type: "string", default: "builtin:quick-fix", - options: [ - { value: "builtin:quick-fix", label: "Quick fix" }, - { value: "builtin:coding", label: "Coding" }, - ], - description: "Preferred workflow id for decision-only or investigation tasks that expect no code changes.", + description: "Preferred built-in or custom workflow id for decision-only or investigation tasks that expect no code changes.", }, { id: "triageDefaultWorkflowId", name: "Triage default workflow", - type: "enum", - default: "builtin:coding", - options: [ - { value: "builtin:coding", label: "Coding" }, - { value: "builtin:quick-fix", label: "Quick fix" }, - ], - description: "Default workflow id for standard coding tasks.", + type: "string", + default: "", + description: + "Optional built-in or custom workflow id override for triage. Empty inherits config.settings.defaultWorkflowId.", }, { id: "leanPlanning", @@ -602,7 +595,19 @@ const TRIAGE_POLICY_DEFAULTS = new Map( BUILTIN_TRIAGE_POLICY_SETTINGS.map((setting) => [setting.id, setting.default]), ); -function formatTriagePolicyValue(id: string, value: unknown): string { +function formatTriagePolicyValue(id: string, value: unknown, settings: Partial): string { + /* + * FNXC:WorkflowRouting 2026-07-15-00:00: + * The triage prompt must name the operator's project default workflow when no + * per-workflow override is configured. This keeps planning guidance aligned + * with config.settings.defaultWorkflowId and accepts custom workflow IDs. + */ + if (id === "triageDefaultWorkflowId") { + const explicitValue = typeof value === "string" ? value.trim() : ""; + if (explicitValue && explicitValue !== "builtin:coding") return explicitValue; + const projectDefault = typeof settings.defaultWorkflowId === "string" ? settings.defaultWorkflowId.trim() : ""; + return projectDefault || "builtin:coding"; + } if (id === "triageNoCommitsDecisionVerbs") { const verbs = Array.isArray(value) ? value : TRIAGE_POLICY_DEFAULTS.get(id); return (Array.isArray(verbs) ? verbs : []).map((verb) => String(verb)).join(", "); @@ -647,7 +652,7 @@ export function renderTriagePolicyPlaceholders(prompt: string, settings: Partial const values = settings as Record; for (const setting of BUILTIN_TRIAGE_POLICY_SETTINGS) { const token = new RegExp(`\\{\\{${setting.id}\\}\\}`, "g"); - rendered = rendered.replace(token, formatTriagePolicyValue(setting.id, values[setting.id] ?? setting.default)); + rendered = rendered.replace(token, formatTriagePolicyValue(setting.id, values[setting.id] ?? setting.default, settings)); } const leftover = rendered.match(/\{\{[^}]+\}\}/); if (leftover) { diff --git a/packages/engine/src/__tests__/triage.test.ts b/packages/engine/src/__tests__/triage.test.ts index 70ad8f954b..3519c3cb7e 100644 --- a/packages/engine/src/__tests__/triage.test.ts +++ b/packages/engine/src/__tests__/triage.test.ts @@ -895,6 +895,9 @@ describe("fast-mode triage", () => { for (const text of required) expect(prompt).toContain(text); for (const text of forbidden) expect(prompt).not.toContain(text); } + expect(renderTriagePolicyPlaceholders(TRIAGE_POLICY_PROMPT, { defaultWorkflowId: "WF-005" })).toContain( + "Keep the project default workflow (`WF-005`)", + ); }); it("includes task-artifact location guidance for forensic/reconciliation tasks", () => { @@ -958,6 +961,44 @@ describe("fast-mode triage", () => { expect(capturedSystemPrompt).toContain("## Review Level"); }); + it("renders a stored triage workflow override over the configured project default", async () => { + const task = createTriageTask({ id: "FN-7967", executionMode: "standard" }); + const store = createMockStore({ + getTask: vi.fn().mockResolvedValue({ ...mockTaskDetail, id: task.id, attachments: [], comments: [] }), + getSettings: vi.fn().mockResolvedValue({ + maxConcurrent: 2, + maxWorktrees: 4, + pollIntervalMs: 10000, + groupOverlappingFiles: false, + autoMerge: true, + defaultWorkflowId: "WF-005", + } as Settings), + getTaskWorkflowSelection: vi.fn().mockReturnValue({ workflowId: "builtin:coding", stepIds: [] }), + getWorkflowSettingsProjectId: vi.fn().mockReturnValue("project-1"), + getWorkflowSettingValues: vi.fn().mockReturnValue({ triageDefaultWorkflowId: "WF-009" }), + }); + + let capturedSystemPrompt = ""; + mockCreateFnAgent.mockImplementationOnce(async (opts: any) => { + capturedSystemPrompt = opts.systemPrompt; + return { + session: { + state: {}, + sessionManager: { getLeafId: vi.fn().mockReturnValue(null) }, + prompt: vi.fn().mockResolvedValue(undefined), + dispose: vi.fn(), + navigateTree: vi.fn(), + }, + }; + }); + + const processor = new TriageProcessor(store, "/tmp/root"); + await processor.specifyTask(task); + + expect(capturedSystemPrompt).toContain("Keep the project default workflow (`WF-009`)"); + expect(capturedSystemPrompt).not.toContain("Keep the project default workflow (`WF-005`)"); + }); + it("renders disabled proactive splitting while preserving explicit breakIntoSubtasks prompts", async () => { const task = createTriageTask({ id: "FN-FAST-003", executionMode: "standard", breakIntoSubtasks: true }); const rootDir = await createTriageFixtureRoot("fn-7491-triage-"); diff --git a/packages/engine/src/triage.ts b/packages/engine/src/triage.ts index 213aa1945c..d78e6cea80 100644 --- a/packages/engine/src/triage.ts +++ b/packages/engine/src/triage.ts @@ -20,7 +20,7 @@ import { resolveAgentPrompt, builtinSeamPrompt, renderTriagePolicyPlaceholders, - resolveEffectiveSettings, + resolveEffectiveSettingsDetailed, resolveEffectivePlannerHeartbeatPatrolEnabled, resolveTaskPlanningPrompt, resolveTaskSeamPrompt, @@ -1144,8 +1144,28 @@ export class TriageProcessor { const workflowFastPlanningPrompt = leanPlanning ? await resolveTaskSeamPrompt(this.store, task.id, "planning-fast").catch(() => undefined) : undefined; - const effectiveWorkflowSettings = await resolveEffectiveSettings(this.store, task).catch(() => ({})); - const plannerHeartbeatPatrolEnabled = resolveEffectivePlannerHeartbeatPatrolEnabled(effectiveWorkflowSettings); + const resolvedWorkflowSettings = await resolveEffectiveSettingsDetailed(this.store, task).catch((): { + effective: Record; + storedKeys: Set; + } => ({ + effective: {}, + storedKeys: new Set(), + })); + const plannerHeartbeatPatrolEnabled = resolveEffectivePlannerHeartbeatPatrolEnabled(resolvedWorkflowSettings.effective); + /* + * FNXC:WorkflowRouting 2026-07-15-13:00: + * Triage policy values are workflow-scoped, while defaultWorkflowId remains + * project-scoped. Only an explicitly stored triageDefaultWorkflowId may + * override the project settings; a declaration default must inherit the + * project default and must not clobber legacy triage policy values. + */ + const triageDefaultWorkflowId = resolvedWorkflowSettings.storedKeys.has("triageDefaultWorkflowId") + ? resolvedWorkflowSettings.effective.triageDefaultWorkflowId + : undefined; + const triagePolicySettings = { + ...settings, + ...(triageDefaultWorkflowId === undefined ? {} : { triageDefaultWorkflowId }), + } as Partial; // FN-6232: standard-mode built-in triage policy is sourced from the workflow IR planning node; the former engine duplicate was removed. const userTriagePrompt = settings.agentPrompts?.roleAssignments?.triage ? resolveAgentPrompt("triage", settings.agentPrompts, { plannerHeartbeatPatrolEnabled }) @@ -1158,7 +1178,7 @@ export class TriageProcessor { // Apply the workflow-native triage policy renderer to both standard and // fast prompts. Fast mode currently has no policy placeholders, making // this a no-op there while still guaranteeing no dangling token leaks. - const renderedBasePrompt = renderTriagePolicyPlaceholders(resolvedBasePrompt, settings); + const renderedBasePrompt = renderTriagePolicyPlaceholders(resolvedBasePrompt, triagePolicySettings); const triageLayers = buildPromptLayers({ basePrompt: renderedBasePrompt, goalContext: triageGoalResolution.goalContext,