FN-7145: verify CE workflow skill loading

Add coverage and documentation for skill-backed workflow step loading.

- Derive compound-engineering skill-step expectations from the built-in workflow definition.
- Assert every skill-backed step requests namespaced and bare skills and uses injected CE skill paths.
- Cover missing FUSION_CE_SKILLS_DIR warnings, skill-less steps, and toolMode-independent skill loading.
- Document the skill-backed workflow execution invariant.

Files changed:
 docs/workflow-steps.md                             |   9 ++
 .../__tests__/ce-workflow-step-executor.test.ts    | 112 +++++++++++++++++++--
 2 files changed, 115 insertions(+), 6 deletions(-)

Fusion-Task-Id: FN-7145
Fusion-Task-Lineage: 1a344fa7-fa0c-4f04-af5a-1a90d48cd34c
Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-06-27 17:17:06 -07:00
parent 9e7c57da08
commit 13d97b7c33
2 changed files with 115 additions and 6 deletions

View File

@@ -51,6 +51,15 @@ Decision-only or investigation tasks can also declare `noCommitsExpected` / `**N
| PR lifecycle | `builtin:pr-workflow` | Reusable PR lifecycle graph fragment (create PR → await review → respond → gate → merge); it is a fragment, not directly selectable as a task workflow. |
| Lead generation | `builtin:lead-generation` | Selectable business workflow for sourcing, qualifying, enriching, and contacting leads with custom lead fields, stage columns, and reviewable enrichment/outreach task documents; requires the workflow graph executor for custom board columns. |
### Skill-backed workflow steps
<!--
FNXC:WorkflowSteps 2026-06-27-17:05:
FN-7145 locks the execution invariant for skill-backed workflow nodes: naming a skill must load the skill into the step session, not only mention it in prompt text. The engine passes both namespaced and bare request forms plus the injected Compound Engineering discovery path so bundled `SKILL.md` files are discoverable, and logs a loud warning when that path is missing.
-->
Skill-backed prompt/gate nodes run through the same workflow-step session builder as other prompt nodes, but their `skillName` is also treated as a resource-loading request. At execution time Fusion merges both the namespaced form (for example `compound-engineering:ce-work`) and the bare form (`ce-work`) into `requestedSkillNames`, then threads the injected `FUSION_CE_SKILLS_DIR` value as `additionalSkillPaths` so the bundled `SKILL.md` can be discovered. If a step names a skill but `FUSION_CE_SKILLS_DIR` is absent, the executor logs a `[skill-load]` warning instead of silently presenting only prompt text while falling back to role skills.
### Custom workflow authoring
Use the dashboard [Workflow Editor](./workflow-editor.md) to inspect built-ins, tune built-in prompts, duplicate workflows, or author custom workflows. Custom workflows can declare graph nodes and edges, columns/traits, task fields, typed workflow settings, model lanes, optional workflow-step templates, and author-time validation. Use this page for runtime semantics; use the editor guide for the visual authoring surface.

View File

@@ -24,6 +24,7 @@
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { BUILTIN_WORKFLOWS } from "@fusion/core";
import "./executor-test-helpers.js";
import { TaskExecutor } from "../executor.js";
import {
@@ -128,6 +129,37 @@ function makeStep(overrides: Record<string, unknown> = {}) {
};
}
type CeSkillStep = {
nodeId: string;
name: string;
skillName: string;
bareSkillName: string;
toolMode: "coding" | "readonly";
};
function compoundEngineeringSkillSteps(): CeSkillStep[] {
const workflow = BUILTIN_WORKFLOWS.find((wf) => wf.id === "builtin:compound-engineering");
if (!workflow) throw new Error("builtin:compound-engineering workflow not found");
return workflow.ir.nodes
.filter((node: any) => typeof node.config?.skillName === "string" && node.config.skillName.trim())
.map((node: any) => {
const skillName = node.config.skillName.trim();
return {
nodeId: node.id,
name: typeof node.config.name === "string" && node.config.name.trim() ? node.config.name.trim() : node.id,
skillName,
bareSkillName: skillName.includes(":") ? skillName.slice(skillName.lastIndexOf(":") + 1) : skillName,
toolMode: node.config.toolMode === "coding" ? "coding" : "readonly",
};
});
}
function skillLoadWarnings(store: ReturnType<typeof createMockStore>): string[] {
return store.logEntry.mock.calls
.map((call: unknown[]) => String(call[1] ?? ""))
.filter((message: string) => message.includes("[skill-load]"));
}
/** captureModifiedFiles / git diff calls go through the mocked execSync→exec. */
function quietGit() {
mockedExecSync.mockImplementation(() => Buffer.from(""));
@@ -267,28 +299,69 @@ describe("CE workflow-step executor integration", () => {
// ── Item 2 (integration half): skillName → requestedSkillNames + paths ───────
describe("executeWorkflowStep skill merge (U1)", () => {
it("merges the step skillName as BOTH namespaced and bare into requestedSkillNames, and threads FUSION_CE_SKILLS_DIR as additionalSkillPaths", async () => {
const ceSkillSteps = compoundEngineeringSkillSteps();
it("derives every skill-bearing step from the built-in compound-engineering workflow", () => {
expect(ceSkillSteps.map((step) => step.skillName)).toEqual([
"compound-engineering:ce-plan",
"compound-engineering:ce-work",
"compound-engineering:ce-code-review",
"compound-engineering:ce-commit-push-pr",
"compound-engineering:ce-resolve-pr-feedback",
"compound-engineering:ce-compound",
]);
});
it.each(ceSkillSteps)(
"loads named skill for built-in CE step $nodeId ($skillName)",
async ({ name, skillName, bareSkillName, toolMode }) => {
const store = createMockStore();
const { executor } = makeExecutor(store);
const cap = captureSession();
const ceSkillsDir = `/opt/ce/.fusion-ce-skills/${bareSkillName}`;
await (executor as any).executeWorkflowStep(
baseStepTask(),
makeStep({ name, skillName, toolMode }),
"/tmp/wt",
{},
{ FUSION_CE_SKILLS_DIR: ceSkillsDir },
undefined,
);
const requested = cap.last?.skillSelection?.requestedSkillNames ?? [];
expect(requested).toContain(skillName);
expect(requested).toContain(bareSkillName);
// The install root from the injected env becomes the discovery path for every CE skill step.
expect(cap.last?.additionalSkillPaths).toEqual([ceSkillsDir]);
expect(skillLoadWarnings(store)).toEqual([]);
},
);
it("warns loudly and does not set additionalSkillPaths when a skill step lacks FUSION_CE_SKILLS_DIR", async () => {
const store = createMockStore();
const { executor } = makeExecutor(store);
const cap = captureSession();
await (executor as any).executeWorkflowStep(
baseStepTask(),
makeStep({ skillName: "compound-engineering:ce-work" }),
makeStep({ name: "Execute", skillName: "compound-engineering:ce-work", toolMode: "coding" }),
"/tmp/wt",
{},
{ FUSION_CE_SKILLS_DIR: "/opt/ce/.fusion-ce-skills" },
undefined,
undefined,
);
const requested = cap.last?.skillSelection?.requestedSkillNames ?? [];
expect(requested).toContain("compound-engineering:ce-work");
expect(requested).toContain("ce-work");
// The install root from the injected env becomes the discovery path.
expect(cap.last?.additionalSkillPaths).toEqual(["/opt/ce/.fusion-ce-skills"]);
expect(cap.last?.additionalSkillPaths).toBeUndefined();
expect(skillLoadWarnings(store)).toEqual([
"[skill-load] Workflow step 'Execute' requests skill 'compound-engineering:ce-work' but FUSION_CE_SKILLS_DIR is unset — the skill cannot be discovered; the step runs with role-fallback skills only.",
]);
});
it("a skill-less step contributes no skillName merge and no additionalSkillPaths", async () => {
it("a skill-less step contributes no skillName merge, no additionalSkillPaths, and no skill-load warning", async () => {
const store = createMockStore();
const { executor } = makeExecutor(store);
const cap = captureSession();
@@ -302,9 +375,36 @@ describe("CE workflow-step executor integration", () => {
undefined,
);
const requested = cap.last?.skillSelection?.requestedSkillNames ?? [];
expect(requested.some((name) => name.startsWith("compound-engineering:") || name.startsWith("ce-"))).toBe(false);
// No CE skills dir injected → no additionalSkillPaths.
expect(cap.last?.additionalSkillPaths).toBeUndefined();
expect(skillLoadWarnings(store)).toEqual([]);
});
it.each(["coding", "readonly"] as const)(
"keeps skill loading independent of %s toolMode",
async (toolMode) => {
const store = createMockStore();
const { executor } = makeExecutor(store);
const cap = captureSession();
await (executor as any).executeWorkflowStep(
baseStepTask(),
makeStep({ skillName: "compound-engineering:ce-code-review", toolMode }),
"/tmp/wt",
{},
{ FUSION_CE_SKILLS_DIR: "/opt/ce/.fusion-ce-skills" },
undefined,
);
const requested = cap.last?.skillSelection?.requestedSkillNames ?? [];
expect(requested).toContain("compound-engineering:ce-code-review");
expect(requested).toContain("ce-code-review");
expect(cap.last?.additionalSkillPaths).toEqual(["/opt/ce/.fusion-ce-skills"]);
expect(skillLoadWarnings(store)).toEqual([]);
},
);
});
// ── Item 4: spawn-tool gating by toolMode ───────────────────────────────────