FN-7491: add workflow toggle for proactive triage splitting

Add a workflow-scoped policy switch so operators can keep large triage tasks intact unless subtask splitting is explicitly requested.

- Define triageProactiveSubtaskSplittingEnabled with default-on workflow settings, display formatting, and prompt rendering.
- Preserve mandatory breakIntoSubtasks behavior while disabling automatic oversized-task decomposition when the setting is false.
- Cover the new policy in core, engine, and dashboard tests plus settings/workflow docs and a changeset.

Files changed:
 .changeset/FN-7491-triage-splitting-setting.md     |  7 +++
 docs/settings-reference.md                         |  6 +++
 docs/workflow-steps.md                             | 10 ++--
 packages/core/src/__tests__/agent-prompts.test.ts  | 17 ++++--
 .../builtin-workflow-settings-triage.test.ts       | 23 ++++++++
 packages/core/src/agent-prompts.ts                 | 27 ++--------
 packages/core/src/builtin-workflow-settings.ts     | 44 ++++++++++++++++
 .../__tests__/WorkflowSettingsPanel.test.tsx       | 53 +++++++++++++++++++
 .../app/components/workflow-setting-display.ts     | 10 ++++
 .../__tests__/triage-threshold-settings.test.ts    | 28 ++++++++++
 packages/engine/src/__tests__/triage.test.ts       | 61 +++++++++++++++++++---
 11 files changed, 250 insertions(+), 36 deletions(-)

Fusion-Task-Id: FN-7491

Fusion-Task-Lineage: c982455a-685c-4ce2-8e33-6d1a7bb9d154

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-04 09:34:20 -07:00
parent b42be87ad1
commit 8668a053dc
11 changed files with 250 additions and 36 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Add a workflow setting to disable automatic large-task triage splitting.
category: feature
dev: Adds triageProactiveSubtaskSplittingEnabled while preserving explicit breakIntoSubtasks requests.

View File

@@ -327,12 +327,16 @@ These groups moved out of project settings and into workflow settings (built-in
<!-- <!--
FNXC:WorkflowRouting 2026-06-22-12:00: FNXC:WorkflowRouting 2026-06-22-12:00:
Triage workflow defaults are policy inputs, not permission to reroute tasks autonomously. Prompt guidance allows workflow selection only for explicit user requests or tasks the agent created. Triage workflow defaults are policy inputs, not permission to reroute tasks autonomously. Prompt guidance allows workflow selection only for explicit user requests or tasks the agent created.
FNXC:TriagePolicy 2026-07-04-00:00:
`triageProactiveSubtaskSplittingEnabled` is workflow/project-scoped so operators can disable automatic oversized-task splitting without disabling explicit per-task `breakIntoSubtasks: true` requests.
--> -->
The built-in workflows also declare triage/spec policy settings that were **not** moved from project settings. They are workflow-native declarations: they never lived in `DEFAULT_PROJECT_SETTINGS`, are not `MOVED_SETTINGS_KEYS`, and resolve only through the workflow effective-settings path. The built-in workflows also declare triage/spec policy settings that were **not** moved from project settings. They are workflow-native declarations: they never lived in `DEFAULT_PROJECT_SETTINGS`, are not `MOVED_SETTINGS_KEYS`, and resolve only through the workflow effective-settings path.
| Setting | Default | Purpose | | Setting | Default | Purpose |
|---|---:|---| |---|---:|---|
| `triageProactiveSubtaskSplittingEnabled` | `true` | Enables automatic large-task splitting guidance for oversized M/L tasks. Set to `false` to keep tasks whole unless `breakIntoSubtasks: true` is explicitly requested. |
| `triageSizeSmallMaxHours` | `2` | Size S upper hour boundary (`S (<2h)`). | | `triageSizeSmallMaxHours` | `2` | Size S upper hour boundary (`S (<2h)`). |
| `triageSizeMediumMaxHours` | `4` | Size M upper hour boundary (`M (2-4h)`). | | `triageSizeMediumMaxHours` | `4` | Size M upper hour boundary (`M (2-4h)`). |
| `triageSizeLargeMaxHours` | `8` | Size L upper hour boundary; XL starts at `8h+`. | | `triageSizeLargeMaxHours` | `8` | Size L upper hour boundary; XL starts at `8h+`. |
@@ -350,6 +354,8 @@ The built-in workflows also declare triage/spec policy settings that were **not*
| `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. | | `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. |
| `codeReviewMaxRevisions` | unset | Workflow-native Code Review remediation cap. Unset/empty means unbounded automatic code-fix passes; a non-negative integer caps attempts; `0` disables automatic Code Review remediation. | | `codeReviewMaxRevisions` | unset | Workflow-native Code Review remediation cap. Unset/empty means unbounded automatic code-fix passes; a non-negative integer caps attempts; `0` disables automatic Code Review remediation. |
When `triageProactiveSubtaskSplittingEnabled` is `true` (the default), triage may proactively replace a large task with 2-5 child tasks when the size, step-count, package breadth, file-scope, or remediation-batch signals justify the coordination overhead. When it is `false`, those automatic oversized-task signals are advisory only for writing a realistic single-task spec; triage must not split solely because the task is large. The per-task `breakIntoSubtasks: true` flag is separate and remains mandatory: if a user explicitly asks for subtask breakdown, triage still evaluates and creates child tasks when the work is meaningfully decomposable.
In the dashboard Settings modal, Project Models exposes Plan/Triage, Executor, In the dashboard Settings modal, Project Models exposes Plan/Triage, Executor,
Reviewer, and declared fallback dropdown controls for the default workflow. The Reviewer, and declared fallback dropdown controls for the default workflow. The
modal's primary **Save** action persists pending default-workflow model lane modal's primary **Save** action persists pending default-workflow model lane

View File

@@ -829,9 +829,13 @@ engine resolves *effective settings* per task as `stored value ?? declaration
default`, dropping any stored value that no longer validates against the current default`, dropping any stored value that no longer validates against the current
declaration (drop-on-orphan) and falling back to the default. declaration (drop-on-orphan) and falling back to the default.
The **step-execution**, **review/approval**, and **per-phase model-lane** knobs that The **step-execution**, **review/approval**, **per-phase model-lane**, and
used to be project settings are now workflow settings declared by `builtin:coding` **triage/spec policy** knobs are workflow settings declared by `builtin:coding`.
with their former defaults. See Triage policy includes `triageProactiveSubtaskSplittingEnabled` (default `true`),
which controls automatic large-task splitting guidance for oversized M/L work.
Set it to `false` in a workflow's Values tab when triage should keep large tasks
whole unless the task explicitly has `breakIntoSubtasks: true`; explicit subtask
requests still follow the mandatory split flow. See
[Settings Reference → Workflow Settings](./settings-reference.md#workflow-settings) [Settings Reference → Workflow Settings](./settings-reference.md#workflow-settings)
for the full moved-key catalog, the editor walkthrough, and the export/sync posture. for the full moved-key catalog, the editor walkthrough, and the export/sync posture.

View File

@@ -306,18 +306,25 @@ describe("resolveAgentPrompt", () => {
expect(triageSource).not.toContain(["FAST", "TRIAGE", "SYSTEM", "PROMPT"].join("_")); expect(triageSource).not.toContain(["FAST", "TRIAGE", "SYSTEM", "PROMPT"].join("_"));
expect(triageSource).not.toMatch(/export const [A-Z_]*TRIAGE[A-Z_]*SYSTEM_PROMPT\s*=/); expect(triageSource).not.toMatch(/export const [A-Z_]*TRIAGE[A-Z_]*SYSTEM_PROMPT\s*=/);
expect(planningPrompt).toBe(corePrompt); expect(planningPrompt).toBe(corePrompt);
expect(corePrompt).toContain("**Broad-scope decomposition signals:**"); expect(corePrompt).toContain("{{triageProactiveSubtaskSplittingEnabled}}");
expect(corePrompt).toContain("step count would reach {{triageSubtaskLargeStepSignal}} or more"); expect(corePrompt).toContain("Explicit user-requested `breakIntoSubtasks: true` remains governed");
expect(corePrompt).toContain("would reach {{triageSubtaskAdditiveStepSignal}} or more");
expect(corePrompt).toContain("{{triageSubtaskFileScopeThreshold}} or more entries");
expect(corePrompt).toContain("at or above {{triageSubtaskRemediationBatchThreshold}} items");
const renderedPrompt = renderTriagePolicyPlaceholders(corePrompt, {}); const renderedPrompt = renderTriagePolicyPlaceholders(corePrompt, {});
expect(renderedPrompt).toContain("**Broad-scope decomposition signals:**");
expect(renderedPrompt).toContain("step count would reach 9 or more"); expect(renderedPrompt).toContain("step count would reach 9 or more");
expect(renderedPrompt).toContain("would reach 12 or more"); expect(renderedPrompt).toContain("would reach 12 or more");
expect(renderedPrompt).toContain("20 or more entries"); expect(renderedPrompt).toContain("20 or more entries");
expect(renderedPrompt).toContain("at or above 30 items"); expect(renderedPrompt).toContain("at or above 30 items");
expect(renderedPrompt).toContain("Even when `breakIntoSubtasks` is not set to `true`, apply these thresholds proactively");
expect(renderedPrompt).not.toContain("{{"); expect(renderedPrompt).not.toContain("{{");
const disabledPrompt = renderTriagePolicyPlaceholders(corePrompt, {
triageProactiveSubtaskSplittingEnabled: false,
} as never);
expect(disabledPrompt).toContain("Proactive oversized-task splitting is DISABLED");
expect(disabledPrompt).toContain("Only create child tasks when `breakIntoSubtasks: true` is explicitly present");
expect(disabledPrompt).not.toContain("Even when `breakIntoSubtasks` is not set to `true`, apply these thresholds proactively");
expect(disabledPrompt).not.toContain("{{");
}); });
it("resolves custom seam prompts and ignores IRs without matching prompts", () => { it("resolves custom seam prompts and ignores IRs without matching prompts", () => {

View File

@@ -9,6 +9,7 @@ import {
import { MOVED_SETTINGS_KEYS } from "../moved-settings.js"; import { MOVED_SETTINGS_KEYS } from "../moved-settings.js";
const expectedDefaults: Record<string, { type: string; default: unknown }> = { const expectedDefaults: Record<string, { type: string; default: unknown }> = {
triageProactiveSubtaskSplittingEnabled: { type: "boolean", default: true },
triageSizeSmallMaxHours: { type: "number", default: 2 }, triageSizeSmallMaxHours: { type: "number", default: 2 },
triageSizeMediumMaxHours: { type: "number", default: 4 }, triageSizeMediumMaxHours: { type: "number", default: 4 },
triageSizeLargeMaxHours: { type: "number", default: 8 }, triageSizeLargeMaxHours: { type: "number", default: 8 },
@@ -98,4 +99,26 @@ describe("workflow-native built-in workflow settings", () => {
expect(rendered).not.toContain("{{"); expect(rendered).not.toContain("{{");
expect(() => renderTriagePolicyPlaceholders("{{unknownTriageToken}}", {})).toThrow(/Unresolved triage policy placeholder/); expect(() => renderTriagePolicyPlaceholders("{{unknownTriageToken}}", {})).toThrow(/Unresolved triage policy placeholder/);
}); });
it("renders proactive splitting policy as enabled by default", () => {
const rendered = renderTriagePolicyPlaceholders("{{triageProactiveSubtaskSplittingEnabled}}", {});
expect(rendered).toContain("For tasks you assess as Size M or L, consider whether splitting");
expect(rendered).toContain("Even when `breakIntoSubtasks` is not set to `true`, apply these thresholds proactively");
expect(rendered).toContain("MORE THAN 7 implementation steps");
expect(rendered).not.toContain("Proactive oversized-task splitting is DISABLED");
expect(rendered).not.toContain("{{");
});
it("renders disabled proactive policy without weakening explicit subtask requests", () => {
const rendered = renderTriagePolicyPlaceholders("{{triageProactiveSubtaskSplittingEnabled}}", {
triageProactiveSubtaskSplittingEnabled: false,
} as never);
expect(rendered).toContain("Proactive oversized-task splitting is DISABLED");
expect(rendered).toContain("Do NOT split solely because the task is Size M/L");
expect(rendered).toContain("Only create child tasks when `breakIntoSubtasks: true` is explicitly present");
expect(rendered).not.toContain("Even when `breakIntoSubtasks` is not set to `true`, apply these thresholds proactively");
expect(rendered).not.toContain("{{");
});
}); });

View File

@@ -464,28 +464,11 @@ When the task includes \`breakIntoSubtasks: true\`, first decide whether it shou
- If not splitting: proceed with a normal PROMPT.md specification. - If not splitting: proceed with a normal PROMPT.md specification.
## Proactive Subtask Breakdown for M/L Tasks ## Proactive Subtask Breakdown for M/L Tasks
For tasks you assess as Size M or L, consider whether splitting into 2-5 child tasks would improve execution quality. Default to keeping the task whole; only split when the work is genuinely large or has clearly independent deliverables. <!--
FNXC:TriagePolicy 2026-07-04-00:00:
**Consider splitting when ANY of these apply:** Workflow policy can disable proactive oversized-task splitting for operators who want triage to keep large tasks whole by default. Explicit user-requested \`breakIntoSubtasks: true\` remains governed by the mandatory breakdown section above and must not be weakened by this toggle.
- The task will require MORE THAN {{triageSubtaskStepThreshold}} implementation steps -->
- The task affects MORE THAN {{triageSubtaskPackageThreshold}} different packages/modules with distinct concerns (a typed field change that naturally touches core types + store + UI + tests is NOT 4 distinct concerns — it's one coherent change) {{triageProactiveSubtaskSplittingEnabled}}
- Any single step would take more than 1-2 hours to complete
- The task has multiple clearly independent deliverables that could be developed and shipped in parallel by different people
**Splitting guidance:**
- Even when \`breakIntoSubtasks\` is not set to \`true\`, apply these thresholds proactively
- Keep explicit user intent first: when \`breakIntoSubtasks: true\`, follow the mandatory breakdown flow above
- Size S tasks should NOT be split — the overhead outweighs the benefit
- A task with 7-10 focused steps within a coherent scope is fine as one unit; do not split it
- Coordination overhead (worktrees, dependency wiring, merge sequencing) is real — only split when the parallelism or scope-clarity benefit clearly outweighs it
- If you decide not to split an M/L task, proceed with a normal PROMPT.md specification
**Broad-scope decomposition signals:**
- Size L tasks, especially when the planned step count would reach {{triageSubtaskLargeStepSignal}} or more.
- Plans whose implementation-step count would reach {{triageSubtaskAdditiveStepSignal}} or more (additive signal — counts even when the surrounding step-count threshold above has not yet fired).
- Tasks whose declared \`## File Scope\` would list {{triageSubtaskFileScopeThreshold}} or more entries.
- Descriptions that quantify large remediation batches (for example "47 failing tests", "30+ broken files") at or above {{triageSubtaskRemediationBatchThreshold}} items — treat as a strong signal that the work should be partitioned by subsystem or file group before specifying.
- When two or more of the signals above fire together, default to splitting via \`fn_task_create\`. If you still choose to keep the task as a single unit, justify the decision explicitly in the PROMPT.md \`## Mission\` paragraph.
## Triage tools ## Triage tools
You have these extra tools during triage: You have these extra tools during triage:

View File

@@ -262,6 +262,18 @@ export const BUILTIN_MOVED_WORKFLOW_SETTINGS: WorkflowSettingDefinition[] = [
]; ];
export const BUILTIN_TRIAGE_POLICY_SETTINGS: WorkflowSettingDefinition[] = [ export const BUILTIN_TRIAGE_POLICY_SETTINGS: WorkflowSettingDefinition[] = [
{
id: "triageProactiveSubtaskSplittingEnabled",
name: "Triage proactive subtask splitting",
type: "boolean",
default: true,
/*
* FNXC:TriagePolicy 2026-07-04-00:00:
* Operators need a workflow/project policy switch that disables automatic large-task splitting without weakening explicit `breakIntoSubtasks: true` requests. Keep the default enabled to preserve existing triage behavior for workflows that have no stored override.
*/
description:
"Enable automatic large-task splitting guidance during triage. Turn off to split only when breakIntoSubtasks is explicitly requested.",
},
{ {
id: "triageSizeSmallMaxHours", id: "triageSizeSmallMaxHours",
name: "Triage size S max hours", name: "Triage size S max hours",
@@ -431,6 +443,38 @@ function formatTriagePolicyValue(id: string, value: unknown): string {
const verbs = Array.isArray(value) ? value : TRIAGE_POLICY_DEFAULTS.get(id); const verbs = Array.isArray(value) ? value : TRIAGE_POLICY_DEFAULTS.get(id);
return (Array.isArray(verbs) ? verbs : []).map((verb) => String(verb)).join(", "); return (Array.isArray(verbs) ? verbs : []).map((verb) => String(verb)).join(", ");
} }
if (id === "triageProactiveSubtaskSplittingEnabled") {
const enabled = value !== false;
if (!enabled) {
return `Proactive oversized-task splitting is DISABLED for this workflow/project.
- Do NOT split solely because the task is Size M/L, has many planned implementation steps, touches many files/packages, or otherwise looks oversized.
- Only create child tasks when \`breakIntoSubtasks: true\` is explicitly present; in that case, follow the mandatory \`## Triage subtask breakdown\` flow above exactly.
- When proactive splitting is disabled and \`breakIntoSubtasks: true\` is absent, write a normal PROMPT.md for the original task even if it is large; document realistic scope, risks, and quality gates instead of replacing it with child tasks.`;
}
return `For tasks you assess as Size M or L, consider whether splitting into 2-5 child tasks would improve execution quality. Default to keeping the task whole; only split when the work is genuinely large or has clearly independent deliverables.
**Consider splitting when ANY of these apply:**
- The task will require MORE THAN {{triageSubtaskStepThreshold}} implementation steps
- The task affects MORE THAN {{triageSubtaskPackageThreshold}} different packages/modules with distinct concerns (a typed field change that naturally touches core types + store + UI + tests is NOT 4 distinct concerns — it's one coherent change)
- Any single step would take more than 1-2 hours to complete
- The task has multiple clearly independent deliverables that could be developed and shipped in parallel by different people
**Splitting guidance:**
- Even when \`breakIntoSubtasks\` is not set to \`true\`, apply these thresholds proactively
- Keep explicit user intent first: when \`breakIntoSubtasks: true\`, follow the mandatory breakdown flow above
- Size S tasks should NOT be split — the overhead outweighs the benefit
- A task with 7-10 focused steps within a coherent scope is fine as one unit; do not split it
- Coordination overhead (worktrees, dependency wiring, merge sequencing) is real — only split when the parallelism or scope-clarity benefit clearly outweighs it
- If you decide not to split an M/L task, proceed with a normal PROMPT.md specification
**Broad-scope decomposition signals:**
- Size L tasks, especially when the planned step count would reach {{triageSubtaskLargeStepSignal}} or more.
- Plans whose implementation-step count would reach {{triageSubtaskAdditiveStepSignal}} or more (additive signal — counts even when the surrounding step-count threshold above has not yet fired).
- Tasks whose declared \`## File Scope\` would list {{triageSubtaskFileScopeThreshold}} or more entries.
- Descriptions that quantify large remediation batches (for example "47 failing tests", "30+ broken files") at or above {{triageSubtaskRemediationBatchThreshold}} items — treat as a strong signal that the work should be partitioned by subsystem or file group before specifying.
- When two or more of the signals above fire together, default to splitting via \`fn_task_create\`. If you still choose to keep the task as a single unit, justify the decision explicitly in the PROMPT.md \`## Mission\` paragraph.`;
}
return String(value ?? TRIAGE_POLICY_DEFAULTS.get(id) ?? ""); return String(value ?? TRIAGE_POLICY_DEFAULTS.get(id) ?? "");
} }

View File

@@ -216,6 +216,59 @@ describe("WorkflowSettingsPanel — Values tab", () => {
expect(screen.getByText(/Leave empty for unbounded automatic Code Review remediation/i)).toBeInTheDocument(); expect(screen.getByText(/Leave empty for unbounded automatic Code Review remediation/i)).toBeInTheDocument();
}); });
it("renders and saves the automatic large-task splitting workflow toggle once", async () => {
const triageToggle: WorkflowSettingDefinition[] = [
{
id: "triageProactiveSubtaskSplittingEnabled",
name: "Triage proactive subtask splitting",
type: "boolean",
default: true,
},
];
mockFetchValues.mockResolvedValueOnce(
payload({ effective: { triageProactiveSubtaskSplittingEnabled: true } }),
);
mockUpdateValues
.mockResolvedValueOnce(
payload({
stored: { triageProactiveSubtaskSplittingEnabled: false },
effective: { triageProactiveSubtaskSplittingEnabled: false },
}),
)
.mockResolvedValueOnce(
payload({ effective: { triageProactiveSubtaskSplittingEnabled: true } }),
);
render(<Host initial={triageToggle} readOnly />);
const controls = await screen.findAllByLabelText("Automatic large-task splitting");
expect(controls).toHaveLength(1);
const toggle = controls[0] as HTMLInputElement;
expect(toggle.checked).toBe(true);
expect(screen.getByText(/Default enabled/i)).toBeInTheDocument();
expect(screen.getByText(/breakIntoSubtasks: true/i)).toBeInTheDocument();
expect(screen.queryByTestId("wf-settings-customized-triageProactiveSubtaskSplittingEnabled")).not.toBeInTheDocument();
fireEvent.click(toggle);
fireEvent.click(screen.getByTestId("wf-settings-save-values"));
await waitFor(() => expect(mockUpdateValues).toHaveBeenCalledWith(
"wf-1",
{ triageProactiveSubtaskSplittingEnabled: false },
"proj-1",
));
expect(screen.getByTestId("wf-settings-customized-triageProactiveSubtaskSplittingEnabled")).toBeInTheDocument();
const row = screen.getByTestId("wf-settings-value-triageProactiveSubtaskSplittingEnabled");
const clearButton = within(row).getByRole("button", { name: "Reset to default" });
fireEvent.click(clearButton);
fireEvent.click(screen.getByTestId("wf-settings-save-values"));
await waitFor(() => expect(mockUpdateValues).toHaveBeenLastCalledWith(
"wf-1",
{ triageProactiveSubtaskSplittingEnabled: null },
"proj-1",
));
});
it("batches three field edits into exactly ONE patch on Save values", async () => { it("batches three field edits into exactly ONE patch on Save values", async () => {
mockFetchValues.mockResolvedValue(payload({ effective: { "timeout-ms": 1000, "new-sessions": false } })); mockFetchValues.mockResolvedValue(payload({ effective: { "timeout-ms": 1000, "new-sessions": false } }));
render(<Host initial={decls} />); render(<Host initial={decls} />);

View File

@@ -101,6 +101,16 @@ const DISPLAY: Record<string, WorkflowSettingDisplay> = {
*/ */
description: "Leave empty for unbounded automatic Code Review remediation; set 0 to disable automatic revision.", description: "Leave empty for unbounded automatic Code Review remediation; set 0 to disable automatic revision.",
}, },
triageProactiveSubtaskSplittingEnabled: {
group: "steps",
label: "Automatic large-task splitting",
/*
* FNXC:TriagePolicy 2026-07-04-00:00:
* Workflow Settings is the canonical operator surface for this workflow/project policy. The copy must make the default enabled state clear and preserve trust that explicit `breakIntoSubtasks: true` requests still split even when automatic large-task splitting is off.
*/
description:
"Default enabled. When off, triage keeps oversized tasks whole unless breakIntoSubtasks: true is explicitly requested.",
},
workflowStepTimeoutMs: { workflowStepTimeoutMs: {
group: "steps", group: "steps",
label: "Step timeout", label: "Step timeout",

View File

@@ -75,6 +75,33 @@ describe("triage threshold workflow settings", () => {
} }
}); });
it("uses stored disabled proactive splitting policy while retaining explicit split instructions", async () => {
const rootDir = makeTempDir("fn-7491-triage-root-");
const globalDir = makeTempDir("fn-7491-triage-global-");
const store = new TaskStore(rootDir, globalDir, { inMemoryDb: true });
await store.init();
try {
const projectId = store.getWorkflowSettingsProjectId();
await store.updateWorkflowSettingValues("builtin:coding", projectId, {
triageProactiveSubtaskSplittingEnabled: false,
});
const effective = await resolveEffectiveSettingsById(store, "builtin:coding", projectId);
expect(effective.triageProactiveSubtaskSplittingEnabled).toBe(false);
const rendered = renderTriagePolicyPlaceholders(builtinPlanningPrompt(), effective);
expect(rendered).toContain("## Triage subtask breakdown");
expect(rendered).toContain("When the task includes `breakIntoSubtasks: true`, first decide whether it should be split");
expect(rendered).toContain("Proactive oversized-task splitting is DISABLED");
expect(rendered).toContain("Do NOT split solely because the task is Size M/L");
expect(rendered).toContain("Only create child tasks when `breakIntoSubtasks: true` is explicitly present");
expect(rendered).not.toContain("Even when `breakIntoSubtasks` is not set to `true`, apply these thresholds proactively");
expect(rendered).not.toContain("{{");
} finally {
store.close();
}
});
it("keeps migrated threshold numbers out of the triage prompt assembly code path", async () => { it("keeps migrated threshold numbers out of the triage prompt assembly code path", async () => {
const source = await readFile(new URL("../triage.ts", import.meta.url), "utf8"); const source = await readFile(new URL("../triage.ts", import.meta.url), "utf8");
const promptAssembly = source.slice( const promptAssembly = source.slice(
@@ -84,6 +111,7 @@ describe("triage threshold workflow settings", () => {
expect(promptAssembly).toContain("renderTriagePolicyPlaceholders"); expect(promptAssembly).toContain("renderTriagePolicyPlaceholders");
expect(promptAssembly).not.toMatch(/\b(?:7|9|12|20|30)\b/); expect(promptAssembly).not.toMatch(/\b(?:7|9|12|20|30)\b/);
expect(promptAssembly).not.toMatch(/triageProactiveSubtaskSplittingEnabled\s*[:=]\s*(?:true|false)/);
expect(promptAssembly).not.toMatch(/builtin:quick-fix|builtin:coding/); expect(promptAssembly).not.toMatch(/builtin:quick-fix|builtin:coding/);
expect(promptAssembly).not.toMatch(/Decide|Evaluate|Verify|Confirm|Audit|Review whether|Investigate and report/); expect(promptAssembly).not.toMatch(/Decide|Evaluate|Verify|Confirm|Audit|Review whether|Investigate and report/);
}); });

View File

@@ -701,10 +701,11 @@ describe("canonical triage policy prompt", () => {
expect(TRIAGE_POLICY_PROMPT).toContain( expect(TRIAGE_POLICY_PROMPT).toContain(
"## Proactive Subtask Breakdown for M/L Tasks", "## Proactive Subtask Breakdown for M/L Tasks",
); );
expect(TRIAGE_POLICY_PROMPT).toContain( expect(TRIAGE_POLICY_PROMPT).toContain("{{triageProactiveSubtaskSplittingEnabled}}");
expect(RENDERED_TRIAGE_POLICY_PROMPT).toContain(
"Even when `breakIntoSubtasks` is not set to `true`", "Even when `breakIntoSubtasks` is not set to `true`",
); );
expect(TRIAGE_POLICY_PROMPT).toContain( expect(RENDERED_TRIAGE_POLICY_PROMPT).toContain(
"Size S tasks should NOT be split", "Size S tasks should NOT be split",
); );
}); });
@@ -714,13 +715,13 @@ describe("canonical triage policy prompt", () => {
expect(RENDERED_TRIAGE_POLICY_PROMPT).toContain( expect(RENDERED_TRIAGE_POLICY_PROMPT).toContain(
"MORE THAN 3 different packages/modules", "MORE THAN 3 different packages/modules",
); );
expect(TRIAGE_POLICY_PROMPT).toContain("MORE THAN {{triageSubtaskStepThreshold}} implementation steps"); expect(RENDERED_TRIAGE_POLICY_PROMPT).not.toContain("{{triageSubtaskStepThreshold}}");
}); });
it("biases toward keeping tasks whole and acknowledges coordination overhead", () => { it("biases toward keeping tasks whole and acknowledges coordination overhead", () => {
expect(TRIAGE_POLICY_PROMPT).toContain("Default to keeping the task whole"); expect(RENDERED_TRIAGE_POLICY_PROMPT).toContain("Default to keeping the task whole");
expect(TRIAGE_POLICY_PROMPT).toContain("Coordination overhead"); expect(RENDERED_TRIAGE_POLICY_PROMPT).toContain("Coordination overhead");
expect(TRIAGE_POLICY_PROMPT).toContain( expect(RENDERED_TRIAGE_POLICY_PROMPT).toContain(
"7-10 focused steps within a coherent scope is fine as one unit", "7-10 focused steps within a coherent scope is fine as one unit",
); );
}); });
@@ -901,6 +902,54 @@ describe("fast-mode triage", () => {
expect(capturedSystemPrompt).toContain("## Review Level"); expect(capturedSystemPrompt).toContain("## Review Level");
}); });
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-");
const detail = { ...mockTaskDetail, id: task.id, breakIntoSubtasks: true, attachments: [], comments: [] };
const store = createMockStore({
getTask: vi.fn().mockResolvedValue(detail),
getSettings: vi.fn().mockResolvedValue({
maxConcurrent: 2,
maxWorktrees: 4,
pollIntervalMs: 10000,
groupOverlappingFiles: false,
autoMerge: true,
triageProactiveSubtaskSplittingEnabled: false,
} as Settings),
});
let capturedSystemPrompt = "";
const { promptWithFallback } = await import("../pi.js");
(promptWithFallback as ReturnType<typeof vi.fn>).mockImplementationOnce(async (_session: unknown, prompt: string) => {
await mkdir(join(rootDir, ".fusion", "tasks", "FN-FAST-003"), { recursive: true }).catch(() => undefined);
await writeFile(join(rootDir, ".fusion", "tasks", "FN-FAST-003", "PROMPT.md"), "# Task: FN-FAST-003 - Split\n\n## Mission\n\nDone.", { flag: "w" }).catch(() => undefined);
expect(prompt).toContain("## Subtask Breakdown Requested");
expect(prompt).toContain("The user has requested that this task be broken into smaller subtasks");
expect(prompt).not.toContain("## Subtask Consideration");
});
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, rootDir);
await processor.specifyTask(task);
expect(capturedSystemPrompt).toContain("Proactive oversized-task splitting is DISABLED");
expect(capturedSystemPrompt).toContain("Only create child tasks when `breakIntoSubtasks: true` is explicitly present");
expect(capturedSystemPrompt).not.toContain("Even when `breakIntoSubtasks` is not set to `true`, apply these thresholds proactively");
expect(promptWithFallback).toHaveBeenCalled();
await cleanupTriageFixtureRoot(rootDir);
});
it("includes triage plugin contributions when provided", async () => { it("includes triage plugin contributions when provided", async () => {
const task = createTriageTask({ id: "FN-FAST-PLUGIN-001", executionMode: "standard" }); const task = createTriageTask({ id: "FN-FAST-PLUGIN-001", executionMode: "standard" });
const store = createMockStore({ const store = createMockStore({