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:
Fusion
2026-04-30 19:46:33 -07:00
committed by gsxdsm
parent 892f6bde68
commit 0c932e5e9c
8 changed files with 424 additions and 1 deletions

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

View File

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

View File

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