FN-7107: load CE stage skills for interactive sessions

Ensure Compound Engineering stages request and expose their registered skills when launching interactive sessions.

- Add plugin-local skill resolution checks that warn when a stage skill install is missing.
- Pass a stable additional skill path and requested stage skill into every CE interactive session.
- Add coverage for stage skill loading options and update skill reachability expectations.

Files changed:
 ...d-skills-not-loading-in-interactive-sessions.md |  1 +
 .../src/__tests__/skill-reachability.test.ts       | 71 +++++++++++++---------
 .../src/__tests__/stage-skill-loading.test.ts      | 69 +++++++++++++++++++++
 .../src/session/orchestrator.ts                    | 48 ++++++++++++++-
 4 files changed, 157 insertions(+), 32 deletions(-)

Fusion-Task-Id: FN-7107

Fusion-Task-Lineage: 0504e9ba-c2e7-4317-9c90-55693baf8170

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-06-27 04:22:47 -07:00
parent f42b49ab2d
commit 04f8fe5d59
4 changed files with 157 additions and 32 deletions

View File

@@ -115,6 +115,7 @@ A plugin author shipping a bundled skill should:
2. **Forward both** seam options when starting the session: `requestedSkillNames: [skillId]` (so the resolver keeps it) **and** `additionalSkillPaths: [installRoot]` (so the loader discovers it). One without the other silently no-ops — a name with no discovered file filters to `[]`; a discovered file with no requested name can be filtered out.
3. Remember `skillsOverride` only filters — declaring a `PluginSkillContribution` is **name-only** and never injects skill content into a live session.
4. **Prove it with a real `DefaultResourceLoader`** (see `packages/engine/src/__tests__/compound-engineering-skill-resolution.test.ts`) that asserts the skill actually appears in the resolved session skills — a scripted/fake session cannot catch a discovery gap.
5. **For stage registries, iterate the registry instead of sampling.** Compound Engineering now treats each `listStages()` entry as a skill-loading invariant: `<stage.skillId>` must have bundled source frontmatter, an installed `<skillId>/SKILL.md`, a plugin-local discovery path, and session options that request the skill. A missing installed stage skill should emit a clear guard warning before session start, not silently run a degraded skill-less stage.
## Related Issues

View File

@@ -1,27 +1,27 @@
import { existsSync, mkdtempSync, rmSync } from "node:fs";
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { installBundledCeSkills } from "../skill-installation.js";
import { resolveStageSkillPaths, buildStageSystemPrompt } from "../session/orchestrator.js";
import { getStage } from "../session/stage-registry.js";
import { installBundledCeSkills, resolveBundledSkillsRoot } from "../skill-installation.js";
import {
buildStageSystemPrompt,
checkStageSkillResolution,
resolveStageSkillPaths,
} from "../session/orchestrator.js";
import { listStages } from "../session/stage-registry.js";
/**
* Prove the launched stage's ce-* skill is REACHABLE for the session — now via
* Prove every registered stage's ce-* skill is REACHABLE for the session — via
* the real seam wiring (closes the U2 → U5 carry-forward).
*
* The U4 `CreateInteractiveAiSessionOptions` surface now carries
* FNXC:CompoundEngineering 2026-06-27-15:44:
* CE stage skill coverage must iterate `listStages()` rather than sample stages so newly registered stages automatically prove the source → install → discovery-path → prompt contract before they can silently launch without their bundled skill.
*
* The U4 `CreateInteractiveAiSessionOptions` surface carries
* `requestedSkillNames` + `additionalSkillPaths`, which the engine adapter
* forwards into `createFnAgent` (`skills` + the loader's `additionalSkillPaths`).
* So the orchestrator hands the session BOTH the stage's skill id and the
* install directory to discover it from. This test asserts:
* 1. the install directory `resolveStageSkillPaths()` returns actually holds
* the stage's `<skillId>/SKILL.md` after install, and
* 2. the system prompt names the stage's skill id.
*
* The engine package separately proves (compound-engineering-skill-resolution
* .test.ts) that `loadSkills` + the resolver resolve a ce-* skill once that
* directory is on the discovery path — together the chain is closed.
* install directory to discover it from.
*/
describe("stage skill reachability (real seam wiring)", () => {
@@ -40,25 +40,36 @@ describe("stage skill reachability (real seam wiring)", () => {
expect(skillPaths[0]).not.toMatch(/\.(claude|codex|gemini)[/\\]skills/);
});
it.each(["brainstorm", "debug"])(
"installing bundled skills onto a discovery root produces %s's SKILL.md",
(stageId) => {
const stage = getStage(stageId)!;
// Install into a temp discovery root (isolated; mirrors what the real
// plugin-local install produces, without writing into the repo dir).
const target = mkdtempSync(join(tmpdir(), "ce-skill-reach-"));
tmpTargets.push(target);
const { results } = installBundledCeSkills({ targetRoot: target });
expect(results.every((r) => r.outcome === "installed" || r.outcome === "skipped")).toBe(true);
const installedSkillMd = join(target, stage.skillId, "SKILL.md");
expect(existsSync(installedSkillMd)).toBe(true);
it.each(listStages())(
"the $stageId stage has a bundled source SKILL.md with matching frontmatter name",
(stage) => {
const skillMd = join(resolveBundledSkillsRoot(), stage.skillId, "SKILL.md");
expect(existsSync(skillMd)).toBe(true);
const content = readFileSync(skillMd, "utf-8");
expect(content).toMatch(new RegExp(`^name:\\s*${stage.skillId}$`, "m"));
},
);
it.each(["brainstorm", "debug"])("the %s stage system prompt names the stage's ce-* skill id", (stageId) => {
const stage = getStage(stageId)!;
it.each(listStages())("installing bundled skills onto a discovery root produces $stageId's SKILL.md", (stage) => {
// Install into a temp discovery root (isolated; mirrors what the real
// plugin-local install produces, without writing into the repo dir).
const target = mkdtempSync(join(tmpdir(), "ce-skill-reach-"));
tmpTargets.push(target);
const { results } = installBundledCeSkills({ targetRoot: target });
const stageResult = results.find((r) => r.skillId === stage.skillId);
expect(stageResult?.outcome).toBe("installed");
const installedSkillMd = join(target, stage.skillId, "SKILL.md");
expect(existsSync(installedSkillMd)).toBe(true);
expect(checkStageSkillResolution(stage, [target])).toMatchObject({
skillId: stage.skillId,
found: true,
expectedSkillMdPaths: [installedSkillMd],
});
});
it.each(listStages())("the $stageId stage system prompt names the stage's ce-* skill id", (stage) => {
const prompt = buildStageSystemPrompt(stage);
expect(prompt).toContain(stage.skillId);
expect(prompt).toContain("question");

View File

@@ -0,0 +1,69 @@
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { CreateInteractiveAiSessionFactory } from "@fusion/core";
import { CeOrchestrator, warnIfStageSkillMissing } from "../session/orchestrator.js";
import { listStages } from "../session/stage-registry.js";
import { makeHarness, makeScriptedSession, type TestHarness } from "./_harness.js";
let h: TestHarness;
beforeEach(() => {
h = makeHarness();
});
afterEach(() => {
h.close();
vi.restoreAllMocks();
});
describe("CE stage skill loading session options", () => {
it.each(listStages())("starts $stageId with its registered skill selected and discoverable", async (stage) => {
const capturedOptions: Parameters<CreateInteractiveAiSessionFactory>[0][] = [];
const session = makeScriptedSession([{ type: "complete", data: { artifact: `# ${stage.stageId}` } }]);
const factory: CreateInteractiveAiSessionFactory = vi.fn(async (options) => {
capturedOptions.push(options);
return { session, sessionFile: join(h.projectRoot, `${stage.stageId}.json`) };
});
const orch = new CeOrchestrator({ ctx: h.ctx, createInteractiveAiSession: factory, projectRoot: h.projectRoot });
const result = await orch.start(stage.stageId, { openingMessage: `Run ${stage.stageId}` });
expect(result.session.status).toBe("completed");
expect(capturedOptions).toHaveLength(1);
expect(capturedOptions[0].requestedSkillNames).toEqual([stage.skillId]);
expect(capturedOptions[0].additionalSkillPaths).toHaveLength(1);
expect(capturedOptions[0].additionalSkillPaths?.[0]).toMatch(/\.fusion-ce-skills$/);
expect(capturedOptions[0].additionalSkillPaths?.[0]).not.toMatch(/\.(claude|codex|gemini)[/\\]skills/);
expect(capturedOptions[0].systemPrompt).toContain(stage.skillId);
});
it("warns loudly when a stage skill is missing from the install root", () => {
const stage = listStages()[0];
const missingRoot = mkdtempSync(join(tmpdir(), "ce-missing-skill-root-"));
try {
const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() };
const guard = warnIfStageSkillMissing(logger, stage, [missingRoot]);
expect(guard).toMatchObject({
skillId: stage.skillId,
found: false,
expectedSkillMdPaths: [join(missingRoot, stage.skillId, "SKILL.md")],
});
expect(logger.warn).toHaveBeenCalledTimes(1);
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining(stage.skillId),
expect.objectContaining({
stageId: stage.stageId,
skillId: stage.skillId,
expectedSkillMdPaths: [join(missingRoot, stage.skillId, "SKILL.md")],
additionalSkillPaths: [missingRoot],
}),
);
} finally {
rmSync(missingRoot, { recursive: true, force: true });
}
});
});

View File

@@ -1,4 +1,4 @@
import { mkdirSync, writeFileSync } from "node:fs";
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
import { dirname, isAbsolute, join } from "node:path";
import type {
CreateInteractiveAiSessionFactory,
@@ -149,6 +149,48 @@ export function resolveStageSkillPaths(): string[] {
return [resolveDefaultInstallTargetRoot()];
}
export interface StageSkillResolutionGuardResult {
skillId: string;
expectedSkillMdPaths: string[];
found: boolean;
}
/**
* FNXC:CompoundEngineering 2026-06-27-15:33:
* Every interactive CE stage must launch with its registered `ce-*` skill both selected and discoverable. If the plugin-local install is missing `<skillId>/SKILL.md`, surface that before session creation so operators see a skill-loading fault instead of a silent degraded stage.
*/
export function checkStageSkillResolution(
stage: CeStageDefinition,
skillPaths: string[] = resolveStageSkillPaths(),
): StageSkillResolutionGuardResult {
const expectedSkillMdPaths = skillPaths.map((root) => join(root, stage.skillId, "SKILL.md"));
return {
skillId: stage.skillId,
expectedSkillMdPaths,
found: expectedSkillMdPaths.some((skillMd) => existsSync(skillMd)),
};
}
export function warnIfStageSkillMissing(
logger: PluginContext["logger"],
stage: CeStageDefinition,
additionalSkillPaths: string[] = resolveStageSkillPaths(),
): StageSkillResolutionGuardResult {
const guard = checkStageSkillResolution(stage, additionalSkillPaths);
if (!guard.found) {
logger.warn(
`Compound Engineering stage '${stage.stageId}' requested skill '${stage.skillId}', but no SKILL.md was found on the plugin-local discovery paths. The session will still request the skill so the engine can resolve it if installation completes, but the current install appears missing.`,
{
stageId: stage.stageId,
skillId: stage.skillId,
expectedSkillMdPaths: guard.expectedSkillMdPaths,
additionalSkillPaths,
},
);
}
return guard;
}
/**
* Build the system prompt: instruct the agent to (a) apply the named ce-* skill
* and (b) emit the JSON question/complete protocol the U4 seam parses.
@@ -252,12 +294,14 @@ export class CeOrchestrator {
): Parameters<CreateInteractiveAiSessionFactory>[0] {
const defaultProvider = getDefaultProvider(this.ctx.settings);
const defaultModelId = getDefaultModelId(this.ctx.settings);
const additionalSkillPaths = resolveStageSkillPaths();
warnIfStageSkillMissing(this.ctx.logger, stage, additionalSkillPaths);
return {
cwd: this.projectRoot,
systemPrompt: buildStageSystemPrompt(stage),
tools: "coding",
requestedSkillNames: [stage.skillId],
additionalSkillPaths: resolveStageSkillPaths(),
additionalSkillPaths,
onProgress: (event) => this.handleProgress(sessionId, event),
...(defaultProvider ? { defaultProvider } : {}),
...(defaultModelId ? { defaultModelId } : {}),