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:
gsxdsm
2026-07-23 12:46:09 -07:00
parent a69e66b54b
commit 2021d5679b
3 changed files with 150 additions and 2 deletions

View 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.

View File

@@ -37,6 +37,74 @@ describe("createMissionTools", () => {
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 () => {
const store = { getMissionStore: () => ({ getMissionWithHierarchy: vi.fn().mockResolvedValue(undefined) }) } as never;
const tool = createMissionTools(store).find((candidate) => candidate.name === "fn_mission_show")!;

View File

@@ -13,7 +13,7 @@ import { createHash, randomUUID } from "node:crypto";
import { tmpdir } from "node:os";
import { extname, isAbsolute, join, relative, resolve, sep } from "node:path";
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 { 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";
@@ -3692,6 +3692,79 @@ const missionToolResult = (text: string, details: Record<string, unknown>, isErr
content: [{ type: "text" as const, text }], details, ...(isError ? { isError: true } : {}),
});
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. */
const updateFields = (params: Record<string, unknown>, fields: string[]) => Object.fromEntries(
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 */
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_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_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 }); }),