feat(FN-2508): merge fusion/fn-2508

This commit is contained in:
gsxdsm
2026-04-25 13:21:02 -07:00
parent 20635652bc
commit dfb566ebc6
9 changed files with 102 additions and 52 deletions

View File

@@ -175,7 +175,7 @@ Defaults from `DEFAULT_PROJECT_SETTINGS`; key scope from `PROJECT_SETTINGS_KEYS`
| `showQuickChatFAB` | `boolean` | `false` | Show floating quick-chat button (chat remains available via More menu). |
| `experimentalFeatures` | `Record<string, boolean>` | `{}` | Project-scoped experimental feature flags. |
> **Note:** Agent `metadata.skills` is not a top-level project setting, but it is the primary mechanism for controlling execution-time skill selection. The engine's `buildSessionSkillContext` function reads this metadata from the assigned agent and uses it to resolve which skills are available in the agent session. If `metadata.skills` is absent or empty, the engine falls back to role-based skills (`executor`, `reviewer`, `merger`, `triage`).
> **Note:** Agent `metadata.skills` is not a top-level project setting, but it is the primary mechanism for controlling execution-time skill selection. The engine's `buildSessionSkillContext` function reads this metadata from the assigned agent and uses it to resolve which skills are available in the agent session. If `metadata.skills` is absent or empty, the engine falls back to the built-in `fusion` skill.
---

View File

@@ -219,7 +219,7 @@ describe("agent skills flow - full integration", () => {
it("flow with role fallback when assigned agent has no skills", async () => {
// Step 1: Set up mock filesystem
const projectRootDir = createMockProjectDir({
skills: ["+skills/executor/SKILL.md"],
skills: ["+skills/fusion/SKILL.md"],
});
// Step 2: Create mock AgentStore with agent that has NO skills
@@ -245,13 +245,13 @@ describe("agent skills flow - full integration", () => {
// Verify role fallback
expect(sessionResult.skillSource).toBe("role-fallback");
expect(sessionResult.resolvedSkillNames).toEqual(["executor"]);
expect(sessionResult.skillSelectionContext?.requestedSkillNames).toEqual(["executor"]);
expect(sessionResult.resolvedSkillNames).toEqual(["fusion"]);
expect(sessionResult.skillSelectionContext?.requestedSkillNames).toEqual(["fusion"]);
// Step 4: Resolve session skills from settings
const resolvedSkills = resolveSessionSkills(sessionResult.skillSelectionContext!);
expect(resolvedSkills.filterActive).toBe(true);
expect(resolvedSkills.allowedSkillPaths.has("skills/executor/SKILL.md")).toBe(true);
expect(resolvedSkills.allowedSkillPaths.has("skills/fusion/SKILL.md")).toBe(true);
});
});

View File

@@ -11493,22 +11493,29 @@ describe("TaskExecutor skillSelection regression (FN-1511)", () => {
});
});
it("omits skillSelection when assigned agent has no skills", async () => {
it("uses role fallback skillSelection when assigned agent has no skills", async () => {
const args = await captureCreateFnAgentArgs({
assignedAgentId: "agent-001",
assignedAgentSkills: [],
});
expect(args).not.toBeNull();
// When no skills, skillSelection may be undefined or executor uses role fallback
// The key is it doesn't crash and handles the case gracefully
expect(args.skillSelection).toMatchObject({
projectRootDir: projectRoot,
requestedSkillNames: expect.arrayContaining(["fusion"]),
sessionPurpose: "executor",
});
});
it("omits skillSelection when no assigned agent", async () => {
it("uses role fallback skillSelection when no assigned agent", async () => {
const args = await captureCreateFnAgentArgs({});
expect(args).not.toBeNull();
// Legacy fallback: no skillSelection when no assigned agent
expect(args.skillSelection).toMatchObject({
projectRootDir: projectRoot,
requestedSkillNames: expect.arrayContaining(["fusion"]),
sessionPurpose: "executor",
});
});
});
@@ -11608,10 +11615,10 @@ describe("TaskExecutor skillSelection regression (FN-1511)", () => {
assignedAgentSkills: [],
});
// No explicit agent skills → executor falls back to role-based skill context
// No explicit agent skills → executor falls back to built-in fusion skill context
expect(ctorOptions.skillSelection).toMatchObject({
projectRootDir: projectRoot,
requestedSkillNames: expect.arrayContaining(["executor"]),
requestedSkillNames: expect.arrayContaining(["fusion"]),
sessionPurpose: "executor",
});
});
@@ -11619,10 +11626,10 @@ describe("TaskExecutor skillSelection regression (FN-1511)", () => {
it("uses role fallback skillSelection when no assigned agent", async () => {
const ctorOptions = await captureStepSessionCtorOptions({});
// No assigned agent → executor falls back to role-based skill context
// No assigned agent → executor falls back to built-in fusion skill context
expect(ctorOptions.skillSelection).toMatchObject({
projectRootDir: projectRoot,
requestedSkillNames: expect.arrayContaining(["executor"]),
requestedSkillNames: expect.arrayContaining(["fusion"]),
sessionPurpose: "executor",
});
});

View File

@@ -5444,10 +5444,10 @@ describe("aiMergeTask — skill selection resolver contract (FN-1510/FN-1511)",
vi.mocked(buildSessionSkillContext).mockResolvedValue({
skillSelectionContext: {
projectRootDir: "/tmp/root",
requestedSkillNames: ["merger"],
requestedSkillNames: ["fusion"],
sessionPurpose: "merger",
},
resolvedSkillNames: ["merger"],
resolvedSkillNames: ["fusion"],
skillSource: "role-fallback",
});
@@ -5482,7 +5482,7 @@ describe("aiMergeTask — skill selection resolver contract (FN-1510/FN-1511)",
const opts = firstCall[0];
expect(opts.skillSelection).toBeDefined();
expect(opts.skillSelection!.projectRootDir).toBe("/tmp/root");
expect(opts.skillSelection!.requestedSkillNames).toEqual(["merger"]);
expect(opts.skillSelection!.requestedSkillNames).toEqual(["fusion"]);
expect(opts.skillSelection!.sessionPurpose).toBe("merger");
});
@@ -5683,10 +5683,10 @@ describe("aiMergeTask — skill selection resolver contract (FN-1510/FN-1511)",
vi.mocked(buildSessionSkillContext).mockResolvedValue({
skillSelectionContext: {
projectRootDir: "/tmp/root",
requestedSkillNames: ["merger"],
requestedSkillNames: ["fusion"],
sessionPurpose: "merger",
},
resolvedSkillNames: ["merger"],
resolvedSkillNames: ["fusion"],
skillSource: "role-fallback",
});

View File

@@ -652,10 +652,10 @@ describe("reviewStep — skill selection resolver contract (FN-1510/FN-1511)", (
vi.mocked(buildSessionSkillContext).mockResolvedValue({
skillSelectionContext: {
projectRootDir: "/tmp/project",
requestedSkillNames: ["reviewer"],
requestedSkillNames: ["fusion"],
sessionPurpose: "reviewer",
},
resolvedSkillNames: ["reviewer"],
resolvedSkillNames: ["fusion"],
skillSource: "role-fallback",
});
@@ -680,7 +680,7 @@ describe("reviewStep — skill selection resolver contract (FN-1510/FN-1511)", (
const opts = mockedCreateFnAgent.mock.calls[0][0];
expect(opts.skillSelection).toBeDefined();
expect(opts.skillSelection!.projectRootDir).toBe("/tmp/project");
expect(opts.skillSelection!.requestedSkillNames).toEqual(["reviewer"]);
expect(opts.skillSelection!.requestedSkillNames).toEqual(["fusion"]);
expect(opts.skillSelection!.sessionPurpose).toBe("reviewer");
});
@@ -839,10 +839,10 @@ describe("reviewStep — skill selection resolver contract (FN-1510/FN-1511)", (
vi.mocked(buildSessionSkillContext).mockResolvedValue({
skillSelectionContext: {
projectRootDir: "/tmp/project",
requestedSkillNames: ["reviewer"],
requestedSkillNames: ["fusion"],
sessionPurpose: "reviewer",
},
resolvedSkillNames: ["reviewer"],
resolvedSkillNames: ["fusion"],
skillSource: "role-fallback",
});

View File

@@ -171,7 +171,7 @@ describe("buildSessionSkillContextSync", () => {
const result = buildSessionSkillContextSync(agent, "executor", projectRootDir);
expect(result.skillSource).toBe("role-fallback");
expect(result.resolvedSkillNames).toEqual(["executor"]);
expect(result.resolvedSkillNames).toEqual(["fusion"]);
});
it("falls back to role when agent has no metadata", () => {
@@ -186,7 +186,7 @@ describe("buildSessionSkillContextSync", () => {
const result = buildSessionSkillContextSync(agent, "executor", projectRootDir);
expect(result.skillSource).toBe("role-fallback");
expect(result.resolvedSkillNames).toEqual(["executor"]);
expect(result.resolvedSkillNames).toEqual(["fusion"]);
});
it("falls back to role when agent has no metadata.skills", () => {
@@ -200,37 +200,37 @@ describe("buildSessionSkillContextSync", () => {
const result = buildSessionSkillContextSync(agent, "executor", projectRootDir);
expect(result.skillSource).toBe("role-fallback");
expect(result.resolvedSkillNames).toEqual(["executor"]);
expect(result.resolvedSkillNames).toEqual(["fusion"]);
});
});
describe("role fallback skills", () => {
it("returns triage role fallback for triage purpose", () => {
it("returns fusion role fallback for triage purpose", () => {
const result = buildSessionSkillContextSync(null, "triage", projectRootDir);
expect(result.skillSource).toBe("role-fallback");
expect(result.resolvedSkillNames).toEqual(["triage"]);
expect(result.resolvedSkillNames).toEqual(["fusion"]);
});
it("returns executor role fallback for executor purpose", () => {
it("returns fusion role fallback for executor purpose", () => {
const result = buildSessionSkillContextSync(null, "executor", projectRootDir);
expect(result.skillSource).toBe("role-fallback");
expect(result.resolvedSkillNames).toEqual(["executor"]);
expect(result.resolvedSkillNames).toEqual(["fusion"]);
});
it("returns reviewer role fallback for reviewer purpose", () => {
it("returns fusion role fallback for reviewer purpose", () => {
const result = buildSessionSkillContextSync(null, "reviewer", projectRootDir);
expect(result.skillSource).toBe("role-fallback");
expect(result.resolvedSkillNames).toEqual(["reviewer"]);
expect(result.resolvedSkillNames).toEqual(["fusion"]);
});
it("returns merger role fallback for merger purpose", () => {
it("returns fusion role fallback for merger purpose", () => {
const result = buildSessionSkillContextSync(null, "merger", projectRootDir);
expect(result.skillSource).toBe("role-fallback");
expect(result.resolvedSkillNames).toEqual(["merger"]);
expect(result.resolvedSkillNames).toEqual(["fusion"]);
});
it("returns no skills for heartbeat purpose (no role fallback)", () => {
@@ -342,7 +342,7 @@ describe("buildSessionSkillContext", () => {
});
expect(result.skillSource).toBe("role-fallback");
expect(result.resolvedSkillNames).toEqual(["executor"]);
expect(result.resolvedSkillNames).toEqual(["fusion"]);
});
it("falls back to role when no assignedAgentId", async () => {
@@ -358,7 +358,7 @@ describe("buildSessionSkillContext", () => {
});
expect(result.skillSource).toBe("role-fallback");
expect(result.resolvedSkillNames).toEqual(["executor"]);
expect(result.resolvedSkillNames).toEqual(["fusion"]);
expect(mockAgentStore.getAgent).not.toHaveBeenCalled();
});
@@ -375,7 +375,7 @@ describe("buildSessionSkillContext", () => {
});
expect(result.skillSource).toBe("role-fallback");
expect(result.resolvedSkillNames).toEqual(["triage"]);
expect(result.resolvedSkillNames).toEqual(["fusion"]);
});
it("falls back to role when agent lookup throws", async () => {
@@ -391,7 +391,7 @@ describe("buildSessionSkillContext", () => {
});
expect(result.skillSource).toBe("role-fallback");
expect(result.resolvedSkillNames).toEqual(["reviewer"]);
expect(result.resolvedSkillNames).toEqual(["fusion"]);
});
it("uses heartbeat with no skills when no assigned agent", async () => {
@@ -429,8 +429,8 @@ describe("SKILL_DIAGNOSTIC_MESSAGES", () => {
});
it("provides role fallback message template", () => {
const msg = SKILL_DIAGNOSTIC_MESSAGES.roleFallbackSkills("triage", ["triage"]);
expect(msg).toBe("Using role fallback skills for triage: [triage]");
const msg = SKILL_DIAGNOSTIC_MESSAGES.roleFallbackSkills("triage", ["fusion"]);
expect(msg).toBe("Using role fallback skills for triage: [fusion]");
});
it("provides no skills available message template", () => {

View File

@@ -745,6 +745,36 @@ describe("createSkillsOverrideFromSelection", () => {
expect(hasExecutorPrefix).toBe(true);
});
it("does not emit missing-skill warnings for built-in fusion fallback requests", () => {
const selection: SkillSelectionResult = {
allowedSkillPaths: new Set<string>(),
excludedSkillPaths: new Set<string>(),
diagnostics: [],
filterActive: true,
};
const override = createSkillsOverrideFromSelection(selection, {
requestedSkillNames: ["fusion"],
sessionPurpose: "reviewer",
});
const result = override({
skills: [],
diagnostics: [],
});
expect(result.skills).toHaveLength(0);
expect(
result.diagnostics.find((d) =>
d.type === "warning"
&& d.message.includes("Requested skill 'fusion' not found in discovered skills"),
),
).toBeUndefined();
expect(
mockPiLog.warn.mock.calls.some((c) => (c[0] as string).includes("Requested skill 'fusion' not found")),
).toBe(false);
});
it("uses structured piLog.warn for skill override diagnostics", () => {
const selection: SkillSelectionResult = {
allowedSkillPaths: new Set(["/path/ghost"]),

View File

@@ -11,11 +11,11 @@
* with valid normalized skills in `agent.metadata.skills`, those skills are used.
*
* 2. **Role Fallback Skills**: If assigned agent is missing or has no valid skills,
* use subsystem role fallback mapping:
* - `triage` → `triage`
* - `executor` / `step-session` → `executor`
* - `reviewer` → `reviewer`
* - `merger` → `merger`
* use the built-in Fusion skill fallback mapping:
* - `triage` → `fusion`
* - `executor` / `step-session` → `fusion`
* - `reviewer` → `fusion`
* - `merger` → `fusion`
* - `heartbeat` → no role fallback (use waking agent only)
*
* 3. **No Skills**: If neither source provides valid skills, pass no requested skills.
@@ -36,7 +36,7 @@ import type { SkillSelectionContext } from "./skill-resolver.js";
/**
* Session purpose for skill selection context.
* Maps to role fallback skills when no assigned agent is available.
* Maps to built-in fallback skills when no assigned agent is available.
*/
export type SessionPurpose = "triage" | "executor" | "reviewer" | "merger" | "heartbeat";
@@ -114,10 +114,10 @@ export function normalizeAgentSkills(
* Heartbeat has no role fallback (uses waking agent only).
*/
const ROLE_FALLBACK_SKILLS: Record<Exclude<SessionPurpose, "heartbeat">, string[]> = {
triage: ["triage"],
executor: ["executor"],
reviewer: ["reviewer"],
merger: ["merger"],
triage: ["fusion"],
executor: ["fusion"],
reviewer: ["fusion"],
merger: ["fusion"],
};
/**

View File

@@ -301,6 +301,16 @@ export function createSkillsOverrideFromSelection(
const { allowedSkillPaths, excludedSkillPaths, filterActive } = selection;
const { requestedSkillNames, sessionPurpose } = options;
const isBuiltInFallbackRequest = (name: string): boolean => {
const purposeUsesRoleFallback = sessionPurpose === "triage"
|| sessionPurpose === "executor"
|| sessionPurpose === "reviewer"
|| sessionPurpose === "merger";
return purposeUsesRoleFallback
&& requestedSkillNames?.length === 1
&& name.toLowerCase() === "fusion";
};
return (base: { skills: Skill[]; diagnostics: ResourceDiagnostic[] }) => {
// If filtering is not active, return base unchanged
if (!filterActive) {
@@ -374,7 +384,10 @@ export function createSkillsOverrideFromSelection(
if (requestedSkillNames) {
const discoveredNamesLower = new Set(base.skills.map((s) => s.name.toLowerCase()));
for (const requestedName of requestedSkillNames) {
if (!discoveredNamesLower.has(requestedName.toLowerCase())) {
if (
!discoveredNamesLower.has(requestedName.toLowerCase())
&& !isBuiltInFallbackRequest(requestedName)
) {
const purpose = sessionPurpose ? ` [${sessionPurpose}]` : "";
newDiagnostics.push({
type: "warning",