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:
Fusion
2026-05-01 23:50:08 -07:00
committed by gsxdsm
parent 39ba9ab0bd
commit 1f68bc3518
8 changed files with 347 additions and 24 deletions

View File

@@ -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"]);
});
});

View File

@@ -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", () => {

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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,
);
}

View File

@@ -980,6 +980,7 @@ export class TriageProcessor {
task,
sessionPurpose: "triage",
projectRootDir: this.rootDir,
pluginRunner: this.options.pluginRunner,
});
let { session } = await createResolvedAgentSession({