FN-8540: render mission hierarchies in agent lookup
Render complete mission hierarchy details from the fn_mission_show agent tool. - Format mission, linked-goal, milestone, slice, and feature metadata with IDs and statuses - Link features to their tasks and bound verbose acceptance and verification text - Cover populated and empty hierarchy responses with regression tests - Add a patch changeset for the agent lookup fix Files changed: .changeset/fn-8540-mission-show-hierarchy.md | 7 ++ .../src/__tests__/agent-mission-tools.test.ts | 68 +++++++++++++++++++ packages/engine/src/agent-tools.ts | 77 +++++++++++++++++++++- 3 files changed, 150 insertions(+), 2 deletions(-) Fusion-Task-Id: FN-8540 Fusion-Task-Lineage: f2282226-3f4e-4d9f-bd8e-d18ad9639c03 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-8540-mission-show-hierarchy.md
Normal file
7
.changeset/fn-8540-mission-show-hierarchy.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
summary: Show complete mission hierarchies in agent mission lookup results.
|
||||||
|
category: fix
|
||||||
|
dev: `fn_mission_show` now renders mission metadata, child IDs/statuses, task links, and empty states.
|
||||||
@@ -37,6 +37,74 @@ describe("createMissionTools", () => {
|
|||||||
expect(result.details).toMatchObject({ feature: { id: "F-1" }, reused: false });
|
expect(result.details).toMatchObject({ feature: { id: "F-1" }, reused: false });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("renders populated hierarchy IDs, statuses, task links, and bounded gate text", async () => {
|
||||||
|
const longAcceptanceCriteria = "a".repeat(241);
|
||||||
|
const mission = {
|
||||||
|
id: "M-1", title: "Mission", status: "active", description: "Mission description", baseBranch: "main",
|
||||||
|
createdAt: "2026-07-23T00:00:00.000Z", updatedAt: "2026-07-23T01:00:00.000Z", eventCount: 4,
|
||||||
|
linkedGoals: [{ id: "G-1", title: "Goal", status: "active" }],
|
||||||
|
milestones: [
|
||||||
|
{
|
||||||
|
id: "MS-1", title: "Repeated", status: "active", acceptanceCriteria: longAcceptanceCriteria,
|
||||||
|
slices: [
|
||||||
|
{
|
||||||
|
id: "SL-1", title: "Repeated", status: "active", activatedAt: "2026-07-23T02:00:00.000Z", verification: "Run focused test",
|
||||||
|
features: [
|
||||||
|
{ id: "F-1", title: "Repeated", status: "triaged", taskId: "FN-1", acceptanceCriteria: longAcceptanceCriteria },
|
||||||
|
{ id: "F-2", title: "Repeated", status: "done" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ id: "MS-2", title: "Second", status: "pending", slices: [] },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const getMissionWithHierarchy = vi.fn().mockResolvedValue(mission);
|
||||||
|
const store = { getMissionStore: () => ({ getMissionWithHierarchy }) } as never;
|
||||||
|
const tool = createMissionTools(store).find((candidate) => candidate.name === "fn_mission_show")!;
|
||||||
|
const result = await tool.execute("call", { id: mission.id });
|
||||||
|
const text = result.content[0].text;
|
||||||
|
|
||||||
|
expect(getMissionWithHierarchy).toHaveBeenCalledWith(mission.id);
|
||||||
|
expect(result.details).toEqual({ mission });
|
||||||
|
expect(text).toContain("Status: active");
|
||||||
|
expect(text).toContain("MS-1: Repeated (active)");
|
||||||
|
expect(text).toContain("SL-1: Repeated (active)");
|
||||||
|
expect(text).toContain("F-1: Repeated (triaged) → FN-1");
|
||||||
|
expect(text).toContain("F-2: Repeated (done)");
|
||||||
|
expect(text).toContain("MS-2: Second (pending)");
|
||||||
|
expect(text).toContain("No slices.");
|
||||||
|
expect(text).toContain("… (truncated, 241 chars)");
|
||||||
|
expect(text).toContain("F-1: Repeated (triaged) → FN-1");
|
||||||
|
expect(text.indexOf("MS-1:")).toBeLessThan(text.indexOf("SL-1:"));
|
||||||
|
expect(text.indexOf("SL-1:")).toBeLessThan(text.indexOf("F-1:"));
|
||||||
|
expect(text).not.toBe(`${mission.id}: ${mission.title}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders explicit empty hierarchy states without optional metadata", async () => {
|
||||||
|
const missionWithoutMilestones = {
|
||||||
|
id: "M-empty", title: "Empty", status: "planning", createdAt: "2026-07-23T00:00:00.000Z", updatedAt: "2026-07-23T00:00:00.000Z", milestones: [],
|
||||||
|
};
|
||||||
|
const missionWithEmptyChildren = {
|
||||||
|
id: "M-children", title: "Children", status: "planning", createdAt: "2026-07-23T00:00:00.000Z", updatedAt: "2026-07-23T00:00:00.000Z",
|
||||||
|
milestones: [{ id: "MS-empty", title: "Empty", status: "pending", slices: [{ id: "SL-empty", title: "Empty", status: "pending", features: [] }] }],
|
||||||
|
};
|
||||||
|
const getMissionWithHierarchy = vi.fn()
|
||||||
|
.mockResolvedValueOnce(missionWithoutMilestones)
|
||||||
|
.mockResolvedValueOnce(missionWithEmptyChildren);
|
||||||
|
const store = { getMissionStore: () => ({ getMissionWithHierarchy }) } as never;
|
||||||
|
const tool = createMissionTools(store).find((candidate) => candidate.name === "fn_mission_show")!;
|
||||||
|
|
||||||
|
const emptyResult = await tool.execute("call", { id: missionWithoutMilestones.id });
|
||||||
|
const childrenResult = await tool.execute("call", { id: missionWithEmptyChildren.id });
|
||||||
|
|
||||||
|
expect(emptyResult.content[0].text).toContain("No linked goals.");
|
||||||
|
expect(emptyResult.content[0].text).toContain("No milestones yet.");
|
||||||
|
expect(childrenResult.content[0].text).toContain("MS-empty: Empty (pending)");
|
||||||
|
expect(childrenResult.content[0].text).toContain("SL-empty: Empty (pending)");
|
||||||
|
expect(childrenResult.content[0].text).toContain("No features.");
|
||||||
|
});
|
||||||
|
|
||||||
it("returns a structured error for missing hierarchy records", async () => {
|
it("returns a structured error for missing hierarchy records", async () => {
|
||||||
const store = { getMissionStore: () => ({ getMissionWithHierarchy: vi.fn().mockResolvedValue(undefined) }) } as never;
|
const store = { getMissionStore: () => ({ getMissionWithHierarchy: vi.fn().mockResolvedValue(undefined) }) } as never;
|
||||||
const tool = createMissionTools(store).find((candidate) => candidate.name === "fn_mission_show")!;
|
const tool = createMissionTools(store).find((candidate) => candidate.name === "fn_mission_show")!;
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { createHash, randomUUID } from "node:crypto";
|
|||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { extname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
import { extname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
||||||
import * as fusionCore from "@fusion/core";
|
import * as fusionCore from "@fusion/core";
|
||||||
import type { AgentState, AgentCapability, AgentUpdateInput, AgentLogEntry, Artifact, ArtifactCreateInput, ArtifactWithTask, Task, TaskDocument, TaskDocumentCreateInput, TaskStore, RunMutationContext, MessageStore, Message, SourceType, Settings, ResearchRun, ResearchRunStatus, TaskCreateInput, ReflectionStore, ApprovalRequestStore, ProjectSettings, ChatStore, WorkflowSettingDefinition, GoalStatus, WorkflowIrNode, IdeationCandidate } from "@fusion/core";
|
import type { AgentState, AgentCapability, AgentUpdateInput, AgentLogEntry, Artifact, ArtifactCreateInput, ArtifactWithTask, Task, TaskDocument, TaskDocumentCreateInput, TaskStore, RunMutationContext, MessageStore, Message, SourceType, Settings, ResearchRun, ResearchRunStatus, TaskCreateInput, ReflectionStore, ApprovalRequestStore, ProjectSettings, ChatStore, WorkflowSettingDefinition, GoalStatus, WorkflowIrNode, IdeationCandidate, MissionWithHierarchy } from "@fusion/core";
|
||||||
import { listTraits, isBuiltinWorkflowId, AgentStore, validateColumnAgentBindings, ColumnAgentBindingError, stripApprovalBypassFlags, WorkflowSettingRejectionError, resolveEffectiveSettingsById, resolveWorkflowIrById, findOrphanedSettingValues, BUILTIN_WORKFLOW_SETTINGS, MAX_TASK_LIST_TEXT_CHARS, formatCurrentTaskLine, normalizeWorkflowIcon, parseWorkflowIr, WorkflowIrError, assertColumnTraitsValid, ColumnTraitValidationError } from "@fusion/core";
|
import { listTraits, isBuiltinWorkflowId, AgentStore, validateColumnAgentBindings, ColumnAgentBindingError, stripApprovalBypassFlags, WorkflowSettingRejectionError, resolveEffectiveSettingsById, resolveWorkflowIrById, findOrphanedSettingValues, BUILTIN_WORKFLOW_SETTINGS, MAX_TASK_LIST_TEXT_CHARS, formatCurrentTaskLine, normalizeWorkflowIcon, parseWorkflowIr, WorkflowIrError, assertColumnTraitsValid, ColumnTraitValidationError } from "@fusion/core";
|
||||||
import { promoteHeldTask } from "./hold-release.js";
|
import { promoteHeldTask } from "./hold-release.js";
|
||||||
import { computeCrossParentDiagnosticClaim, computeCrossParentDiagnosticClaimId, computeParentIntentClaimId, DASHBOARD_USER_ID, dailyMemoryPath, ensureOpenClawMemoryFiles, evaluateImplementationTaskBind, extractAgentProvisioningRequest, findSameAgentDuplicates, getMemoryBackendCapabilities, getProjectMemory, isEphemeralAgent, memoryLongTermPath, normalizeMessageParticipant, reconcileDeterministicDuplicate, resolveAgentProvisioningPolicy, resolveMemoryBackend, resolveResearchSettings, resolveTaskGithubTracking, runDeterministicDuplicateGuard, scheduleQmdProjectMemoryRefresh, searchProjectMemory, shouldSkipBackgroundQmdRefresh } from "@fusion/core";
|
import { computeCrossParentDiagnosticClaim, computeCrossParentDiagnosticClaimId, computeParentIntentClaimId, DASHBOARD_USER_ID, dailyMemoryPath, ensureOpenClawMemoryFiles, evaluateImplementationTaskBind, extractAgentProvisioningRequest, findSameAgentDuplicates, getMemoryBackendCapabilities, getProjectMemory, isEphemeralAgent, memoryLongTermPath, normalizeMessageParticipant, reconcileDeterministicDuplicate, resolveAgentProvisioningPolicy, resolveMemoryBackend, resolveResearchSettings, resolveTaskGithubTracking, runDeterministicDuplicateGuard, scheduleQmdProjectMemoryRefresh, searchProjectMemory, shouldSkipBackgroundQmdRefresh } from "@fusion/core";
|
||||||
@@ -3692,6 +3692,79 @@ const missionToolResult = (text: string, details: Record<string, unknown>, isErr
|
|||||||
content: [{ type: "text" as const, text }], details, ...(isError ? { isError: true } : {}),
|
content: [{ type: "text" as const, text }], details, ...(isError ? { isError: true } : {}),
|
||||||
});
|
});
|
||||||
const optionalText = (value: string | undefined) => value?.trim() || undefined;
|
const optionalText = (value: string | undefined) => value?.trim() || undefined;
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:MissionToolParity 2026-07-23-12:29:
|
||||||
|
Engine-managed agent surfaces must render hierarchy IDs and statuses because downstream mission
|
||||||
|
operations require those identifiers. Keep rich optional gate prose bounded in text while details
|
||||||
|
retains the complete MissionStore hierarchy for programmatic callers.
|
||||||
|
*/
|
||||||
|
function formatMissionHierarchy(mission: MissionWithHierarchy): string {
|
||||||
|
const lines: string[] = [];
|
||||||
|
const renderBoundedField = (indent: string, label: string, value: string | undefined) => {
|
||||||
|
const trimmed = value?.trim();
|
||||||
|
if (!trimmed) return;
|
||||||
|
if (trimmed.length > 240) {
|
||||||
|
lines.push(`${indent}${label} ${trimmed.slice(0, 240)}… (truncated, ${trimmed.length} chars)`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lines.push(`${indent}${label} ${trimmed}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
lines.push(`${mission.id}: ${mission.title}`);
|
||||||
|
lines.push(`Status: ${mission.status}`);
|
||||||
|
lines.push(`Created: ${mission.createdAt}`);
|
||||||
|
lines.push(`Updated: ${mission.updatedAt}`);
|
||||||
|
if (mission.description) lines.push(`Description: ${mission.description}`);
|
||||||
|
if (mission.baseBranch) lines.push(`Base branch: ${mission.baseBranch}`);
|
||||||
|
if (mission.eventCount !== undefined) lines.push(`Events: ${mission.eventCount}`);
|
||||||
|
lines.push("");
|
||||||
|
|
||||||
|
lines.push("Linked Goals:");
|
||||||
|
if ((mission.linkedGoals?.length ?? 0) === 0) {
|
||||||
|
lines.push("No linked goals.");
|
||||||
|
} else {
|
||||||
|
for (const goal of mission.linkedGoals ?? []) lines.push(`- ${goal.id}: ${goal.title} (${goal.status})`);
|
||||||
|
}
|
||||||
|
lines.push("");
|
||||||
|
|
||||||
|
if (mission.milestones.length === 0) {
|
||||||
|
lines.push("No milestones yet.");
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push("Milestones:");
|
||||||
|
for (const milestone of mission.milestones) {
|
||||||
|
const icon = milestone.status === "complete" ? "✓" : milestone.status === "active" ? "●" : "○";
|
||||||
|
lines.push(` ${icon} ${milestone.id}: ${milestone.title} (${milestone.status})`);
|
||||||
|
renderBoundedField(" ", "AC:", milestone.acceptanceCriteria);
|
||||||
|
if (milestone.slices.length === 0) {
|
||||||
|
lines.push(" No slices.");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const slice of milestone.slices) {
|
||||||
|
const icon = slice.status === "complete" ? "✓" : slice.status === "active" ? "●" : "○";
|
||||||
|
const activated = slice.activatedAt ? ` [activated: ${slice.activatedAt}]` : "";
|
||||||
|
lines.push(` ${icon} ${slice.id}: ${slice.title} (${slice.status})${activated}`);
|
||||||
|
renderBoundedField(" ", "Verification:", slice.verification);
|
||||||
|
if (slice.features.length === 0) {
|
||||||
|
lines.push(" No features.");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const feature of slice.features) {
|
||||||
|
const icon = feature.status === "done" ? "✓" : feature.status === "in-progress" ? "▸" : feature.status === "triaged" ? "●" : "○";
|
||||||
|
const taskLink = feature.taskId ? ` → ${feature.taskId}` : "";
|
||||||
|
lines.push(` ${icon} ${feature.id}: ${feature.title} (${feature.status})${taskLink}`);
|
||||||
|
renderBoundedField(" ", "AC:", feature.acceptanceCriteria);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
/* FNXC:MissionToolParity 2026-07-30-09:56: A supplied empty update value must remain an empty string so MissionStore can clear it, matching the pi-extension contract; only omitted values leave a field unchanged. */
|
/* FNXC:MissionToolParity 2026-07-30-09:56: A supplied empty update value must remain an empty string so MissionStore can clear it, matching the pi-extension contract; only omitted values leave a field unchanged. */
|
||||||
const updateFields = (params: Record<string, unknown>, fields: string[]) => Object.fromEntries(
|
const updateFields = (params: Record<string, unknown>, fields: string[]) => Object.fromEntries(
|
||||||
fields.filter((field) => params[field] !== undefined).map((field) => [field, (params[field] as string).trim()]),
|
fields.filter((field) => params[field] !== undefined).map((field) => [field, (params[field] as string).trim()]),
|
||||||
@@ -3707,7 +3780,7 @@ export function createMissionTools(store: TaskStore): ToolDefinition[] {
|
|||||||
/* eslint-enable @typescript-eslint/no-explicit-any */
|
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||||
return [
|
return [
|
||||||
tool("fn_mission_list", "List Missions", "List all missions with their current status.", missionListParams, async () => { const missions = await store.getMissionStore().listMissions(); return missionToolResult(missions.length ? `Missions (${missions.length})\n${missions.map((m) => `- ${m.id}: ${m.title} (${m.status})`).join("\n")}` : "No missions yet.", { missions, count: missions.length }); }),
|
tool("fn_mission_list", "List Missions", "List all missions with their current status.", missionListParams, async () => { const missions = await store.getMissionStore().listMissions(); return missionToolResult(missions.length ? `Missions (${missions.length})\n${missions.map((m) => `- ${m.id}: ${m.title} (${m.status})`).join("\n")}` : "No missions yet.", { missions, count: missions.length }); }),
|
||||||
tool("fn_mission_show", "Show Mission", "Show a mission with its full milestone, slice, and feature hierarchy.", missionShowParams, async ({ id }) => { const mission = await store.getMissionStore().getMissionWithHierarchy(id); return mission ? missionToolResult(`${mission.id}: ${mission.title}`, { mission }) : missionToolResult(`Mission ${id} not found`, { code: "MISSION_NOT_FOUND", missionId: id }, true); }),
|
tool("fn_mission_show", "Show Mission", "Show a mission with its full milestone, slice, and feature hierarchy.", missionShowParams, async ({ id }) => { const mission = await store.getMissionStore().getMissionWithHierarchy(id); return mission ? missionToolResult(formatMissionHierarchy(mission), { mission }) : missionToolResult(`Mission ${id} not found`, { code: "MISSION_NOT_FOUND", missionId: id }, true); }),
|
||||||
tool("fn_mission_create", "Create Mission", "Create a high-level mission.", missionCreateParams, async (p) => { const ms = store.getMissionStore(); const mission = await ms.createMission({ title: p.title.trim(), description: optionalText(p.description), baseBranch: optionalText(p.baseBranch) }); const updated = p.autoAdvance === undefined ? mission : await ms.updateMission(mission.id, { autoAdvance: p.autoAdvance }); return missionToolResult(`Created ${updated.id}: ${updated.title}`, { mission: updated }); }),
|
tool("fn_mission_create", "Create Mission", "Create a high-level mission.", missionCreateParams, async (p) => { const ms = store.getMissionStore(); const mission = await ms.createMission({ title: p.title.trim(), description: optionalText(p.description), baseBranch: optionalText(p.baseBranch) }); const updated = p.autoAdvance === undefined ? mission : await ms.updateMission(mission.id, { autoAdvance: p.autoAdvance }); return missionToolResult(`Created ${updated.id}: ${updated.title}`, { mission: updated }); }),
|
||||||
tool("fn_mission_update", "Update Mission", "Partially update a mission.", missionUpdateParams, async (p) => { const updates = updateFields(p, ["title", "description"]); if (!Object.keys(updates).length) return missionToolResult("No fields to update", {}, true); const mission = await store.getMissionStore().updateMission(p.id, updates); return missionToolResult(`Updated ${mission.id}: ${mission.title}`, { mission }); }),
|
tool("fn_mission_update", "Update Mission", "Partially update a mission.", missionUpdateParams, async (p) => { const updates = updateFields(p, ["title", "description"]); if (!Object.keys(updates).length) return missionToolResult("No fields to update", {}, true); const mission = await store.getMissionStore().updateMission(p.id, updates); return missionToolResult(`Updated ${mission.id}: ${mission.title}`, { mission }); }),
|
||||||
tool("fn_mission_delete", "Delete Mission", "Delete a mission and its hierarchy.", missionDeleteParams, async ({ id }) => { await store.getMissionStore().deleteMission(id); return missionToolResult(`Deleted ${id}`, { missionId: id }); }),
|
tool("fn_mission_delete", "Delete Mission", "Delete a mission and its hierarchy.", missionDeleteParams, async ({ id }) => { await store.getMissionStore().deleteMission(id); return missionToolResult(`Deleted ${id}`, { missionId: id }); }),
|
||||||
|
|||||||
Reference in New Issue
Block a user