feat(FN-3117): add plugin dashboard views, chat session icons, and SQLite t
This release (v0.15.0) brings significant plugin system enhancements including a new dependency graph plugin with dashboard view, plugin skills in session selection, and extended plugin UI slot metadata. Database improvements add SQLite WAL tuning, integrity checks, and batch writes for agent logs. Fusion-Task-Id: FN-3117
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import type { AgentStore } from "@fusion/core";
|
||||
import type { PluginRunner } from "../plugin-runner.js";
|
||||
import { buildSessionSkillContext } from "../session-skill-context.js";
|
||||
import { createSkillsOverrideFromSelection, resolveSessionSkills } from "../skill-resolver.js";
|
||||
import type { Skill } from "@mariozechner/pi-coding-agent";
|
||||
|
||||
describe("plugin skill integration", () => {
|
||||
const projectRootDir = "/test/project";
|
||||
|
||||
it("merges enabled plugin skills, excludes disabled, and dedupes with agent skills", async () => {
|
||||
const agentStore = {
|
||||
getAgent: vi.fn().mockResolvedValue({
|
||||
id: "agent-1",
|
||||
metadata: { skills: ["fusion", "agent-only"] },
|
||||
}),
|
||||
} as unknown as AgentStore;
|
||||
|
||||
const pluginRunner = {
|
||||
getPluginSkills: vi.fn().mockReturnValue([
|
||||
{ pluginId: "plugin-a", skill: { name: "fusion", enabled: true } },
|
||||
{ pluginId: "plugin-a", skill: { name: "plugin-enabled", enabled: true } },
|
||||
{ pluginId: "plugin-b", skill: { name: "plugin-disabled", enabled: false } },
|
||||
]),
|
||||
} as unknown as PluginRunner;
|
||||
|
||||
const contextResult = await buildSessionSkillContext({
|
||||
agentStore,
|
||||
task: { assignedAgentId: "agent-1" },
|
||||
sessionPurpose: "executor",
|
||||
projectRootDir,
|
||||
pluginRunner,
|
||||
});
|
||||
|
||||
expect(contextResult.skillSelectionContext?.requestedSkillNames).toEqual([
|
||||
"fusion",
|
||||
"agent-only",
|
||||
"plugin-enabled",
|
||||
]);
|
||||
});
|
||||
|
||||
it("coexists with role fallback and flows through resolver pipeline", async () => {
|
||||
const agentStore = {
|
||||
getAgent: vi.fn().mockResolvedValue(null),
|
||||
} as unknown as AgentStore;
|
||||
const pluginRunner = {
|
||||
getPluginSkills: vi.fn().mockReturnValue([
|
||||
{ pluginId: "plugin-a", skill: { name: "plugin-skill", enabled: true } },
|
||||
]),
|
||||
} as unknown as PluginRunner;
|
||||
|
||||
const contextResult = await buildSessionSkillContext({
|
||||
agentStore,
|
||||
task: {},
|
||||
sessionPurpose: "triage",
|
||||
projectRootDir,
|
||||
pluginRunner,
|
||||
});
|
||||
|
||||
expect(contextResult.skillSelectionContext?.requestedSkillNames).toEqual(["fusion", "plugin-skill"]);
|
||||
|
||||
const resolved = resolveSessionSkills(contextResult.skillSelectionContext!);
|
||||
const skillsOverride = createSkillsOverrideFromSelection(resolved, {
|
||||
requestedSkillNames: contextResult.skillSelectionContext?.requestedSkillNames,
|
||||
sessionPurpose: contextResult.skillSelectionContext?.sessionPurpose,
|
||||
});
|
||||
const makeSkill = (name: string, filePath: string): Skill => ({
|
||||
name,
|
||||
description: `${name} description`,
|
||||
filePath,
|
||||
baseDir: "/skills",
|
||||
sourceInfo: {} as Skill["sourceInfo"],
|
||||
disableModelInvocation: false,
|
||||
});
|
||||
|
||||
const filtered = skillsOverride({
|
||||
skills: [
|
||||
makeSkill("fusion", "/skills/fusion/SKILL.md"),
|
||||
makeSkill("plugin-skill", "/skills/plugin/SKILL.md"),
|
||||
makeSkill("other", "/skills/other/SKILL.md"),
|
||||
],
|
||||
diagnostics: [],
|
||||
});
|
||||
|
||||
expect(contextResult.skillSelectionContext?.requestedSkillNames).toEqual(["fusion", "plugin-skill"]);
|
||||
expect(filtered.skills.map((skill) => skill.name)).toEqual(["fusion", "plugin-skill"]);
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,14 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import {
|
||||
normalizeAgentSkills,
|
||||
collectPluginSkillNames,
|
||||
buildSessionSkillContext,
|
||||
buildSessionSkillContextSync,
|
||||
SKILL_DIAGNOSTIC_MESSAGES,
|
||||
type SessionPurpose,
|
||||
} from "../session-skill-context.js";
|
||||
import type { Agent, AgentStore } from "@fusion/core";
|
||||
import type { PluginRunner } from "../plugin-runner.js";
|
||||
|
||||
describe("normalizeAgentSkills", () => {
|
||||
it("returns empty array for non-array input", () => {
|
||||
@@ -265,6 +267,62 @@ describe("buildSessionSkillContextSync", () => {
|
||||
expect(result.resolvedSkillNames).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
it("merges plugin skills in sync path", () => {
|
||||
const pluginRunner = {
|
||||
getPluginSkills: vi.fn().mockReturnValue([
|
||||
{ pluginId: "plugin-a", skill: { name: "plugin-skill" } },
|
||||
]),
|
||||
} as unknown as PluginRunner;
|
||||
|
||||
const result = buildSessionSkillContextSync(null, "executor", projectRootDir, pluginRunner);
|
||||
expect(result.resolvedSkillNames).toEqual(["fusion", "plugin-skill"]);
|
||||
});
|
||||
|
||||
it("keeps legacy behavior in sync path when pluginRunner is omitted", () => {
|
||||
const result = buildSessionSkillContextSync(null, "executor", projectRootDir);
|
||||
expect(result.resolvedSkillNames).toEqual(["fusion"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("collectPluginSkillNames", () => {
|
||||
it("returns empty arrays when pluginRunner is undefined", () => {
|
||||
expect(collectPluginSkillNames(undefined)).toEqual({ names: [], pluginIds: [] });
|
||||
});
|
||||
|
||||
it("returns empty arrays when no plugin skills are contributed", () => {
|
||||
const pluginRunner = { getPluginSkills: vi.fn().mockReturnValue([]) } as unknown as PluginRunner;
|
||||
expect(collectPluginSkillNames(pluginRunner)).toEqual({ names: [], pluginIds: [] });
|
||||
});
|
||||
|
||||
it("returns enabled plugin skill names and dedupes by first occurrence", () => {
|
||||
const pluginRunner = {
|
||||
getPluginSkills: vi.fn().mockReturnValue([
|
||||
{ pluginId: "plugin-a", skill: { name: "alpha" } },
|
||||
{ pluginId: "plugin-b", skill: { name: "beta" } },
|
||||
{ pluginId: "plugin-c", skill: { name: "alpha" } },
|
||||
]),
|
||||
} as unknown as PluginRunner;
|
||||
|
||||
expect(collectPluginSkillNames(pluginRunner)).toEqual({
|
||||
names: ["alpha", "beta"],
|
||||
pluginIds: ["plugin-a", "plugin-b"],
|
||||
});
|
||||
});
|
||||
|
||||
it("filters out disabled skills", () => {
|
||||
const pluginRunner = {
|
||||
getPluginSkills: vi.fn().mockReturnValue([
|
||||
{ pluginId: "plugin-a", skill: { name: "alpha", enabled: false } },
|
||||
{ pluginId: "plugin-b", skill: { name: "beta", enabled: true } },
|
||||
]),
|
||||
} as unknown as PluginRunner;
|
||||
|
||||
expect(collectPluginSkillNames(pluginRunner)).toEqual({
|
||||
names: ["beta"],
|
||||
pluginIds: ["plugin-b"],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildSessionSkillContext", () => {
|
||||
@@ -410,6 +468,73 @@ describe("buildSessionSkillContext", () => {
|
||||
expect(result.resolvedSkillNames).toEqual([]);
|
||||
expect(result.skillSelectionContext).toBeUndefined();
|
||||
});
|
||||
|
||||
it("appends plugin skills to requestedSkillNames", async () => {
|
||||
const mockAgentStore = { getAgent: vi.fn().mockResolvedValue(null) } as unknown as AgentStore;
|
||||
const pluginRunner = {
|
||||
getPluginSkills: vi.fn().mockReturnValue([
|
||||
{ pluginId: "plugin-a", skill: { name: "plugin-skill" } },
|
||||
]),
|
||||
} as unknown as PluginRunner;
|
||||
|
||||
const result = await buildSessionSkillContext({
|
||||
agentStore: mockAgentStore,
|
||||
task: {},
|
||||
sessionPurpose: "executor",
|
||||
projectRootDir,
|
||||
pluginRunner,
|
||||
});
|
||||
|
||||
expect(result.resolvedSkillNames).toEqual(["fusion", "plugin-skill"]);
|
||||
expect(result.skillSelectionContext?.requestedSkillNames).toEqual(["fusion", "plugin-skill"]);
|
||||
});
|
||||
|
||||
it("deduplicates plugin skills against assigned-agent skills case-insensitively", async () => {
|
||||
const mockAgent: Agent = {
|
||||
id: "agent-001",
|
||||
name: "Test Agent",
|
||||
role: "executor",
|
||||
state: "idle",
|
||||
metadata: { skills: ["Fusion"] },
|
||||
} as unknown as Agent;
|
||||
const mockAgentStore = { getAgent: vi.fn().mockResolvedValue(mockAgent) } as unknown as AgentStore;
|
||||
const pluginRunner = {
|
||||
getPluginSkills: vi.fn().mockReturnValue([
|
||||
{ pluginId: "plugin-a", skill: { name: "fusion" } },
|
||||
{ pluginId: "plugin-b", skill: { name: "plugin-skill" } },
|
||||
]),
|
||||
} as unknown as PluginRunner;
|
||||
|
||||
const result = await buildSessionSkillContext({
|
||||
agentStore: mockAgentStore,
|
||||
task: { assignedAgentId: "agent-001" },
|
||||
sessionPurpose: "executor",
|
||||
projectRootDir,
|
||||
pluginRunner,
|
||||
});
|
||||
|
||||
expect(result.resolvedSkillNames).toEqual(["Fusion", "plugin-skill"]);
|
||||
});
|
||||
|
||||
it("creates heartbeat skill context from plugin skills when no agent skills exist", async () => {
|
||||
const mockAgentStore = { getAgent: vi.fn() } as unknown as AgentStore;
|
||||
const pluginRunner = {
|
||||
getPluginSkills: vi.fn().mockReturnValue([
|
||||
{ pluginId: "plugin-a", skill: { name: "plugin-skill" } },
|
||||
]),
|
||||
} as unknown as PluginRunner;
|
||||
|
||||
const result = await buildSessionSkillContext({
|
||||
agentStore: mockAgentStore,
|
||||
task: {},
|
||||
sessionPurpose: "heartbeat",
|
||||
projectRootDir,
|
||||
pluginRunner,
|
||||
});
|
||||
|
||||
expect(result.skillSource).toBe("role-fallback");
|
||||
expect(result.skillSelectionContext?.requestedSkillNames).toEqual(["plugin-skill"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SKILL_DIAGNOSTIC_MESSAGES", () => {
|
||||
|
||||
@@ -1380,7 +1380,7 @@ export class HeartbeatMonitor {
|
||||
heartbeatLog.warn(`Failed to configure heartbeat memory tools for ${agentId}: ${message}`);
|
||||
}
|
||||
// Build skill selection context for heartbeat session (uses waking agent's skills, no role fallback)
|
||||
const skillContext = buildSessionSkillContextSync(agent, "heartbeat", rootDir);
|
||||
const skillContext = buildSessionSkillContextSync(agent, "heartbeat", rootDir, this.pluginRunner);
|
||||
|
||||
const baseHeartbeatSystemPrompt = isNoTaskRun
|
||||
? HEARTBEAT_NO_TASK_SYSTEM_PROMPT
|
||||
|
||||
@@ -2286,6 +2286,7 @@ export class TaskExecutor {
|
||||
task: detail,
|
||||
sessionPurpose: "executor",
|
||||
projectRootDir: this.rootDir,
|
||||
pluginRunner: this.options.pluginRunner,
|
||||
});
|
||||
|
||||
if (settings.runStepsInNewSessions) {
|
||||
@@ -4805,6 +4806,7 @@ and show an appropriate message to the user.\`
|
||||
task,
|
||||
sessionPurpose: "executor",
|
||||
projectRootDir: this.rootDir,
|
||||
pluginRunner: this.options.pluginRunner,
|
||||
});
|
||||
|
||||
const workflowAgent = task.assignedAgentId && this.options.agentStore
|
||||
@@ -6347,6 +6349,7 @@ Child agent: ${agent.id} (${name})`;
|
||||
task: childTask,
|
||||
sessionPurpose: "executor",
|
||||
projectRootDir: this.rootDir,
|
||||
pluginRunner: this.options.pluginRunner,
|
||||
});
|
||||
const parentAgent = childTask.assignedAgentId
|
||||
? await this.options.agentStore.getAgent(childTask.assignedAgentId).catch(() => null)
|
||||
|
||||
@@ -911,6 +911,7 @@ async function attemptInMergeVerificationFix(
|
||||
task: taskForSkillContext,
|
||||
sessionPurpose: "merger",
|
||||
projectRootDir: rootDir,
|
||||
pluginRunner: options.pluginRunner,
|
||||
});
|
||||
} catch {
|
||||
// Graceful fallback - no skill selection
|
||||
@@ -4748,6 +4749,7 @@ async function runAiAgentForCommit(params: AiAgentParams): Promise<{ success: bo
|
||||
task: taskForSkillContext,
|
||||
sessionPurpose: "merger",
|
||||
projectRootDir: rootDir,
|
||||
pluginRunner: options.pluginRunner,
|
||||
});
|
||||
} catch {
|
||||
// Graceful fallback - no skill selection
|
||||
@@ -5332,6 +5334,7 @@ If issues are found that need attention, describe them clearly and include concr
|
||||
task: taskForSkillContext,
|
||||
sessionPurpose: "merger",
|
||||
projectRootDir: rootDir,
|
||||
pluginRunner: mergeOptions.pluginRunner,
|
||||
});
|
||||
} catch {
|
||||
// Graceful fallback - no skill selection
|
||||
|
||||
@@ -414,6 +414,7 @@ export async function reviewStep(
|
||||
task: options.task ?? {},
|
||||
sessionPurpose: "reviewer",
|
||||
projectRootDir: options.rootDir,
|
||||
pluginRunner: options.pluginRunner,
|
||||
});
|
||||
} catch {
|
||||
// Graceful fallback - no skill selection
|
||||
|
||||
@@ -30,6 +30,8 @@
|
||||
*/
|
||||
|
||||
import type { Agent, AgentStore } from "@fusion/core";
|
||||
import { piLog } from "./logger.js";
|
||||
import type { PluginRunner } from "./plugin-runner.js";
|
||||
import type { SkillSelectionContext } from "./skill-resolver.js";
|
||||
|
||||
// ── Types ───────────────────────────────────────────────────────────────────
|
||||
@@ -52,6 +54,8 @@ export interface SessionSkillContextInput {
|
||||
sessionPurpose: SessionPurpose;
|
||||
/** Absolute path to project root */
|
||||
projectRootDir: string;
|
||||
/** Optional plugin runner for plugin-contributed skills */
|
||||
pluginRunner?: PluginRunner;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -107,6 +111,41 @@ export function normalizeAgentSkills(
|
||||
return result;
|
||||
}
|
||||
|
||||
export function collectPluginSkillNames(
|
||||
pluginRunner: PluginRunner | undefined,
|
||||
): { names: string[]; pluginIds: string[] } {
|
||||
if (!pluginRunner) {
|
||||
return { names: [], pluginIds: [] };
|
||||
}
|
||||
|
||||
const pluginSkills = pluginRunner.getPluginSkills();
|
||||
const seenNames = new Set<string>();
|
||||
const pluginIds = new Set<string>();
|
||||
const names: string[] = [];
|
||||
|
||||
for (const contribution of pluginSkills) {
|
||||
const { pluginId, skill } = contribution;
|
||||
if (skill.enabled === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const name = skill.name.trim();
|
||||
if (name.length === 0 || seenNames.has(name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
seenNames.add(name);
|
||||
pluginIds.add(pluginId);
|
||||
names.push(name);
|
||||
piLog.log(`[skills] Plugin ${pluginId} contributes skill: ${name}`);
|
||||
}
|
||||
|
||||
return {
|
||||
names,
|
||||
pluginIds: Array.from(pluginIds),
|
||||
};
|
||||
}
|
||||
|
||||
// ── Role Fallback Mapping ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -185,18 +224,20 @@ export async function buildSessionSkillContext(
|
||||
);
|
||||
|
||||
if (agentSkills.length > 0) {
|
||||
// Found valid skills from assigned agent
|
||||
const skillSelectionContext: SkillSelectionContext = {
|
||||
projectRootDir,
|
||||
requestedSkillNames: agentSkills,
|
||||
return mergePluginSkills(
|
||||
{
|
||||
skillSelectionContext: {
|
||||
projectRootDir,
|
||||
requestedSkillNames: agentSkills,
|
||||
sessionPurpose,
|
||||
},
|
||||
resolvedSkillNames: agentSkills,
|
||||
skillSource: "assigned-agent",
|
||||
},
|
||||
sessionPurpose,
|
||||
};
|
||||
|
||||
return {
|
||||
skillSelectionContext,
|
||||
resolvedSkillNames: agentSkills,
|
||||
skillSource: "assigned-agent",
|
||||
};
|
||||
projectRootDir,
|
||||
input.pluginRunner,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
@@ -204,7 +245,12 @@ export async function buildSessionSkillContext(
|
||||
}
|
||||
}
|
||||
|
||||
return resolveRoleFallback(sessionPurpose, projectRootDir);
|
||||
return mergePluginSkills(
|
||||
resolveRoleFallback(sessionPurpose, projectRootDir),
|
||||
sessionPurpose,
|
||||
projectRootDir,
|
||||
input.pluginRunner,
|
||||
);
|
||||
}
|
||||
|
||||
function resolveRoleFallback(
|
||||
@@ -232,6 +278,53 @@ function resolveRoleFallback(
|
||||
};
|
||||
}
|
||||
|
||||
function mergePluginSkills(
|
||||
baseResult: SessionSkillContextResult,
|
||||
sessionPurpose: SessionPurpose,
|
||||
projectRootDir: string,
|
||||
pluginRunner: PluginRunner | undefined,
|
||||
): SessionSkillContextResult {
|
||||
const { names: pluginSkillNames } = collectPluginSkillNames(pluginRunner);
|
||||
if (pluginSkillNames.length === 0) {
|
||||
return baseResult;
|
||||
}
|
||||
|
||||
const mergedNames = [...baseResult.resolvedSkillNames];
|
||||
const existingNames = new Set(mergedNames.map((name) => name.toLowerCase()));
|
||||
const appendedPluginNames: string[] = [];
|
||||
|
||||
for (const pluginSkillName of pluginSkillNames) {
|
||||
const key = pluginSkillName.toLowerCase();
|
||||
if (existingNames.has(key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
existingNames.add(key);
|
||||
mergedNames.push(pluginSkillName);
|
||||
appendedPluginNames.push(pluginSkillName);
|
||||
}
|
||||
|
||||
if (appendedPluginNames.length > 0) {
|
||||
piLog.log(
|
||||
`[skills] Merged ${appendedPluginNames.length} plugin skill(s) into ${sessionPurpose} session: [${appendedPluginNames.join(", ")}]`,
|
||||
);
|
||||
}
|
||||
|
||||
if (mergedNames.length === 0) {
|
||||
return baseResult;
|
||||
}
|
||||
|
||||
return {
|
||||
skillSelectionContext: {
|
||||
projectRootDir,
|
||||
requestedSkillNames: mergedNames,
|
||||
sessionPurpose,
|
||||
},
|
||||
resolvedSkillNames: mergedNames,
|
||||
skillSource: baseResult.skillSource === "none" ? "role-fallback" : baseResult.skillSource,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Sync Builder (for hot paths) ────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -244,6 +337,7 @@ export function buildSessionSkillContextSync(
|
||||
agent: Agent | null | undefined,
|
||||
sessionPurpose: SessionPurpose,
|
||||
projectRootDir: string,
|
||||
pluginRunner?: PluginRunner,
|
||||
): SessionSkillContextResult {
|
||||
// Rule 1: Check assigned agent skills
|
||||
if (agent) {
|
||||
@@ -252,19 +346,27 @@ export function buildSessionSkillContextSync(
|
||||
);
|
||||
|
||||
if (agentSkills.length > 0) {
|
||||
const skillSelectionContext: SkillSelectionContext = {
|
||||
projectRootDir,
|
||||
requestedSkillNames: agentSkills,
|
||||
return mergePluginSkills(
|
||||
{
|
||||
skillSelectionContext: {
|
||||
projectRootDir,
|
||||
requestedSkillNames: agentSkills,
|
||||
sessionPurpose,
|
||||
},
|
||||
resolvedSkillNames: agentSkills,
|
||||
skillSource: "assigned-agent",
|
||||
},
|
||||
sessionPurpose,
|
||||
};
|
||||
|
||||
return {
|
||||
skillSelectionContext,
|
||||
resolvedSkillNames: agentSkills,
|
||||
skillSource: "assigned-agent",
|
||||
};
|
||||
projectRootDir,
|
||||
pluginRunner,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return resolveRoleFallback(sessionPurpose, projectRootDir);
|
||||
return mergePluginSkills(
|
||||
resolveRoleFallback(sessionPurpose, projectRootDir),
|
||||
sessionPurpose,
|
||||
projectRootDir,
|
||||
pluginRunner,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -980,6 +980,7 @@ export class TriageProcessor {
|
||||
task,
|
||||
sessionPurpose: "triage",
|
||||
projectRootDir: this.rootDir,
|
||||
pluginRunner: this.options.pluginRunner,
|
||||
});
|
||||
|
||||
let { session } = await createResolvedAgentSession({
|
||||
|
||||
Reference in New Issue
Block a user