feat(FN-4569): add fn_feature_update mission tool

Fusion-Task-Id: FN-4569
Fusion-Task-Lineage: c6736466-c317-4d6c-b1a6-225554e8fc0f
This commit is contained in:
Fusion
2026-05-15 00:29:29 -07:00
committed by gsxdsm
parent 6ae22b69a1
commit 216d76db86
8 changed files with 395 additions and 1 deletions

View File

@@ -202,6 +202,7 @@ describe.skipIf(!SHOULD_RUN_LEGACY_EXTENSION_INTEGRATION)("fn pi extension (lega
"fn_feature_add",
"fn_slice_activate",
"fn_feature_link_task",
"fn_feature_update",
"fn_agent_stop",
"fn_agent_start",
"fn_agent_create",
@@ -1373,6 +1374,292 @@ describe.skipIf(!SHOULD_RUN_LEGACY_EXTENSION_INTEGRATION)("fn pi extension (lega
});
});
describe("fn_feature_update", () => {
it("patches title, description, and acceptanceCriteria", async () => {
const missionTool = api.tools.get("fn_mission_create")!;
const milestoneTool = api.tools.get("fn_milestone_add")!;
const sliceTool = api.tools.get("fn_slice_add")!;
const featureTool = api.tools.get("fn_feature_add")!;
const updateTool = api.tools.get("fn_feature_update")!;
const mission = await missionTool.execute("m1", { title: "Mission" }, undefined, undefined, makeCtx(tmpDir));
const milestone = await milestoneTool.execute(
"ms1",
{ missionId: mission.details.missionId, title: "Milestone" },
undefined,
undefined,
makeCtx(tmpDir),
);
const slice = await sliceTool.execute(
"sl1",
{ milestoneId: milestone.details.milestoneId, title: "Slice" },
undefined,
undefined,
makeCtx(tmpDir),
);
const feature = await featureTool.execute(
"f1",
{ sliceId: slice.details.sliceId, title: "Feature", description: "Original", acceptanceCriteria: "AC old" },
undefined,
undefined,
makeCtx(tmpDir),
);
const result = await updateTool.execute(
"fu1",
{
id: feature.details.featureId,
title: "Updated Feature",
description: "Updated description",
acceptanceCriteria: "AC new",
},
undefined,
undefined,
makeCtx(tmpDir),
);
const store = new TaskStore(tmpDir);
await store.init();
const persisted = store.getMissionStore().getFeature(feature.details.featureId);
expect(result.content[0].text).toContain("Updated");
expect(persisted?.title).toBe("Updated Feature");
expect(persisted?.description).toBe("Updated description");
expect(persisted?.acceptanceCriteria).toBe("AC new");
});
it("partial patch preserves untouched fields", async () => {
const missionTool = api.tools.get("fn_mission_create")!;
const milestoneTool = api.tools.get("fn_milestone_add")!;
const sliceTool = api.tools.get("fn_slice_add")!;
const featureTool = api.tools.get("fn_feature_add")!;
const updateTool = api.tools.get("fn_feature_update")!;
const mission = await missionTool.execute("m1", { title: "Mission" }, undefined, undefined, makeCtx(tmpDir));
const milestone = await milestoneTool.execute(
"ms1",
{ missionId: mission.details.missionId, title: "Milestone" },
undefined,
undefined,
makeCtx(tmpDir),
);
const slice = await sliceTool.execute(
"sl1",
{ milestoneId: milestone.details.milestoneId, title: "Slice" },
undefined,
undefined,
makeCtx(tmpDir),
);
const feature = await featureTool.execute(
"f1",
{ sliceId: slice.details.sliceId, title: "Feature", description: "Original", acceptanceCriteria: "AC old" },
undefined,
undefined,
makeCtx(tmpDir),
);
await updateTool.execute(
"fu2",
{ id: feature.details.featureId, acceptanceCriteria: "AC patched" },
undefined,
undefined,
makeCtx(tmpDir),
);
const store = new TaskStore(tmpDir);
await store.init();
const persisted = store.getMissionStore().getFeature(feature.details.featureId);
expect(persisted?.title).toBe("Feature");
expect(persisted?.description).toBe("Original");
expect(persisted?.acceptanceCriteria).toBe("AC patched");
});
it("preserves slice ordering", async () => {
const missionTool = api.tools.get("fn_mission_create")!;
const milestoneTool = api.tools.get("fn_milestone_add")!;
const sliceTool = api.tools.get("fn_slice_add")!;
const featureTool = api.tools.get("fn_feature_add")!;
const updateTool = api.tools.get("fn_feature_update")!;
const mission = await missionTool.execute("m1", { title: "Mission" }, undefined, undefined, makeCtx(tmpDir));
const milestone = await milestoneTool.execute(
"ms1",
{ missionId: mission.details.missionId, title: "Milestone" },
undefined,
undefined,
makeCtx(tmpDir),
);
const slice = await sliceTool.execute(
"sl1",
{ milestoneId: milestone.details.milestoneId, title: "Slice" },
undefined,
undefined,
makeCtx(tmpDir),
);
const first = await featureTool.execute(
"f1",
{ sliceId: slice.details.sliceId, title: "F1" },
undefined,
undefined,
makeCtx(tmpDir),
);
const second = await featureTool.execute(
"f2",
{ sliceId: slice.details.sliceId, title: "F2" },
undefined,
undefined,
makeCtx(tmpDir),
);
const third = await featureTool.execute(
"f3",
{ sliceId: slice.details.sliceId, title: "F3" },
undefined,
undefined,
makeCtx(tmpDir),
);
await updateTool.execute(
"fu3",
{ id: second.details.featureId, title: "F2 Updated" },
undefined,
undefined,
makeCtx(tmpDir),
);
const store = new TaskStore(tmpDir);
await store.init();
const features = store.getMissionStore().listFeatures(slice.details.sliceId);
expect(features.map((featureItem) => featureItem.id)).toEqual([
first.details.featureId,
second.details.featureId,
third.details.featureId,
]);
});
it("preserves linked task association", async () => {
const missionTool = api.tools.get("fn_mission_create")!;
const milestoneTool = api.tools.get("fn_milestone_add")!;
const sliceTool = api.tools.get("fn_slice_add")!;
const featureTool = api.tools.get("fn_feature_add")!;
const createTaskTool = api.tools.get("fn_task_create")!;
const linkTool = api.tools.get("fn_feature_link_task")!;
const updateTool = api.tools.get("fn_feature_update")!;
const mission = await missionTool.execute("m1", { title: "Mission" }, undefined, undefined, makeCtx(tmpDir));
const milestone = await milestoneTool.execute(
"ms1",
{ missionId: mission.details.missionId, title: "Milestone" },
undefined,
undefined,
makeCtx(tmpDir),
);
const slice = await sliceTool.execute(
"sl1",
{ milestoneId: milestone.details.milestoneId, title: "Slice" },
undefined,
undefined,
makeCtx(tmpDir),
);
const feature = await featureTool.execute(
"f1",
{ sliceId: slice.details.sliceId, title: "Feature" },
undefined,
undefined,
makeCtx(tmpDir),
);
const taskResult = await createTaskTool.execute(
"t1",
{ description: "Task for feature" },
undefined,
undefined,
makeCtx(tmpDir),
);
await linkTool.execute(
"l1",
{ featureId: feature.details.featureId, taskId: taskResult.details.taskId },
undefined,
undefined,
makeCtx(tmpDir),
);
await updateTool.execute(
"fu4",
{ id: feature.details.featureId, title: "Updated Feature" },
undefined,
undefined,
makeCtx(tmpDir),
);
const store = new TaskStore(tmpDir);
await store.init();
const persisted = store.getMissionStore().getFeature(feature.details.featureId);
expect(persisted?.taskId).toBe(taskResult.details.taskId);
expect(persisted?.status).toBe("triaged");
});
it("returns error when feature not found", async () => {
const updateTool = api.tools.get("fn_feature_update")!;
const result = await updateTool.execute(
"fu5",
{ id: "F-999", title: "Updated" },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain("Feature F-999 not found");
});
it("returns error when no fields supplied", async () => {
const missionTool = api.tools.get("fn_mission_create")!;
const milestoneTool = api.tools.get("fn_milestone_add")!;
const sliceTool = api.tools.get("fn_slice_add")!;
const featureTool = api.tools.get("fn_feature_add")!;
const updateTool = api.tools.get("fn_feature_update")!;
const mission = await missionTool.execute("m1", { title: "Mission" }, undefined, undefined, makeCtx(tmpDir));
const milestone = await milestoneTool.execute(
"ms1",
{ missionId: mission.details.missionId, title: "Milestone" },
undefined,
undefined,
makeCtx(tmpDir),
);
const slice = await sliceTool.execute(
"sl1",
{ milestoneId: milestone.details.milestoneId, title: "Slice" },
undefined,
undefined,
makeCtx(tmpDir),
);
const feature = await featureTool.execute(
"f1",
{ sliceId: slice.details.sliceId, title: "Feature" },
undefined,
undefined,
makeCtx(tmpDir),
);
const result = await updateTool.execute(
"fu6",
{ id: feature.details.featureId },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain("No fields to update");
});
});
describe("GitHub import tools", () => {
it("fn_task_import_github requires gh auth", async () => {
const tool = api.tools.get("fn_task_import_github")!;

View File

@@ -2527,6 +2527,83 @@ export default function kbExtension(pi: ExtensionAPI) {
},
});
// ── fn_feature_update ─────────────────────────────────────────────
pi.registerTool({
name: "fn_feature_update",
label: "fn: Update Feature",
description:
"Update an existing feature's title, description, or acceptance criteria. " +
"Partial patches leave untouched fields intact.",
promptSnippet: "Update an existing mission feature",
promptGuidelines: [
"Use to revise acceptance criteria after reconciliation without re-creating the feature",
"Slice ordering and linked tasks are preserved",
"Provide only the fields you want to change",
],
parameters: Type.Object({
id: Type.String({ description: "Feature ID to update (e.g., F-001)" }),
title: Type.Optional(Type.String({ description: "Updated feature title" })),
description: Type.Optional(Type.String({ description: "Updated feature description" })),
acceptanceCriteria: Type.Optional(
Type.String({ description: "Updated acceptance criteria for completing the feature" })
),
}),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const store = await getStore(ctx.cwd);
const missionStore = store.getMissionStore();
const existingFeature = missionStore.getFeature(params.id);
if (!existingFeature) {
return {
content: [{ type: "text", text: `Feature ${params.id} not found` }],
isError: true,
details: { error: "Feature not found" },
};
}
const updates: { title?: string; description?: string; acceptanceCriteria?: string } = {};
if ("title" in params) {
updates.title = params.title?.trim();
}
if ("description" in params) {
updates.description = params.description?.trim();
}
if ("acceptanceCriteria" in params) {
updates.acceptanceCriteria = params.acceptanceCriteria?.trim();
}
if (Object.keys(updates).length === 0) {
return {
content: [
{
type: "text",
text: "No fields to update (provide at least one of: title, description, acceptanceCriteria)",
},
],
isError: true,
details: { error: "No fields to update" },
};
}
const feature = missionStore.updateFeature(params.id, updates);
return {
content: [{ type: "text", text: `Updated ${feature.id}: "${feature.title}"` }],
details: {
featureId: feature.id,
sliceId: feature.sliceId,
title: feature.title,
description: feature.description,
acceptanceCriteria: feature.acceptanceCriteria,
status: feature.status,
},
};
},
});
// ── fn_agent_stop ─────────────────────────────────────────────────
pi.registerTool({