From 13d97b7c33ef6dc532bdecf6ab1eae1029e237ee Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sat, 27 Jun 2026 17:17:06 -0700 Subject: [PATCH] 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) --- docs/workflow-steps.md | 9 ++ .../ce-workflow-step-executor.test.ts | 112 +++++++++++++++++- 2 files changed, 115 insertions(+), 6 deletions(-) diff --git a/docs/workflow-steps.md b/docs/workflow-steps.md index dc9877bd30..f992a5bb8f 100644 --- a/docs/workflow-steps.md +++ b/docs/workflow-steps.md @@ -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 + + + +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. diff --git a/packages/engine/src/__tests__/ce-workflow-step-executor.test.ts b/packages/engine/src/__tests__/ce-workflow-step-executor.test.ts index 3c3ff5cc13..720c4eb8a9 100644 --- a/packages/engine/src/__tests__/ce-workflow-step-executor.test.ts +++ b/packages/engine/src/__tests__/ce-workflow-step-executor.test.ts @@ -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 = {}) { }; } +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): 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 ───────────────────────────────────