feat(FN-2508): merge fusion/fn-2508
This commit is contained in:
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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"]),
|
||||
|
||||
@@ -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"],
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user