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:
619
packages/dashboard/src/roadmap-suggestions.test.ts
Normal file
619
packages/dashboard/src/roadmap-suggestions.test.ts
Normal 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",
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
521
packages/dashboard/src/roadmap-suggestions.ts
Normal file
521
packages/dashboard/src/roadmap-suggestions.ts
Normal 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;
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user