FN-7967: accept custom triage workflow IDs and honor project default
Allow triageDefaultWorkflowId and triageDecisionOnlyWorkflowId to accept custom workflow IDs so project default workflows are honored at triage time. - Change triage workflow settings from enum to string; empty triageDefaultWorkflowId inherits config.settings.defaultWorkflowId - Render triage prompt default from project settings unless an explicit stored override exists - Only pass stored triageDefaultWorkflowId into triage policy settings so declaration defaults do not clobber project defaults - Document settings behavior and add core/engine regression coverage - Add patch changeset for @runfusion/fusion Files changed: .changeset/fn-7967-triage-default-workflow.md | 7 +++++++ docs/settings-reference.md | 4 ++-- packages/core/src/__tests__/builtin-workflow-settings-triage.test.ts | 43 +++++++++++++++++++++++++++++++++++++++++-- packages/core/src/builtin-workflow-settings.ts | 35 ++++++++++++++++++++--------------- packages/engine/src/__tests__/triage.test.ts | 41 +++++++++++++++++++++++++++++++++++++++++ packages/engine/src/triage.ts | 28 ++++++++++++++++++++++++---- 6 files changed, 135 insertions(+), 23 deletions(-) Fusion-Task-Id: FN-7967 Fusion-Task-Lineage: e42ea061-889c-4bdd-8a9d-f56f34fc0c89 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-7967-triage-default-workflow.md
Normal file
7
.changeset/fn-7967-triage-default-workflow.md
Normal file
@@ -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`.
|
||||
@@ -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. |
|
||||
|
||||
@@ -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<string, { type: string; default: unknown }> = {
|
||||
triageProactiveSubtaskSplittingEnabled: { type: "boolean", default: true },
|
||||
@@ -24,8 +28,8 @@ const expectedDefaults: Record<string, { type: string; default: unknown }> = {
|
||||
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}}", {});
|
||||
|
||||
|
||||
@@ -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<Settings>): 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<string, unknown>;
|
||||
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) {
|
||||
|
||||
@@ -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-");
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
storedKeys: Set<string>;
|
||||
} => ({
|
||||
effective: {},
|
||||
storedKeys: new Set<string>(),
|
||||
}));
|
||||
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<Settings>;
|
||||
// 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,
|
||||
|
||||
Reference in New Issue
Block a user