FN-8546: fix ideation candidate discovery

Expose persisted ideation candidate identities so agents can select a candidate for convergence.

- Render candidate IDs, provenance, source references, and content in show and diverge responses.
- Cover discover-to-converge behavior, duplicate content, empty sessions, and missing sessions.
- Document the direct candidate-ID convergence workflow and add a patch changeset.

Files changed:
 .changeset/fn-8546-ideation-candidate-ids.md       |  7 ++
 docs/agent-tool-surface-full-loop.md               |  4 +-
 docs/ideation/persisted-diverge-converge.md        |  6 +-
 .../src/__tests__/agent-ideation-tools.test.ts     | 93 +++++++++++++++++++---
 packages/engine/src/agent-tools.ts                 | 23 +++++-
 5 files changed, 116 insertions(+), 17 deletions(-)

Fusion-Task-Id: FN-8546

Fusion-Task-Lineage: 7232701f-5c6b-40fc-8d46-d65b97af9c0a

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-23 12:26:02 -07:00
parent ff165ecb5a
commit 84d7306bd3
5 changed files with 116 additions and 17 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Make ideation candidate IDs discoverable for direct convergence.
category: fix
dev: Show and diverge tool text now includes candidate identity, provenance, and content.

View File

@@ -1,5 +1,7 @@
# Full-loop agent tool-surface audit and delivery plan
> **Status:** The mission-hierarchy and persisted-ideation gaps recorded below have been delivered. Current `fn_ideation_*` tools share the persisted factory across engine and eligible chat lanes; show/diverge text includes canonical candidate IDs and provenance so agents can converge an explicit candidate. Remaining entries are the historical delivery audit.
[← Docs index](./README.md)
<!--
@@ -9,7 +11,7 @@ FR-08 requires chat and agent sessions to reach the complete build loop through
## Decision summary
Fusion already has useful task, workflow, goal, research, execution, and verification tools. It does **not** yet give an engine agent or dashboard chat session a complete native route from an idea or research result into the execution-oriented mission hierarchy. In particular, `packages/engine/src/agent-tools.ts` has no `fn_mission_*`, `fn_milestone_*`, `fn_slice_*`, or `fn_feature_*` factory, and no ideation diverge/converge tool. Research can be run by qualifying sessions, but its current handoff ends at a task document/task action rather than an attributable roadmap feature.
Fusion has useful task, workflow, goal, research, execution, verification, mission-hierarchy, and persisted ideation tools. Engine agents and eligible dashboard chat sessions can use shared mission and ideation factories to create or attach a Mission handoff. Ideation show/diverge text returns canonical candidate IDs and provenance so agents can pass an ID directly to convergence. Research can be run by qualifying sessions, but its current handoff ends at a task document/task action rather than an attributable roadmap feature.
The mission hierarchy is **Mission → Milestone → Slice → Feature → Task** ([Missions](./missions.md)); it is not the separate lightweight `Roadmap → RoadmapMilestone → RoadmapFeature` model. This document uses “roadmap” to mean the execution-oriented mission hierarchy unless it explicitly says otherwise.

View File

@@ -3,9 +3,9 @@
Fusion ideation is a project-scoped, bounded operation rather than a free-form document.
1. Start a session with `fn_ideation_start` or Command Center → **Ideation**.
2. Record alternatives with `fn_ideation_diverge`; each candidate records an `agent`, `human`, or `research` origin and optional source reference.
3. Inspect sessions with `fn_ideation_list` and `fn_ideation_show`.
4. Converge an explicit candidate using `fn_ideation_converge` or the Command Center action.
2. Record alternatives with `fn_ideation_diverge`; each candidate records an `agent`, `human`, or `research` origin and optional source reference. Its response lists every newly assigned candidate ID with that provenance and full content.
3. Inspect sessions with `fn_ideation_list` and `fn_ideation_show`. Show lists every persisted candidate's ID, origin, source-reference state (`none` when omitted), and full content; it explicitly reports when a session has no candidates.
4. Copy a candidate ID from diverge or show directly into `fn_ideation_converge` (or use the Command Center action) to converge that explicit candidate.
Convergence creates a canonical Mission by default, or attaches to a supplied `targetMissionId`. The selected candidate and session persist the Mission (and optional Feature) linkage. It never writes an orphan ideation document as the handoff.

View File

@@ -1,7 +1,14 @@
import { describe, expect, it, vi } from "vitest";
import { createIdeationTools } from "../agent-tools.js";
const candidate = { id: "IC-1", sessionId: "IS-1", content: "Candidate", origin: "agent", selected: false };
const candidate = {
id: "IC-1", sessionId: "IS-1", content: "Candidate", origin: "agent" as const, selected: false,
createdAt: "2026-07-23T00:00:00.000Z", updatedAt: "2026-07-23T00:00:00.000Z",
};
const textOf = (result: { content: Array<{ type: string; text: string }> }) => result.content[0]!.text;
const findTool = (store: never, name: string) => createIdeationTools(store).find((tool) => tool.name === name)!;
describe("createIdeationTools", () => {
it("exposes read, divergence, and atomic convergence operations", () => {
@@ -14,26 +21,92 @@ describe("createIdeationTools", () => {
it("delegates convergence to the single persisted operation and returns linkage", async () => {
const convergeSession = vi.fn().mockResolvedValue({ id: "IS-1", status: "converged", targetMissionId: "M-1", candidates: [{ ...candidate, selected: true, linkedMissionId: "M-1" }] });
const store = { getIdeationStore: () => ({ convergeSession }) } as never;
const tool = createIdeationTools(store).find((item) => item.name === "fn_ideation_converge")!;
const result = await tool.execute("call", { sessionId: "IS-1", candidateId: "IC-1" });
const result = await findTool(store, "fn_ideation_converge").execute("call", { sessionId: "IS-1", candidateId: "IC-1" });
expect(convergeSession).toHaveBeenCalledWith("IS-1", "IC-1", { targetMissionId: undefined, targetFeatureId: undefined });
expect(result.details).toMatchObject({ targetMissionId: "M-1", session: { status: "converged" } });
});
it("records all divergent candidates with provenance", async () => {
const addCandidate = vi.fn().mockResolvedValue(candidate);
it("renders every persisted candidate's identity, provenance, and multiline content in show text", async () => {
const candidates = [
{ ...candidate, content: "Duplicate idea\nwith detail" },
{ ...candidate, id: "IC-2", origin: "research" as const, sourceRef: "R-1", content: "Duplicate idea\nwith detail" },
];
const store = { getIdeationStore: () => ({ getSessionWithCandidates: vi.fn().mockResolvedValue({ id: "IS-1", title: "Ideas", status: "open", candidates }) }) } as never;
const result = await findTool(store, "fn_ideation_show").execute("call", { id: "IS-1" });
expect(textOf(result)).toBe([
"IS-1: Ideas (open)",
"Candidates (2)",
"- IC-1 (agent)",
" Source reference: none",
" Content:",
" Duplicate idea",
" with detail",
"- IC-2 (research)",
" Source reference: R-1",
" Content:",
" Duplicate idea",
" with detail",
].join("\n"));
});
it("reports an explicit empty candidate state and preserves missing-session errors", async () => {
const ideation = {
getSessionWithCandidates: vi.fn()
.mockResolvedValueOnce({ id: "IS-empty", title: "Empty", status: "open", candidates: [] })
.mockResolvedValueOnce(undefined),
};
const store = { getIdeationStore: () => ideation } as never;
const tool = findTool(store, "fn_ideation_show");
const empty = await tool.execute("call", { id: "IS-empty" });
expect(textOf(empty)).toContain("Candidates (0): no divergent candidates recorded.");
const missing = await tool.execute("call", { id: "IS-missing" });
expect(missing).toMatchObject({ isError: true, details: { code: "IDEATION_SESSION_NOT_FOUND" } });
});
it("returns every assigned divergent candidate ID in agent-visible text, including duplicate content", async () => {
const created = [
{ ...candidate, content: "Same idea" },
{ ...candidate, id: "IC-2", origin: "human" as const, sourceRef: "note-2", content: "Same idea" },
];
const addCandidate = vi.fn().mockResolvedValueOnce(created[0]).mockResolvedValueOnce(created[1]);
const store = { getIdeationStore: () => ({ addCandidate }) } as never;
const tool = createIdeationTools(store).find((item) => item.name === "fn_ideation_diverge")!;
const result = await tool.execute("call", { sessionId: "IS-1", candidates: [candidate, { ...candidate, id: "IC-2", origin: "research", sourceRef: "R-1" }] });
const result = await findTool(store, "fn_ideation_diverge").execute("call", {
sessionId: "IS-1",
candidates: created.map(({ content, origin, sourceRef }) => ({ content, origin, sourceRef })),
});
expect(addCandidate).toHaveBeenCalledTimes(2);
expect(result.details).toMatchObject({ candidates: [candidate, candidate] });
expect(textOf(result)).toContain("- IC-1 (agent)");
expect(textOf(result)).toContain("- IC-2 (human)");
expect(textOf(result)).toContain("Source reference: none");
expect(textOf(result)).toContain("Source reference: note-2");
expect(result.details).toMatchObject({ candidates: created });
});
it("lets an agent converge with the candidate ID discovered from divergence text", async () => {
const discovered = { ...candidate, id: "IC-discovered", content: "Reachable Mission", sourceRef: "research-42" };
const convergeSession = vi.fn().mockResolvedValue({
id: "IS-1", title: "Ideas", status: "converged", targetMissionId: "M-1", candidates: [{ ...discovered, selected: true }],
});
const store = { getIdeationStore: () => ({ addCandidate: vi.fn().mockResolvedValue(discovered), convergeSession }) } as never;
const divergent = await findTool(store, "fn_ideation_diverge").execute("call", {
sessionId: "IS-1", candidates: [{ content: discovered.content, origin: discovered.origin, sourceRef: discovered.sourceRef }],
});
const discoveredId = textOf(divergent).match(/- (IC-[\w-]+) \(/)?.[1];
expect(discoveredId).toBe(discovered.id);
const converged = await findTool(store, "fn_ideation_converge").execute("call", { sessionId: "IS-1", candidateId: discoveredId });
expect(convergeSession).toHaveBeenCalledWith("IS-1", discovered.id, { targetMissionId: undefined, targetFeatureId: undefined });
expect(textOf(converged)).toBe("Converged IS-1 into Mission M-1");
});
it("returns structured failures for an empty or already-converged session", async () => {
const convergeSession = vi.fn().mockRejectedValue(new Error("Ideation session IS-1 is already converged"));
const store = { getIdeationStore: () => ({ convergeSession }) } as never;
const tool = createIdeationTools(store).find((item) => item.name === "fn_ideation_converge")!;
const result = await tool.execute("call", { sessionId: "IS-1", candidateId: "IC-missing" });
const result = await findTool(store, "fn_ideation_converge").execute("call", { sessionId: "IS-1", candidateId: "IC-missing" });
expect(result.isError).toBe(true);
expect(result.details).toMatchObject({ error: expect.stringContaining("already converged") });
});

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 } 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 { 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";
@@ -3770,6 +3770,19 @@ const ideationToolResult = (text: string, details: Record<string, unknown>, isEr
content: [{ type: "text" as const, text }], details, ...(isError ? { isError: true } : {}),
});
/*
FNXC:Ideation 2026-07-23-12:13:
Convergence requires a canonical candidate ID, so every shared agent-facing
ideation response that exposes candidates must render their ID and provenance
in text rather than leaving them discoverable only in structured details.
*/
const formatIdeationCandidate = (candidate: IdeationCandidate): string => [
`- ${candidate.id} (${candidate.origin})`,
` Source reference: ${candidate.sourceRef ?? "none"}`,
" Content:",
...candidate.content.split("\n").map((line) => ` ${line}`),
].join("\n");
/** Create the persisted ideation surface shared by executor, triage, heartbeat, and chat. */
export function createIdeationTools(store: TaskStore): ToolDefinition[] {
/* eslint-disable @typescript-eslint/no-explicit-any */
@@ -3788,7 +3801,11 @@ export function createIdeationTools(store: TaskStore): ToolDefinition[] {
}),
tool("fn_ideation_show", "Show Ideation Session", "Show one ideation session and its divergent candidates.", ideationShowParams, async ({ id }) => {
const session = await store.getIdeationStore().getSessionWithCandidates(id);
return session ? ideationToolResult(`${session.id}: ${session.title}`, { session }) : ideationToolResult(`Ideation session ${id} not found`, { code: "IDEATION_SESSION_NOT_FOUND", sessionId: id }, true);
if (!session) return ideationToolResult(`Ideation session ${id} not found`, { code: "IDEATION_SESSION_NOT_FOUND", sessionId: id }, true);
const candidates = session.candidates.length
? `Candidates (${session.candidates.length})\n${session.candidates.map(formatIdeationCandidate).join("\n")}`
: "Candidates (0): no divergent candidates recorded.";
return ideationToolResult(`${session.id}: ${session.title} (${session.status})\n${candidates}`, { session });
}),
tool("fn_ideation_start", "Start Ideation", "Create a bounded persisted ideation session.", ideationStartParams, async ({ title, prompt }) => {
const session = await store.getIdeationStore().createSession({ title, prompt });
@@ -3798,7 +3815,7 @@ export function createIdeationTools(store: TaskStore): ToolDefinition[] {
const ideation = store.getIdeationStore();
const created = [];
for (const candidate of candidates) created.push(await ideation.addCandidate(sessionId, candidate));
return ideationToolResult(`Recorded ${created.length} candidate${created.length === 1 ? "" : "s"}`, { candidates: created });
return ideationToolResult(`Recorded ${created.length} candidate${created.length === 1 ? "" : "s"}\n${created.map(formatIdeationCandidate).join("\n")}`, { candidates: created });
}),
tool("fn_ideation_converge", "Converge Ideation", "Select a candidate and atomically create or attach its canonical Mission handoff.", ideationConvergeParams, async ({ sessionId, candidateId, targetMissionId, targetFeatureId }) => {
const session = await store.getIdeationStore().convergeSession(sessionId, candidateId, { targetMissionId, targetFeatureId });