feat(FN-3035): tighten insight tool filter typing
Fixes the insight tool filter typing in the CLI extension to use stricter types, preventing incorrect filter values from being passed through. Fusion-Task-Id: FN-3035
This commit is contained in:
5
.changeset/fn-3035-insight-tools.md
Normal file
5
.changeset/fn-3035-insight-tools.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Add read-only `fn_insight_*` pi extension tools so agents can list and inspect persisted insights and recent insight-generation runs directly from the project `InsightStore`.
|
||||
@@ -32,6 +32,7 @@ Mission → Milestone → Slice → Feature → Task
|
||||
- **Mission tools** — `fn_mission_create`, `fn_mission_list`, `fn_mission_show`, `fn_mission_delete`, `fn_milestone_add`, `fn_slice_add`, `fn_feature_add`, `fn_slice_activate`, `fn_feature_link_task`
|
||||
- **Agent tools** — `fn_agent_stop`, `fn_agent_start`
|
||||
- **Skills tools** — `fn_skills_search`, `fn_skills_install`
|
||||
- **Insight tools** — `fn_insight_list`, `fn_insight_show`, `fn_insight_run_list`, `fn_insight_run_show`
|
||||
<!-- END: tool-categories -->
|
||||
- **Dashboard** — Use `/fn` command to start/stop the dashboard
|
||||
|
||||
|
||||
@@ -283,6 +283,47 @@ Install an agent skill from skills.sh into the current project. Downloads skill
|
||||
| `source` | string | ✓ | GitHub source in owner/repo format (e.g., 'firebase/agent-skills') |
|
||||
| `skill` | string | — | Specific skill name to install (e.g., 'firebase-basics'). Omit to install all skills from the source. |
|
||||
|
||||
## Insight Tools
|
||||
|
||||
### fn_insight_list
|
||||
|
||||
List persisted project insights with optional category/status filters.
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|------|----------|-------------|
|
||||
| `category` | string(enum) | — | Filter by insight category |
|
||||
| `status` | string(enum) | — | Filter by insight status |
|
||||
| `runId` | string | — | Filter to insights linked to a specific run ID |
|
||||
| `limit` | number | — | Max insights to return |
|
||||
| `offset` | number | — | Number of rows to skip |
|
||||
|
||||
### fn_insight_show
|
||||
|
||||
Show a single persisted insight by ID.
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|------|----------|-------------|
|
||||
| `id` | string | ✓ | Insight ID (e.g. INS-XXXXX) |
|
||||
|
||||
### fn_insight_run_list
|
||||
|
||||
List recent insight-generation runs with optional status/trigger filters.
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|------|----------|-------------|
|
||||
| `status` | string(enum) | — | Filter by run status |
|
||||
| `trigger` | string(enum) | — | Filter by run trigger |
|
||||
| `limit` | number | — | Max runs to return |
|
||||
| `offset` | number | — | Number of runs to skip |
|
||||
|
||||
### fn_insight_run_show
|
||||
|
||||
Show a single insight-generation run by ID.
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|------|----------|-------------|
|
||||
| `id` | string | ✓ | Insight run ID (e.g. INSR-XXXXX) |
|
||||
|
||||
<!-- END: extension-tools -->
|
||||
## Dashboard Command
|
||||
|
||||
|
||||
@@ -29,6 +29,10 @@ All skill/extension tool invocations in this catalog use the public `fn_*` names
|
||||
| `fn_task_import_github_issue` | Import a specific GitHub issue as a Fusion task. Fetches the issue by number and creates a single task in the planning column with the issue title and body. |
|
||||
| `fn_task_browse_github_issues` | List open GitHub issues from a repository to browse before importing. Returns issue numbers, titles, and URLs for selection. Use with fn_task_import_github_issue to import specific issues by number. |
|
||||
| `fn_task_plan` | Create a task via AI-guided planning mode — interactive conversation to refine your idea into a well-specified task. |
|
||||
| `fn_insight_list` | List persisted project insights with optional category/status filters. |
|
||||
| `fn_insight_show` | Show a single persisted insight by ID. |
|
||||
| `fn_insight_run_list` | List recent insight-generation runs with optional status/trigger filters. |
|
||||
| `fn_insight_run_show` | Show a single insight-generation run by ID. |
|
||||
| `fn_mission_create` | Create a new mission — a high-level objective that can span multiple milestones. Missions contain milestones that break down work into phases. |
|
||||
| `fn_mission_list` | List all missions with their current status. |
|
||||
| `fn_mission_show` | Show mission details with full hierarchy: milestones → slices → features. |
|
||||
|
||||
112
packages/cli/src/__tests__/extension-insights.test.ts
Normal file
112
packages/cli/src/__tests__/extension-insights.test.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import kbExtension from "../extension.js";
|
||||
import { TaskStore } from "@fusion/core";
|
||||
|
||||
interface RegisteredTool {
|
||||
name: string;
|
||||
execute: (
|
||||
toolCallId: string,
|
||||
params: any,
|
||||
signal: AbortSignal | undefined,
|
||||
onUpdate: ((update: any) => void) | undefined,
|
||||
ctx: any,
|
||||
) => Promise<any>;
|
||||
}
|
||||
|
||||
function createMockAPI() {
|
||||
const tools = new Map<string, RegisteredTool>();
|
||||
return {
|
||||
registerTool(def: RegisteredTool) {
|
||||
tools.set(def.name, def);
|
||||
},
|
||||
registerCommand() {},
|
||||
registerShortcut() {},
|
||||
registerFlag() {},
|
||||
on() {},
|
||||
tools,
|
||||
} as any;
|
||||
}
|
||||
|
||||
function makeCtx(cwd: string) {
|
||||
return { cwd } as any;
|
||||
}
|
||||
|
||||
describe("fn insight extension tools", () => {
|
||||
let tmpDir: string;
|
||||
let api: ReturnType<typeof createMockAPI>;
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await mkdtemp(join(tmpdir(), "kb-ext-insights-test-"));
|
||||
api = createMockAPI();
|
||||
kbExtension(api);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("registers all insight tools", () => {
|
||||
expect(api.tools.has("fn_insight_list")).toBe(true);
|
||||
expect(api.tools.has("fn_insight_show")).toBe(true);
|
||||
expect(api.tools.has("fn_insight_run_list")).toBe(true);
|
||||
expect(api.tools.has("fn_insight_run_show")).toBe(true);
|
||||
});
|
||||
|
||||
it("lists and shows persisted insights", async () => {
|
||||
const store = new TaskStore(tmpDir);
|
||||
await store.init();
|
||||
const insightStore = store.getInsightStore();
|
||||
|
||||
const created = insightStore.createInsight("", {
|
||||
title: "Agent-visible insight",
|
||||
category: "quality",
|
||||
status: "generated",
|
||||
provenance: { trigger: "manual" },
|
||||
content: "Ensure this appears in extension output",
|
||||
});
|
||||
|
||||
const listTool = api.tools.get("fn_insight_list")!;
|
||||
const listResult = await listTool.execute("call-1", { category: "quality" }, undefined, undefined, makeCtx(tmpDir));
|
||||
expect(listResult.content[0].text).toContain(created.id);
|
||||
expect(listResult.details.insights).toHaveLength(1);
|
||||
|
||||
const showTool = api.tools.get("fn_insight_show")!;
|
||||
const showResult = await showTool.execute("call-2", { id: created.id }, undefined, undefined, makeCtx(tmpDir));
|
||||
expect(showResult.content[0].text).toContain("Agent-visible insight");
|
||||
expect(showResult.details.insight.id).toBe(created.id);
|
||||
});
|
||||
|
||||
it("lists and shows insight runs", async () => {
|
||||
const store = new TaskStore(tmpDir);
|
||||
await store.init();
|
||||
const insightStore = store.getInsightStore();
|
||||
|
||||
const run = insightStore.createRun("", { trigger: "manual" });
|
||||
insightStore.updateRun(run.id, { status: "completed", insightsCreated: 2, insightsUpdated: 1 });
|
||||
|
||||
const listTool = api.tools.get("fn_insight_run_list")!;
|
||||
const listResult = await listTool.execute("call-3", { status: "completed" }, undefined, undefined, makeCtx(tmpDir));
|
||||
expect(listResult.content[0].text).toContain(run.id);
|
||||
expect(listResult.details.runs).toHaveLength(1);
|
||||
|
||||
const showTool = api.tools.get("fn_insight_run_show")!;
|
||||
const showResult = await showTool.execute("call-4", { id: run.id }, undefined, undefined, makeCtx(tmpDir));
|
||||
expect(showResult.content[0].text).toContain("Status: completed");
|
||||
expect(showResult.details.run.id).toBe(run.id);
|
||||
});
|
||||
|
||||
it("returns helpful errors for invalid pagination and missing IDs", async () => {
|
||||
const listTool = api.tools.get("fn_insight_list")!;
|
||||
const invalidList = await listTool.execute("call-5", { limit: 0 }, undefined, undefined, makeCtx(tmpDir));
|
||||
expect(invalidList.isError).toBe(true);
|
||||
expect(invalidList.content[0].text).toContain("Invalid limit");
|
||||
|
||||
const showTool = api.tools.get("fn_insight_show")!;
|
||||
const missing = await showTool.execute("call-6", { id: "INS-MISSING" }, undefined, undefined, makeCtx(tmpDir));
|
||||
expect(missing.isError).toBe(true);
|
||||
expect(missing.content[0].text).toContain("not found");
|
||||
});
|
||||
});
|
||||
@@ -162,6 +162,11 @@ describe.skip("fn pi extension", () => {
|
||||
"fn_task_unarchive",
|
||||
"fn_task_delete",
|
||||
"fn_task_plan",
|
||||
// Insight tools
|
||||
"fn_insight_list",
|
||||
"fn_insight_show",
|
||||
"fn_insight_run_list",
|
||||
"fn_insight_run_show",
|
||||
// Mission tools
|
||||
"fn_mission_create",
|
||||
"fn_mission_list",
|
||||
|
||||
@@ -7,6 +7,10 @@ import {
|
||||
COLUMN_LABELS,
|
||||
validateNodeOverrideChange,
|
||||
type Task,
|
||||
type InsightCategory,
|
||||
type InsightStatus,
|
||||
type InsightRunStatus,
|
||||
type InsightRunTrigger,
|
||||
} from "@fusion/core";
|
||||
import {
|
||||
getGhErrorMessage,
|
||||
@@ -94,6 +98,28 @@ async function validateAssignableAgentId(
|
||||
return null;
|
||||
}
|
||||
|
||||
const INSIGHT_CATEGORIES: InsightCategory[] = [
|
||||
"quality",
|
||||
"performance",
|
||||
"architecture",
|
||||
"security",
|
||||
"reliability",
|
||||
"ux",
|
||||
"testability",
|
||||
"documentation",
|
||||
"dependency",
|
||||
"workflow",
|
||||
"other",
|
||||
"features",
|
||||
"competitive_analysis",
|
||||
"research",
|
||||
"trends",
|
||||
];
|
||||
|
||||
const INSIGHT_STATUSES: InsightStatus[] = ["generated", "confirmed", "stale", "dismissed"];
|
||||
const INSIGHT_RUN_STATUSES: InsightRunStatus[] = ["pending", "running", "completed", "failed", "cancelled"];
|
||||
const INSIGHT_RUN_TRIGGERS: InsightRunTrigger[] = ["schedule", "manual", "task_completion", "merge_event", "api"];
|
||||
|
||||
function formatTaskLine(t: Task): string {
|
||||
const label =
|
||||
t.title || t.description.slice(0, 60) + (t.description.length > 60 ? "…" : "");
|
||||
@@ -1156,6 +1182,232 @@ export default function kbExtension(pi: ExtensionAPI) {
|
||||
},
|
||||
});
|
||||
|
||||
// ── Insights Tools ──────────────────────────────────────────────
|
||||
|
||||
pi.registerTool({
|
||||
name: "fn_insight_list",
|
||||
label: "fn: List Insights",
|
||||
description: "List persisted project insights with optional category/status filters.",
|
||||
promptSnippet: "List persisted project insights",
|
||||
parameters: Type.Object({
|
||||
category: Type.Optional(
|
||||
StringEnum([...INSIGHT_CATEGORIES], {
|
||||
description: "Filter by insight category",
|
||||
}) as unknown as TSchema,
|
||||
),
|
||||
status: Type.Optional(
|
||||
StringEnum([...INSIGHT_STATUSES], {
|
||||
description: "Filter by insight status",
|
||||
}) as unknown as TSchema,
|
||||
),
|
||||
runId: Type.Optional(Type.String({ description: "Filter to insights linked to a specific run ID" })),
|
||||
limit: Type.Optional(Type.Number({ description: "Max insights to return" })),
|
||||
offset: Type.Optional(Type.Number({ description: "Number of rows to skip" })),
|
||||
}),
|
||||
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||||
if (params.limit !== undefined && (!Number.isInteger(params.limit) || params.limit < 1)) {
|
||||
return {
|
||||
content: [{ type: "text", text: "Invalid limit. Provide an integer >= 1." }],
|
||||
isError: true,
|
||||
details: { error: "Invalid limit" },
|
||||
};
|
||||
}
|
||||
if (params.offset !== undefined && (!Number.isInteger(params.offset) || params.offset < 0)) {
|
||||
return {
|
||||
content: [{ type: "text", text: "Invalid offset. Provide an integer >= 0." }],
|
||||
isError: true,
|
||||
details: { error: "Invalid offset" },
|
||||
};
|
||||
}
|
||||
|
||||
const store = await getStore(ctx.cwd);
|
||||
const insightStore = store.getInsightStore();
|
||||
const category = params.category as InsightCategory | undefined;
|
||||
const status = params.status as InsightStatus | undefined;
|
||||
const options = {
|
||||
category,
|
||||
status,
|
||||
runId: params.runId,
|
||||
limit: params.limit,
|
||||
offset: params.offset,
|
||||
};
|
||||
const insights = insightStore.listInsights(options);
|
||||
const count = insightStore.countInsights({
|
||||
category,
|
||||
status,
|
||||
runId: params.runId,
|
||||
});
|
||||
|
||||
if (insights.length === 0) {
|
||||
return {
|
||||
content: [{ type: "text", text: "No insights found for the provided filters." }],
|
||||
details: { count, insights: [] },
|
||||
};
|
||||
}
|
||||
|
||||
const lines = [`Insights (${insights.length}/${count} shown):`];
|
||||
for (const insight of insights) {
|
||||
const title = insight.title.length > 80 ? `${insight.title.slice(0, 80)}…` : insight.title;
|
||||
lines.push(` ${insight.id} [${insight.category}] [${insight.status}] ${title}`);
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: lines.join("\n") }],
|
||||
details: { count, insights },
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
pi.registerTool({
|
||||
name: "fn_insight_show",
|
||||
label: "fn: Show Insight",
|
||||
description: "Show a single persisted insight by ID.",
|
||||
promptSnippet: "Show full details for a persisted insight",
|
||||
parameters: Type.Object({
|
||||
id: Type.String({ description: "Insight ID (e.g. INS-XXXXX)" }),
|
||||
}),
|
||||
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||||
const store = await getStore(ctx.cwd);
|
||||
const insightStore = store.getInsightStore();
|
||||
const insight = insightStore.getInsight(params.id);
|
||||
|
||||
if (!insight) {
|
||||
return {
|
||||
content: [{ type: "text", text: `Insight ${params.id} not found.` }],
|
||||
isError: true,
|
||||
details: { error: "Insight not found", id: params.id },
|
||||
};
|
||||
}
|
||||
|
||||
const lines = [
|
||||
`${insight.id}: ${insight.title}`,
|
||||
`Category: ${insight.category}`,
|
||||
`Status: ${insight.status}`,
|
||||
`Last run: ${insight.lastRunId ?? "none"}`,
|
||||
`Created: ${insight.createdAt}`,
|
||||
`Updated: ${insight.updatedAt}`,
|
||||
];
|
||||
|
||||
if (insight.content) {
|
||||
lines.push("", "Content:", insight.content.length > 500 ? `${insight.content.slice(0, 500)}\n... (truncated)` : insight.content);
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: lines.join("\n") }],
|
||||
details: { insight },
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
pi.registerTool({
|
||||
name: "fn_insight_run_list",
|
||||
label: "fn: List Insight Runs",
|
||||
description: "List recent insight-generation runs with optional status/trigger filters.",
|
||||
promptSnippet: "List recent insight-generation runs",
|
||||
parameters: Type.Object({
|
||||
status: Type.Optional(
|
||||
StringEnum([...INSIGHT_RUN_STATUSES], {
|
||||
description: "Filter by run status",
|
||||
}) as unknown as TSchema,
|
||||
),
|
||||
trigger: Type.Optional(
|
||||
StringEnum([...INSIGHT_RUN_TRIGGERS], {
|
||||
description: "Filter by run trigger",
|
||||
}) as unknown as TSchema,
|
||||
),
|
||||
limit: Type.Optional(Type.Number({ description: "Max runs to return" })),
|
||||
offset: Type.Optional(Type.Number({ description: "Number of runs to skip" })),
|
||||
}),
|
||||
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||||
if (params.limit !== undefined && (!Number.isInteger(params.limit) || params.limit < 1)) {
|
||||
return {
|
||||
content: [{ type: "text", text: "Invalid limit. Provide an integer >= 1." }],
|
||||
isError: true,
|
||||
details: { error: "Invalid limit" },
|
||||
};
|
||||
}
|
||||
if (params.offset !== undefined && (!Number.isInteger(params.offset) || params.offset < 0)) {
|
||||
return {
|
||||
content: [{ type: "text", text: "Invalid offset. Provide an integer >= 0." }],
|
||||
isError: true,
|
||||
details: { error: "Invalid offset" },
|
||||
};
|
||||
}
|
||||
|
||||
const store = await getStore(ctx.cwd);
|
||||
const insightStore = store.getInsightStore();
|
||||
const status = params.status as InsightRunStatus | undefined;
|
||||
const trigger = params.trigger as InsightRunTrigger | undefined;
|
||||
const options = {
|
||||
status,
|
||||
trigger,
|
||||
limit: params.limit,
|
||||
offset: params.offset,
|
||||
};
|
||||
const runs = insightStore.listRuns(options);
|
||||
const count = insightStore.countRuns({ status, trigger });
|
||||
|
||||
if (runs.length === 0) {
|
||||
return {
|
||||
content: [{ type: "text", text: "No insight runs found for the provided filters." }],
|
||||
details: { count, runs: [] },
|
||||
};
|
||||
}
|
||||
|
||||
const lines = [`Insight runs (${runs.length}/${count} shown):`];
|
||||
for (const run of runs) {
|
||||
lines.push(
|
||||
` ${run.id} [${run.status}] [${run.trigger}] created=${run.insightsCreated} updated=${run.insightsUpdated}`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: lines.join("\n") }],
|
||||
details: { count, runs },
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
pi.registerTool({
|
||||
name: "fn_insight_run_show",
|
||||
label: "fn: Show Insight Run",
|
||||
description: "Show a single insight-generation run by ID.",
|
||||
promptSnippet: "Show full details for an insight-generation run",
|
||||
parameters: Type.Object({
|
||||
id: Type.String({ description: "Insight run ID (e.g. INSR-XXXXX)" }),
|
||||
}),
|
||||
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||||
const store = await getStore(ctx.cwd);
|
||||
const insightStore = store.getInsightStore();
|
||||
const run = insightStore.getRun(params.id);
|
||||
|
||||
if (!run) {
|
||||
return {
|
||||
content: [{ type: "text", text: `Insight run ${params.id} not found.` }],
|
||||
isError: true,
|
||||
details: { error: "Insight run not found", id: params.id },
|
||||
};
|
||||
}
|
||||
|
||||
const lines = [
|
||||
`${run.id}`,
|
||||
`Trigger: ${run.trigger}`,
|
||||
`Status: ${run.status}`,
|
||||
`Insights: created ${run.insightsCreated}, updated ${run.insightsUpdated}`,
|
||||
`Created: ${run.createdAt}`,
|
||||
`Started: ${run.startedAt ?? "not started"}`,
|
||||
`Completed: ${run.completedAt ?? "not completed"}`,
|
||||
];
|
||||
if (run.summary) lines.push(`Summary: ${run.summary}`);
|
||||
if (run.error) lines.push(`Error: ${run.error}`);
|
||||
|
||||
return {
|
||||
content: [{ type: "text", text: lines.join("\n") }],
|
||||
details: { run },
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// ── Mission Tools ───────────────────────────────────────────────
|
||||
// Mission hierarchy management for multi-phase project planning
|
||||
|
||||
|
||||
@@ -43,13 +43,14 @@ const CAP_TABLE_BEGIN =
|
||||
"<!-- BEGIN: fusion-capabilities-tool-table (auto-generated by scripts/sync-fusion-skill-tools.mjs — do not edit by hand) -->";
|
||||
const CAP_TABLE_END = "<!-- END: fusion-capabilities-tool-table -->";
|
||||
|
||||
const CATEGORY_ORDER = ["Task", "GitHub", "Mission", "Agent", "Skills", "Other"];
|
||||
const CATEGORY_ORDER = ["Task", "GitHub", "Mission", "Agent", "Skills", "Insight", "Other"];
|
||||
const CATEGORY_LABELS = {
|
||||
Task: "Task tools",
|
||||
GitHub: "GitHub tools",
|
||||
Mission: "Mission tools",
|
||||
Agent: "Agent tools",
|
||||
Skills: "Skills tools",
|
||||
Insight: "Insight tools",
|
||||
Other: "Other tools",
|
||||
};
|
||||
|
||||
@@ -59,6 +60,7 @@ const CATEGORY_HEADERS = {
|
||||
Mission: "## Mission Tools",
|
||||
Agent: "## Agent Tools",
|
||||
Skills: "## Skills Tools",
|
||||
Insight: "## Insight Tools",
|
||||
Other: "## Other Tools",
|
||||
};
|
||||
|
||||
@@ -75,6 +77,7 @@ function categorize(name) {
|
||||
}
|
||||
if (name.startsWith("fn_agent_")) return "Agent";
|
||||
if (name.startsWith("fn_skills_")) return "Skills";
|
||||
if (name.startsWith("fn_insight_")) return "Insight";
|
||||
return "Other";
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user