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:
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.
| 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)`). |
| `triageSizeMediumMaxHours` | `4` | Size M upper hour boundary (`M (2-4h)`). |
| `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. |
| `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,
Reviewer, and declared fallback dropdown controls for the default workflow. The
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
declaration (drop-on-orphan) and falling back to the default.
The **step-execution**, **review/approval**, and **per-phase model-lane** knobs that
used to be project settings are now workflow settings declared by `builtin:coding`
with their former defaults. See
The **step-execution**, **review/approval**, **per-phase model-lane**, and
**triage/spec policy** knobs are workflow settings declared by `builtin:coding`.
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)
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.toMatch(/export const [A-Z_]*TRIAGE[A-Z_]*SYSTEM_PROMPT\s*=/);
expect(planningPrompt).toBe(corePrompt);
expect(corePrompt).toContain("**Broad-scope decomposition signals:**");
expect(corePrompt).toContain("step count would reach {{triageSubtaskLargeStepSignal}} or more");
expect(corePrompt).toContain("would reach {{triageSubtaskAdditiveStepSignal}} or more");
expect(corePrompt).toContain("{{triageSubtaskFileScopeThreshold}} or more entries");
expect(corePrompt).toContain("at or above {{triageSubtaskRemediationBatchThreshold}} items");
expect(corePrompt).toContain("{{triageProactiveSubtaskSplittingEnabled}}");
expect(corePrompt).toContain("Explicit user-requested `breakIntoSubtasks: true` remains governed");
const renderedPrompt = renderTriagePolicyPlaceholders(corePrompt, {});
expect(renderedPrompt).toContain("**Broad-scope decomposition signals:**");
expect(renderedPrompt).toContain("step count would reach 9 or more");
expect(renderedPrompt).toContain("would reach 12 or more");
expect(renderedPrompt).toContain("20 or more entries");
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("{{");
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", () => {

View File

@@ -9,6 +9,7 @@ import {
import { MOVED_SETTINGS_KEYS } from "../moved-settings.js";
const expectedDefaults: Record<string, { type: string; default: unknown }> = {
triageProactiveSubtaskSplittingEnabled: { type: "boolean", default: true },
triageSizeSmallMaxHours: { type: "number", default: 2 },
triageSizeMediumMaxHours: { type: "number", default: 4 },
triageSizeLargeMaxHours: { type: "number", default: 8 },
@@ -98,4 +99,26 @@ describe("workflow-native built-in workflow settings", () => {
expect(rendered).not.toContain("{{");
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.
## 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.
**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.
<!--
FNXC:TriagePolicy 2026-07-04-00:00:
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.
-->
{{triageProactiveSubtaskSplittingEnabled}}
## Triage tools
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[] = [
{
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",
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);
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) ?? "");
}

View File

@@ -216,6 +216,59 @@ describe("WorkflowSettingsPanel — Values tab", () => {
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 () => {
mockFetchValues.mockResolvedValue(payload({ effective: { "timeout-ms": 1000, "new-sessions": false } }));
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.",
},
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: {
group: "steps",
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 () => {
const source = await readFile(new URL("../triage.ts", import.meta.url), "utf8");
const promptAssembly = source.slice(
@@ -84,6 +111,7 @@ describe("triage threshold workflow settings", () => {
expect(promptAssembly).toContain("renderTriagePolicyPlaceholders");
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(/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(
"## 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`",
);
expect(TRIAGE_POLICY_PROMPT).toContain(
expect(RENDERED_TRIAGE_POLICY_PROMPT).toContain(
"Size S tasks should NOT be split",
);
});
@@ -714,13 +715,13 @@ describe("canonical triage policy prompt", () => {
expect(RENDERED_TRIAGE_POLICY_PROMPT).toContain(
"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", () => {
expect(TRIAGE_POLICY_PROMPT).toContain("Default to keeping the task whole");
expect(TRIAGE_POLICY_PROMPT).toContain("Coordination overhead");
expect(TRIAGE_POLICY_PROMPT).toContain(
expect(RENDERED_TRIAGE_POLICY_PROMPT).toContain("Default to keeping the task whole");
expect(RENDERED_TRIAGE_POLICY_PROMPT).toContain("Coordination overhead");
expect(RENDERED_TRIAGE_POLICY_PROMPT).toContain(
"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");
});
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 () => {
const task = createTriageTask({ id: "FN-FAST-PLUGIN-001", executionMode: "standard" });
const store = createMockStore({