From 2021d5679b57b14a8be0cc8d401f0d8becacc9ae Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Thu, 23 Jul 2026 12:46:09 -0700 Subject: [PATCH] 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) --- .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(-) create mode 100644 .changeset/fn-8540-mission-show-hierarchy.md diff --git a/.changeset/fn-8540-mission-show-hierarchy.md b/.changeset/fn-8540-mission-show-hierarchy.md new file mode 100644 index 0000000000..f9313c4598 --- /dev/null +++ b/.changeset/fn-8540-mission-show-hierarchy.md @@ -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. diff --git a/packages/engine/src/__tests__/agent-mission-tools.test.ts b/packages/engine/src/__tests__/agent-mission-tools.test.ts index 0d950f69b3..41a9de150f 100644 --- a/packages/engine/src/__tests__/agent-mission-tools.test.ts +++ b/packages/engine/src/__tests__/agent-mission-tools.test.ts @@ -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")!; diff --git a/packages/engine/src/agent-tools.ts b/packages/engine/src/agent-tools.ts index b2e135b528..e47b942eb2 100644 --- a/packages/engine/src/agent-tools.ts +++ b/packages/engine/src/agent-tools.ts @@ -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, 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, 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 }); }),