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:
@@ -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
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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 } : {}),
|
||||
|
||||
Reference in New Issue
Block a user