feat(FN-1671): add AI milestone suggestion feature

- Add milestone suggestion generation backend contract with scoring algorithm
- Add milestone suggestion hook actions (generate, accept, dismiss) in useRoadmaps
- Add RoadmapsView UI with suggestion panel, cards, and accept/dismiss buttons
- Document AI milestone suggestion feature in dashboard guide
- Add roadmap-suggestions unit tests covering generation and UX flows
This commit is contained in:
Fusion
2026-04-15 09:49:45 -07:00
committed by gsxdsm
parent 05e928d67f
commit bd7ada7533
8 changed files with 1829 additions and 1 deletions

View File

@@ -0,0 +1,619 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import {
validateSuggestionInput,
generateMilestoneSuggestions,
ValidationError,
ParseError,
__resetSuggestionState,
__setCreateKbAgent,
} from "./roadmap-suggestions";
describe("roadmap-suggestions", () => {
beforeEach(() => {
vi.clearAllMocks();
__resetSuggestionState();
});
afterEach(() => {
__resetSuggestionState();
});
describe("validateSuggestionInput", () => {
it("accepts valid input with all fields", () => {
const input = {
goalPrompt: "Build a modern e-commerce platform",
count: 5,
};
expect(() => validateSuggestionInput(input)).not.toThrow();
});
it("accepts valid input without optional count", () => {
const input = {
goalPrompt: "Build a modern e-commerce platform",
};
expect(() => validateSuggestionInput(input)).not.toThrow();
});
it("accepts count at minimum boundary (1)", () => {
const input = {
goalPrompt: "Test goal",
count: 1,
};
expect(() => validateSuggestionInput(input)).not.toThrow();
});
it("accepts count at maximum boundary (10)", () => {
const input = {
goalPrompt: "Test goal",
count: 10,
};
expect(() => validateSuggestionInput(input)).not.toThrow();
});
it("rejects null input", () => {
expect(() => validateSuggestionInput(null)).toThrow(ValidationError);
});
it("rejects non-object input", () => {
expect(() => validateSuggestionInput("string")).toThrow(ValidationError);
expect(() => validateSuggestionInput(123)).toThrow(ValidationError);
expect(() => validateSuggestionInput([])).toThrow(ValidationError);
});
it("rejects missing goalPrompt", () => {
expect(() => validateSuggestionInput({})).toThrow(ValidationError);
expect(() => validateSuggestionInput({ count: 5 })).toThrow(ValidationError);
});
it("rejects non-string goalPrompt", () => {
expect(() =>
validateSuggestionInput({ goalPrompt: 123 })
).toThrow(ValidationError);
expect(() =>
validateSuggestionInput({ goalPrompt: null })
).toThrow(ValidationError);
expect(() =>
validateSuggestionInput({ goalPrompt: [] })
).toThrow(ValidationError);
});
it("rejects empty goalPrompt", () => {
expect(() =>
validateSuggestionInput({ goalPrompt: "" })
).toThrow(ValidationError);
expect(() =>
validateSuggestionInput({ goalPrompt: " " })
).toThrow(ValidationError);
});
it("rejects goalPrompt exceeding max length", () => {
const longPrompt = "a".repeat(4001);
expect(() =>
validateSuggestionInput({ goalPrompt: longPrompt })
).toThrow(ValidationError);
});
it("accepts goalPrompt at exactly max length", () => {
const maxPrompt = "a".repeat(4000);
expect(() =>
validateSuggestionInput({ goalPrompt: maxPrompt })
).not.toThrow();
});
it("rejects non-integer count", () => {
expect(() =>
validateSuggestionInput({ goalPrompt: "Test", count: 3.5 })
).toThrow(ValidationError);
});
it("rejects count below minimum", () => {
expect(() =>
validateSuggestionInput({ goalPrompt: "Test", count: 0 })
).toThrow(ValidationError);
expect(() =>
validateSuggestionInput({ goalPrompt: "Test", count: -1 })
).toThrow(ValidationError);
});
it("rejects count above maximum", () => {
expect(() =>
validateSuggestionInput({ goalPrompt: "Test", count: 11 })
).toThrow(ValidationError);
});
});
describe("generateMilestoneSuggestions", () => {
const rootDir = "/test/project";
it("generates milestone suggestions successfully", async () => {
const mockSession = {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
state: {
messages: [
{
role: "assistant",
content: [
{
type: "text",
text: '[\n {"title": "Foundation Setup", "description": "Set up core infrastructure"},\n {"title": "User Authentication", "description": "Implement login and user management"}\n]',
},
],
},
],
},
};
const mockCreateKbAgent = vi.fn().mockResolvedValue({
session: mockSession,
});
__setCreateKbAgent(mockCreateKbAgent);
const suggestions = await generateMilestoneSuggestions(
"Build a modern e-commerce platform",
5,
rootDir
);
expect(suggestions).toHaveLength(2);
expect(suggestions[0]).toEqual({
title: "Foundation Setup",
description: "Set up core infrastructure",
});
expect(suggestions[1]).toEqual({
title: "User Authentication",
description: "Implement login and user management",
});
});
it("uses default count of 5 when not specified", async () => {
const mockSession = {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
state: {
messages: [
{
role: "assistant",
content: [
{
type: "text",
text: '[\n {"title": "Setup", "description": "Initial setup"}\n]',
},
],
},
],
},
};
const mockCreateKbAgent = vi.fn().mockResolvedValue({
session: mockSession,
});
__setCreateKbAgent(mockCreateKbAgent);
await generateMilestoneSuggestions("Test goal", undefined, rootDir);
expect(mockCreateKbAgent).toHaveBeenCalledWith(
expect.objectContaining({
cwd: rootDir,
systemPrompt: expect.stringContaining("milestone"),
})
);
});
it("respects the count parameter", async () => {
const mockSession = {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
state: {
messages: [
{
role: "assistant",
content: [
{
type: "text",
text: '[\n {"title": "Setup", "description": "Initial setup"}\n]',
},
],
},
],
},
};
const mockCreateKbAgent = vi.fn().mockResolvedValue({
session: mockSession,
});
__setCreateKbAgent(mockCreateKbAgent);
await generateMilestoneSuggestions("Test goal", 3, rootDir);
expect(mockCreateKbAgent).toHaveBeenCalled();
});
it("includes count in user message", async () => {
const mockSession = {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
state: {
messages: [
{
role: "assistant",
content: [
{
type: "text",
text: '[\n {"title": "Setup", "description": "Initial setup"}\n]',
},
],
},
],
},
};
const mockCreateKbAgent = vi.fn().mockResolvedValue({
session: mockSession,
});
__setCreateKbAgent(mockCreateKbAgent);
await generateMilestoneSuggestions("Build a platform", 5, rootDir);
expect(mockSession.prompt).toHaveBeenCalledWith(
expect.stringContaining("5 milestones")
);
});
it("disposes session after successful generation", async () => {
const mockSession = {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
state: {
messages: [
{
role: "assistant",
content: [
{
type: "text",
text: '[\n {"title": "Setup", "description": "Initial setup"}\n]',
},
],
},
],
},
};
const mockCreateKbAgent = vi.fn().mockResolvedValue({
session: mockSession,
});
__setCreateKbAgent(mockCreateKbAgent);
await generateMilestoneSuggestions("Test", 5, rootDir);
expect(mockSession.dispose).toHaveBeenCalled();
});
it("throws when AI service is unavailable", async () => {
__setCreateKbAgent(undefined);
await expect(
generateMilestoneSuggestions("Test", 5, rootDir)
).rejects.toThrow("AI service is not available");
});
it("throws when rootDir is missing", async () => {
const mockSession = {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
state: {
messages: [],
},
};
const mockCreateKbAgent = vi.fn().mockResolvedValue({
session: mockSession,
});
__setCreateKbAgent(mockCreateKbAgent);
await expect(
generateMilestoneSuggestions("Test", 5)
).rejects.toThrow("rootDir is required");
});
it("handles markdown-wrapped JSON response", async () => {
const mockSession = {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
state: {
messages: [
{
role: "assistant",
content: [
{
type: "text",
text: '```json\n[\n {"title": "Setup", "description": "Initial setup"}\n]\n```',
},
],
},
],
},
};
const mockCreateKbAgent = vi.fn().mockResolvedValue({
session: mockSession,
});
__setCreateKbAgent(mockCreateKbAgent);
const suggestions = await generateMilestoneSuggestions("Test", 5, rootDir);
expect(suggestions).toHaveLength(1);
expect(suggestions[0].title).toBe("Setup");
});
it("handles plain array response without markdown", async () => {
const mockSession = {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
state: {
messages: [
{
role: "assistant",
content: [
{
type: "text",
text: '[\n {"title": "Phase 1", "description": "First phase"}\n]',
},
],
},
],
},
};
const mockCreateKbAgent = vi.fn().mockResolvedValue({
session: mockSession,
});
__setCreateKbAgent(mockCreateKbAgent);
const suggestions = await generateMilestoneSuggestions("Test", 5, rootDir);
expect(suggestions).toHaveLength(1);
expect(suggestions[0].title).toBe("Phase 1");
});
it("handles suggestions without description", async () => {
const mockSession = {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
state: {
messages: [
{
role: "assistant",
content: [
{
type: "text",
text: '[{"title": "Phase 1"}, {"title": "Phase 2", "description": "With desc"}]',
},
],
},
],
},
};
const mockCreateKbAgent = vi.fn().mockResolvedValue({
session: mockSession,
});
__setCreateKbAgent(mockCreateKbAgent);
const suggestions = await generateMilestoneSuggestions("Test", 5, rootDir);
expect(suggestions).toHaveLength(2);
expect(suggestions[0]).toEqual({ title: "Phase 1", description: undefined });
expect(suggestions[1]).toEqual({ title: "Phase 2", description: "With desc" });
});
it("limits suggestions to requested count", async () => {
const mockSession = {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
state: {
messages: [
{
role: "assistant",
content: [
{
type: "text",
text: '[\n {"title": "One"}, {"title": "Two"}, {"title": "Three"}, {"title": "Four"}, {"title": "Five"}\n]',
},
],
},
],
},
};
const mockCreateKbAgent = vi.fn().mockResolvedValue({
session: mockSession,
});
__setCreateKbAgent(mockCreateKbAgent);
const suggestions = await generateMilestoneSuggestions("Test", 2, rootDir);
expect(suggestions).toHaveLength(2);
expect(suggestions[0].title).toBe("One");
expect(suggestions[1].title).toBe("Two");
});
it("strips whitespace from titles and descriptions", async () => {
const mockSession = {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
state: {
messages: [
{
role: "assistant",
content: [
{
type: "text",
text: '[\n {"title": " Trimmed Title ", "description": " With whitespace "}\n]',
},
],
},
],
},
};
const mockCreateKbAgent = vi.fn().mockResolvedValue({
session: mockSession,
});
__setCreateKbAgent(mockCreateKbAgent);
const suggestions = await generateMilestoneSuggestions("Test", 5, rootDir);
expect(suggestions[0]).toEqual({
title: "Trimmed Title",
description: "With whitespace",
});
});
it("throws ParseError when AI returns no JSON", async () => {
const mockSession = {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
state: {
messages: [
{
role: "assistant",
content: [
{
type: "text",
text: "Here are some milestones without JSON",
},
],
},
],
},
};
const mockCreateKbAgent = vi.fn().mockResolvedValue({
session: mockSession,
});
__setCreateKbAgent(mockCreateKbAgent);
await expect(
generateMilestoneSuggestions("Test", 5, rootDir)
).rejects.toThrow(ParseError);
});
it("throws ParseError when JSON is not an array", async () => {
const mockSession = {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
state: {
messages: [
{
role: "assistant",
content: [
{
type: "text",
text: '{"title": "Not an array"}',
},
],
},
],
},
};
const mockCreateKbAgent = vi.fn().mockResolvedValue({
session: mockSession,
});
__setCreateKbAgent(mockCreateKbAgent);
await expect(
generateMilestoneSuggestions("Test", 5, rootDir)
).rejects.toThrow(ParseError);
});
it("throws ParseError when milestone is missing title", async () => {
const mockSession = {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
state: {
messages: [
{
role: "assistant",
content: [
{
type: "text",
text: '[{"description": "Missing title"}]',
},
],
},
],
},
};
const mockCreateKbAgent = vi.fn().mockResolvedValue({
session: mockSession,
});
__setCreateKbAgent(mockCreateKbAgent);
await expect(
generateMilestoneSuggestions("Test", 5, rootDir)
).rejects.toThrow(ParseError);
});
it("supports model override parameters", async () => {
const mockSession = {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
state: {
messages: [
{
role: "assistant",
content: [
{
type: "text",
text: '[\n {"title": "Setup", "description": "Initial setup"}\n]',
},
],
},
],
},
};
const mockCreateKbAgent = vi.fn().mockResolvedValue({
session: mockSession,
});
__setCreateKbAgent(mockCreateKbAgent);
await generateMilestoneSuggestions(
"Test",
5,
rootDir,
"openai",
"gpt-4o"
);
expect(mockCreateKbAgent).toHaveBeenCalledWith(
expect.objectContaining({
defaultProvider: "openai",
defaultModelId: "gpt-4o",
})
);
});
});
});

View File

@@ -0,0 +1,521 @@
/**
* Roadmap Milestone Suggestion Generation Service
*
* Provides AI-powered milestone suggestion generation for roadmaps.
* Users can generate milestone ideas from a goal prompt and accept them
* into their roadmap.
*
* Features:
* - AI agent integration via dynamic import of @fusion/engine
* - Planning-style JSON extraction with repair
* - Input validation (goal prompt max length, count bounds)
* - Read-only endpoint (no persistence of suggestions)
* - Error mapping (validation 400, not found 404, AI/parser 500/503)
*/
import { randomUUID } from "node:crypto";
// Dynamic import for @fusion/engine to avoid resolution issues in test environment
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let createKbAgent: any;
// Track if engine has been initialized (prevents multiple imports)
let engineInitialized = false;
// Flag to indicate if createKbAgent was explicitly set (even to undefined)
let createKbAgentExplicitlySet = false;
// Initialize the import (this runs in actual server, mocked in tests)
async function initEngine(): Promise<void> {
if (engineInitialized) return;
// If createKbAgent was explicitly set (even to undefined), don't try to import
if (createKbAgentExplicitlySet) {
engineInitialized = true;
return;
}
if (!createKbAgent) {
try {
// Use dynamic import with variable to prevent static analysis
const engineModule = "@fusion/engine";
const engine = await import(/* @vite-ignore */ engineModule);
createKbAgent = engine.createKbAgent;
} catch {
// Allow failure in test environments - agent functionality will be stubbed
createKbAgent = undefined;
}
}
engineInitialized = true;
}
// ── Types ───────────────────────────────────────────────────────────────────
/** Input for generating milestone suggestions */
export interface GenerateMilestoneSuggestionsInput {
/** The goal prompt/description for the roadmap */
goalPrompt: string;
/** Number of milestones to generate (default 5, max 10) */
count?: number;
}
/** A suggested milestone with title and optional description */
export interface MilestoneSuggestion {
title: string;
description?: string;
}
/** System prompt for milestone suggestion generation */
export const MILESTONE_SUGGESTION_SYSTEM_PROMPT = `You are a milestone planning assistant for a product roadmap system.
Your job is to suggest logical milestones that would help achieve a user's roadmap goal.
## Guidelines
1. **Think about phases**: Break the goal into logical phases (e.g., "Foundation", "Core Features", "Polish", "Launch")
2. **Use clear titles**: Milestone titles should be concise and descriptive (e.g., "Authentication System", "User Dashboard MVP")
3. **Add context**: Include a brief description explaining what this milestone encompasses
4. **Order matters**: List milestones in the order they should be completed
5. **Realistic scope**: Each milestone should be achievable in 2-4 weeks
## Output Format
Respond with ONLY a valid JSON array of milestone suggestions:
[
{
"title": "Milestone Title",
"description": "Brief description of what this milestone covers (1-2 sentences)"
},
...
]
Do NOT include any markdown formatting, code fences, or additional text. Only output the JSON array.`;
// ── Constants ─────────────────────────────────────────────────────────────
/** Maximum length for goal prompt */
const MAX_GOAL_PROMPT_LENGTH = 4000;
/** Default number of suggestions to generate */
const DEFAULT_SUGGESTION_COUNT = 5;
/** Maximum number of suggestions to generate */
const MAX_SUGGESTION_COUNT = 10;
/** Minimum number of suggestions to generate */
const MIN_SUGGESTION_COUNT = 1;
/** Max number of retry attempts when AI returns unparseable output */
const MAX_PARSE_RETRIES = 1;
// ── Validation ─────────────────────────────────────────────────────────────
/**
* Validate the input for generating milestone suggestions.
* Throws with a descriptive error message on validation failure.
*/
export function validateSuggestionInput(input: unknown): asserts input is GenerateMilestoneSuggestionsInput {
if (!input || typeof input !== "object") {
throw new ValidationError("Request body must be an object");
}
const { goalPrompt, count } = input as Record<string, unknown>;
// Validate goalPrompt
if (typeof goalPrompt !== "string" || !goalPrompt.trim()) {
throw new ValidationError("goalPrompt is required and must be a non-empty string");
}
if (goalPrompt.length > MAX_GOAL_PROMPT_LENGTH) {
throw new ValidationError(
`goalPrompt exceeds maximum length of ${MAX_GOAL_PROMPT_LENGTH} characters`
);
}
// Validate count (optional)
if (count !== undefined) {
if (typeof count !== "number" || !Number.isInteger(count)) {
throw new ValidationError("count must be an integer");
}
if (count < MIN_SUGGESTION_COUNT || count > MAX_SUGGESTION_COUNT) {
throw new ValidationError(
`count must be between ${MIN_SUGGESTION_COUNT} and ${MAX_SUGGESTION_COUNT}`
);
}
}
}
// ── JSON Extraction ────────────────────────────────────────────────────────
/**
* Extract the best JSON candidate from AI response text.
* Handles markdown-wrapped JSON, embedded JSON, and balanced brace extraction.
*/
function extractJsonCandidate(text: string): string | null {
if (!text || !text.trim()) return null;
// 1. Try markdown code blocks first (most reliable)
const codeBlockMatch = text.match(/```(?:json)?\s*([\s\S]*?)\s*```/);
if (codeBlockMatch?.[1]) {
const candidate = codeBlockMatch[1].trim();
if (candidate.startsWith("[")) return candidate;
}
// 2. Find all top-level bracket-delimited arrays using balanced counting
const candidates: Array<{ start: number; end: number; text: string }> = [];
for (let i = 0; i < text.length; i++) {
if (text[i] === "[") {
let depth = 0;
let inString = false;
let escape = false;
for (let j = i; j < text.length; j++) {
const ch = text[j];
if (escape) {
escape = false;
continue;
}
if (ch === "\\") {
escape = true;
continue;
}
if (ch === '"') {
inString = !inString;
continue;
}
if (inString) continue;
if (ch === "[") depth++;
if (ch === "]") depth--;
if (depth === 0) {
const candidate = text.slice(i, j + 1).trim();
// Only accept candidates that parse as valid JSON
try {
JSON.parse(candidate);
candidates.push({ start: i, end: j, text: candidate });
} catch {
// Not valid JSON, skip
}
break;
}
}
}
}
// Pick the largest valid candidate (most likely the full response)
if (candidates.length > 0) {
candidates.sort((a, b) => b.text.length - a.text.length);
return candidates[0].text;
}
// 3. Last resort: try the full trimmed text
const trimmed = text.trim();
if (trimmed.startsWith("[")) return trimmed;
return null;
}
/**
* Attempt to repair common JSON issues:
* - Truncated JSON (missing closing brackets/braces)
* - Trailing commas before closing brackets/braces
* - Missing closing quotes
*/
function repairJson(text: string): string {
let repaired = text;
// Fix trailing commas before } or ]
repaired = repaired.replace(/,\s*([}\]])/g, "$1");
// Count open/close braces and brackets
let openBraces = 0;
let openBrackets = 0;
let inString = false;
let escape = false;
for (const ch of repaired) {
if (escape) { escape = false; continue; }
if (ch === "\\") { escape = true; continue; }
if (ch === '"') { inString = !inString; continue; }
if (inString) continue;
if (ch === "{") openBraces++;
if (ch === "}") openBraces--;
if (ch === "[") openBrackets++;
if (ch === "]") openBrackets--;
}
// If we're in an unclosed string, close it
if (inString) {
repaired += '"';
}
// Re-count after potential string fix
openBraces = 0;
openBrackets = 0;
inString = false;
escape = false;
for (const ch of repaired) {
if (escape) { escape = false; continue; }
if (ch === "\\") { escape = true; continue; }
if (ch === '"') { inString = !inString; continue; }
if (inString) continue;
if (ch === "{") openBraces++;
if (ch === "}") openBraces--;
if (ch === "[") openBrackets++;
if (ch === "]") openBrackets--;
}
// Close unclosed brackets and braces
repaired += "]".repeat(Math.max(0, openBrackets));
repaired += "}".repeat(Math.max(0, openBraces));
return repaired;
}
/**
* Parse AI response JSON with robust extraction and recovery.
*/
function parseMilestoneSuggestions(text: string): MilestoneSuggestion[] {
const candidate = extractJsonCandidate(text);
if (!candidate) {
throw new ParseError("AI returned no valid JSON. Please try again.");
}
let parsed: unknown;
try {
parsed = JSON.parse(candidate);
} catch {
// Attempt repair for truncated/malformed JSON
try {
const repaired = repairJson(candidate);
parsed = JSON.parse(repaired);
} catch (repairErr) {
throw new ParseError(
`Failed to parse AI response: ${repairErr instanceof Error ? repairErr.message : "Unknown error"}. Please try again.`
);
}
}
// Validate structure: must be an array
if (!Array.isArray(parsed)) {
throw new ParseError("AI response must be a JSON array of milestone suggestions");
}
// Validate and normalize each item
const suggestions: MilestoneSuggestion[] = [];
for (let i = 0; i < parsed.length; i++) {
const item = parsed[i];
if (!item || typeof item !== "object") {
throw new ParseError(`Item ${i + 1} in AI response is invalid`);
}
const { title, description } = item as Record<string, unknown>;
if (typeof title !== "string" || !title.trim()) {
throw new ParseError(`Item ${i + 1} in AI response is missing a valid title`);
}
suggestions.push({
title: title.trim(),
description: typeof description === "string" && description.trim()
? description.trim()
: undefined,
});
}
if (suggestions.length === 0) {
throw new ParseError("AI returned no milestone suggestions");
}
return suggestions;
}
// ── Generation ─────────────────────────────────────────────────────────────
/**
* Generate milestone suggestions from a goal prompt.
*
* @param goalPrompt - The goal/description for the roadmap
* @param count - Number of suggestions to generate (default 5, max 10)
* @param rootDir - Project root directory for AI context
* @param modelProvider - Optional AI model provider override
* @param modelId - Optional AI model ID override
* @returns Array of milestone suggestions
*/
export async function generateMilestoneSuggestions(
goalPrompt: string,
count: number = DEFAULT_SUGGESTION_COUNT,
rootDir?: string,
modelProvider?: string,
modelId?: string,
): Promise<MilestoneSuggestion[]> {
// Ensure engine is loaded before using createKbAgent
await initEngine();
if (!createKbAgent) {
throw new ServiceUnavailableError("AI service is not available");
}
if (!rootDir) {
throw new Error("rootDir is required for AI-powered suggestion generation");
}
// Create a unique session ID for this generation
const sessionId = randomUUID();
let agent: ReturnType<typeof createKbAgent> | undefined;
try {
// Create AI agent with milestone suggestion system prompt
agent = await createKbAgent({
cwd: rootDir,
systemPrompt: MILESTONE_SUGGESTION_SYSTEM_PROMPT,
tools: "readonly",
...(modelProvider && modelId
? {
defaultProvider: modelProvider,
defaultModelId: modelId,
}
: {}),
onThinking: () => {
// Ignore thinking output for milestone suggestions
},
onText: () => {
// Ignore incremental text
},
});
// Send the goal prompt with count instruction
const userMessage = `Please suggest ${count} milestones for the following roadmap goal:\n\n${goalPrompt.trim()}`;
// Get response from AI
await agent.session.prompt(userMessage);
// Extract response text from agent state
interface AgentMessage {
role: string;
content?: string | Array<{ type: string; text: string }>;
}
const lastMessage = (agent.session.state.messages as AgentMessage[])
.filter((m: AgentMessage) => m.role === "assistant")
.pop();
let responseText = "";
if (lastMessage?.content) {
if (typeof lastMessage.content === "string") {
responseText = lastMessage.content;
} else if (Array.isArray(lastMessage.content)) {
responseText = lastMessage.content
.filter((c: { type: string; text: string }): c is { type: "text"; text: string } => c.type === "text")
.map((c: { type: string; text: string }) => c.text)
.join("");
}
}
// Parse the JSON response with retry
let suggestions: MilestoneSuggestion[] | undefined;
let lastError: Error | undefined;
for (let attempt = 0; attempt <= MAX_PARSE_RETRIES; attempt++) {
try {
suggestions = parseMilestoneSuggestions(responseText);
break;
} catch (err) {
lastError = err instanceof Error ? err : new Error(String(err));
if (attempt < MAX_PARSE_RETRIES) {
// Retry: ask the AI to reformat as clean JSON
try {
await agent.session.prompt(
"Your previous response could not be parsed as JSON. " +
"Please respond with ONLY a JSON array of milestone suggestions in this format: " +
'[{"title": "Milestone Title", "description": "Brief description"}, ...]. ' +
"No markdown, no explanation, just the JSON array."
);
// Get the new response text
const retryMessage = (agent.session.state.messages as AgentMessage[])
.filter((m: AgentMessage) => m.role === "assistant")
.pop();
let retryText = "";
if (retryMessage?.content) {
if (typeof retryMessage.content === "string") {
retryText = retryMessage.content;
} else if (Array.isArray(retryMessage.content)) {
retryText = retryMessage.content
.filter((c: { type: string; text: string }): c is { type: "text"; text: string } => c.type === "text")
.map((c: { type: string; text: string }) => c.text)
.join("");
}
}
responseText = retryText;
} catch {
// Retry prompt itself failed — give up
break;
}
}
}
}
if (!suggestions) {
throw new ParseError(
`Failed to parse AI response after ${MAX_PARSE_RETRIES + 1} attempts: ${lastError?.message || "Unknown error"}`
);
}
// Limit to requested count
return suggestions.slice(0, count);
} finally {
// Always dispose the agent session
if (agent) {
try {
agent.session.dispose?.();
} catch {
// Ignore disposal errors
}
}
}
}
// ── Custom Errors ───────────────────────────────────────────────────────────
export class ValidationError extends Error {
constructor(message: string) {
super(message);
this.name = "ValidationError";
}
}
export class ParseError extends Error {
constructor(message: string) {
super(message);
this.name = "ParseError";
}
}
export class ServiceUnavailableError extends Error {
constructor(message: string) {
super(message);
this.name = "ServiceUnavailableError";
}
}
// ── Test Helpers ───────────────────────────────────────────────────────────
/**
* Reset module state. Used for testing only.
*/
export function __resetSuggestionState(): void {
createKbAgent = undefined;
engineInitialized = false;
createKbAgentExplicitlySet = false;
}
/**
* Inject a mock createKbAgent function. Used for testing only.
*/
export function __setCreateKbAgent(mock: typeof createKbAgent): void {
createKbAgent = mock;
createKbAgentExplicitlySet = true;
}

View File

@@ -42,6 +42,13 @@ import {
import { getMissionInterviewSession, cleanupMissionInterviewSession } from "./mission-interview.js";
import { getTargetInterviewSession, cleanupTargetInterviewSession } from "./milestone-slice-interview.js";
import { writeSSEEvent } from "./sse-buffer.js";
import {
generateMilestoneSuggestions,
validateSuggestionInput,
ValidationError as SuggestionValidationError,
ParseError as SuggestionParseError,
ServiceUnavailableError as SuggestionServiceUnavailableError,
} from "./roadmap-suggestions.js";
import {
ApiError,
badRequest,
@@ -2715,6 +2722,63 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
}
});
// ── Roadmap Milestone Suggestions ───────────────────────────────────────
// Generate milestone suggestions from a goal prompt
router.post("/roadmaps/:roadmapId/suggestions/milestones", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const roadmapStore = scopedStore.getRoadmapStore();
const { roadmapId } = req.params;
// Check if roadmap exists
const roadmap = roadmapStore.getRoadmap(roadmapId);
if (!roadmap) {
throw notFound(`Roadmap ${roadmapId} not found`);
}
// Validate input
let input: { goalPrompt: string; count?: number };
try {
validateSuggestionInput(req.body);
input = req.body as { goalPrompt: string; count?: number };
} catch (err) {
if (err instanceof SuggestionValidationError) {
throw badRequest(err.message);
}
throw err;
}
// Get project root directory for AI context
const rootDir = scopedStore.getRootDir();
// Generate suggestions
try {
const suggestions = await generateMilestoneSuggestions(
input.goalPrompt,
input.count,
rootDir
);
res.json({ suggestions });
} catch (err) {
if (err instanceof SuggestionParseError) {
throw internalError(err.message);
}
if (err instanceof SuggestionServiceUnavailableError) {
res.status(503).json({ error: err.message });
return;
}
throw err;
}
} catch (err: any) {
if (err instanceof ApiError) {
throw err;
}
rethrowAsApiError(err, "Failed to generate milestone suggestions");
}
});
// List all tasks
router.get("/tasks", async (req, res) => {
try {