feat(FN-1672): add AI feature suggestion UI to roadmaps
- Add dashboard API endpoints for roadmap feature suggestions - Create useRoadmaps hook for fetching and managing roadmap data with suggestions - Add feature suggestion UI to RoadmapsView with Accept All / Accept Individual buttons - Render AI suggestions in MilestoneCard component - Add CSS styling for suggestion buttons and interaction states - Write comprehensive tests for useRoadmaps hook and RoadmapsView component - Update dashboard guide documentation with feature suggestion usage
This commit is contained in:
@@ -246,6 +246,29 @@ Roadmaps View includes one-click AI-powered milestone generation to help you qui
|
||||
- Suggestions are ephemeral (in-memory only) and don't persist to the database
|
||||
- The generate button is disabled when no roadmap is selected or prompt is empty
|
||||
|
||||
### AI Feature Suggestions
|
||||
|
||||
Within each milestone, you can generate AI-powered feature suggestions to quickly add features.
|
||||
|
||||
**How to use:**
|
||||
|
||||
1. Open a roadmap and select it
|
||||
2. Find the milestone you want to add features to
|
||||
3. Click the **AI Suggestions** button in the milestone's action bar
|
||||
4. Review the suggested features:
|
||||
- Click the **check icon** on any suggestion to accept it as a feature
|
||||
- Click **Accept All** to add all suggestions as features (in order)
|
||||
- Click **Clear** to discard all suggestions
|
||||
|
||||
**Key features:**
|
||||
|
||||
- Features are generated using AI based on the milestone's context
|
||||
- Suggestions include existing features to avoid duplication
|
||||
- Accepted features appear immediately in the milestone
|
||||
- Features are created in the order displayed in the suggestion list
|
||||
- Suggestion state is scoped to each milestone (no cross-milestone leakage)
|
||||
- The AI Suggestions button shows "Generating..." while a request is in flight
|
||||
|
||||
### Empty States
|
||||
|
||||
- **No roadmaps**: "No roadmaps yet. Click + to create one."
|
||||
|
||||
@@ -4303,5 +4303,73 @@ describe("Settings API wrappers", () => {
|
||||
const [url] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
expect(url).toBe("/api/roadmaps/features/RF-001/move?projectId=proj_xyz");
|
||||
});
|
||||
|
||||
it("generateFeatureSuggestions sends POST with milestone ID", async () => {
|
||||
const { generateFeatureSuggestions } = await import("./api");
|
||||
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
headers: {
|
||||
get: (name: string) =>
|
||||
name.toLowerCase() === "content-type" ? "application/json" : null,
|
||||
},
|
||||
json: () => Promise.resolve({ suggestions: [{ title: "Feature 1" }, { title: "Feature 2" }] }),
|
||||
text: () => Promise.resolve(JSON.stringify({ suggestions: [{ title: "Feature 1" }, { title: "Feature 2" }] })),
|
||||
} as unknown as Response);
|
||||
|
||||
const result = await generateFeatureSuggestions("RMS-001");
|
||||
|
||||
expect(result.suggestions).toHaveLength(2);
|
||||
expect(result.suggestions[0].title).toBe("Feature 1");
|
||||
const [url] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
expect(url).toContain("/api/roadmaps/milestones/RMS-001/suggestions/features");
|
||||
});
|
||||
|
||||
it("generateFeatureSuggestions includes input parameters in body", async () => {
|
||||
const { generateFeatureSuggestions } = await import("./api");
|
||||
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
headers: {
|
||||
get: (name: string) =>
|
||||
name.toLowerCase() === "content-type" ? "application/json" : null,
|
||||
},
|
||||
json: () => Promise.resolve({ suggestions: [] }),
|
||||
text: () => Promise.resolve(JSON.stringify({ suggestions: [] })),
|
||||
} as unknown as Response);
|
||||
|
||||
await generateFeatureSuggestions("RMS-001", { prompt: "Focus on auth", count: 3 });
|
||||
|
||||
const [, options] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
const body = JSON.parse((options as RequestInit).body as string);
|
||||
expect(body.prompt).toBe("Focus on auth");
|
||||
expect(body.count).toBe(3);
|
||||
});
|
||||
|
||||
it("generateFeatureSuggestions includes projectId when provided", async () => {
|
||||
const { generateFeatureSuggestions } = await import("./api");
|
||||
|
||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
statusText: "OK",
|
||||
headers: {
|
||||
get: (name: string) =>
|
||||
name.toLowerCase() === "content-type" ? "application/json" : null,
|
||||
},
|
||||
json: () => Promise.resolve({ suggestions: [] }),
|
||||
text: () => Promise.resolve(JSON.stringify({ suggestions: [] })),
|
||||
} as unknown as Response);
|
||||
|
||||
await generateFeatureSuggestions("RMS-001", undefined, "proj_abc");
|
||||
|
||||
const [url] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
expect(url).toContain("/api/roadmaps/milestones/RMS-001/suggestions/features");
|
||||
expect(url).toContain("projectId=proj_abc");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4592,6 +4592,40 @@ export function generateMilestoneSuggestions(
|
||||
);
|
||||
}
|
||||
|
||||
/** Response type for feature suggestions */
|
||||
export interface FeatureSuggestionsResponse {
|
||||
suggestions: Array<{
|
||||
title: string;
|
||||
description?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
/** Input for generating feature suggestions */
|
||||
export interface GenerateFeatureSuggestionsInput {
|
||||
/** Optional prompt to guide feature generation */
|
||||
prompt?: string;
|
||||
/** Number of features to generate (default 5, max 10) */
|
||||
count?: number;
|
||||
}
|
||||
|
||||
/** Generate feature suggestions for a milestone */
|
||||
export function generateFeatureSuggestions(
|
||||
milestoneId: string,
|
||||
input?: GenerateFeatureSuggestionsInput,
|
||||
projectId?: string
|
||||
): Promise<FeatureSuggestionsResponse> {
|
||||
return api<FeatureSuggestionsResponse>(
|
||||
withProjectId(`/roadmaps/milestones/${encodeURIComponent(milestoneId)}/suggestions/features`, projectId),
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
...(input?.prompt !== undefined ? { prompt: input.prompt.trim() } : {}),
|
||||
...(input?.count !== undefined ? { count: input.count } : {}),
|
||||
}),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// ── AI Sessions (Background Tasks) ─────────────────────────────────────────
|
||||
|
||||
export interface AiSessionSummary {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { Plus, Pencil, Trash2, Check, X, GripVertical } from "lucide-react";
|
||||
import { Plus, Pencil, Trash2, Check, X, GripVertical, Sparkles } from "lucide-react";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
import { useRoadmaps } from "../hooks/useRoadmaps";
|
||||
import { useRoadmaps, type FeatureSuggestion } from "../hooks/useRoadmaps";
|
||||
import type {
|
||||
Roadmap,
|
||||
RoadmapMilestone,
|
||||
@@ -178,6 +178,13 @@ function MilestoneCard({
|
||||
onFeatureDrop,
|
||||
onFeatureDragLeave,
|
||||
onFeatureDropOnMilestone,
|
||||
// Feature suggestion props
|
||||
featureSuggestions,
|
||||
isGeneratingFeatureSuggestions,
|
||||
onGenerateFeatureSuggestions,
|
||||
onAcceptFeatureSuggestion,
|
||||
onAcceptAllFeatureSuggestions,
|
||||
onClearFeatureSuggestions,
|
||||
}: {
|
||||
milestone: RoadmapMilestone;
|
||||
features: RoadmapFeature[];
|
||||
@@ -215,6 +222,13 @@ function MilestoneCard({
|
||||
onFeatureDrop: (featureId: string, targetIndex: number) => void;
|
||||
onFeatureDragLeave: (e: React.DragEvent) => void;
|
||||
onFeatureDropOnMilestone: () => void;
|
||||
// Feature suggestion props
|
||||
featureSuggestions?: FeatureSuggestion[];
|
||||
isGeneratingFeatureSuggestions?: boolean;
|
||||
onGenerateFeatureSuggestions?: () => void;
|
||||
onAcceptFeatureSuggestion?: (index: number) => void;
|
||||
onAcceptAllFeatureSuggestions?: () => void;
|
||||
onClearFeatureSuggestions?: () => void;
|
||||
}) {
|
||||
const isEditingMilestone = milestoneEdit?.milestoneId === milestone.id;
|
||||
|
||||
@@ -391,6 +405,20 @@ function MilestoneCard({
|
||||
<Plus size={12} />
|
||||
<span>Add Feature</span>
|
||||
</button>
|
||||
<button
|
||||
className="roadmaps-view__suggest-btn"
|
||||
onClick={() => {
|
||||
// Generate feature suggestions for this milestone
|
||||
onGenerateFeatureSuggestions?.();
|
||||
}}
|
||||
disabled={isGeneratingFeatureSuggestions ?? false}
|
||||
title="Generate feature suggestions with AI"
|
||||
aria-label="Generate feature suggestions"
|
||||
data-testid={`generate-features-${milestone.id}`}
|
||||
>
|
||||
<Sparkles size={12} />
|
||||
<span>{isGeneratingFeatureSuggestions ? "Generating..." : "AI Suggestions"}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -567,6 +595,60 @@ function MilestoneCard({
|
||||
);
|
||||
})
|
||||
)}
|
||||
|
||||
{/* Feature Suggestions Section */}
|
||||
{featureSuggestions && featureSuggestions.length > 0 && (
|
||||
<div className="roadmap-suggestion-section">
|
||||
<div className="roadmap-suggestion-header">
|
||||
<h4 className="roadmap-suggestion-title">AI Feature Suggestions</h4>
|
||||
<div className="roadmap-suggestion-actions">
|
||||
<button
|
||||
className="roadmap-suggestion-accept-all-btn"
|
||||
onClick={() => onAcceptAllFeatureSuggestions?.()}
|
||||
title="Accept all suggestions"
|
||||
aria-label="Accept all"
|
||||
data-testid={`accept-all-features-${milestone.id}`}
|
||||
>
|
||||
Accept All
|
||||
</button>
|
||||
<button
|
||||
className="roadmap-suggestion-clear-btn"
|
||||
onClick={() => onClearFeatureSuggestions?.()}
|
||||
title="Clear suggestions"
|
||||
aria-label="Clear"
|
||||
data-testid={`clear-features-${milestone.id}`}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="roadmap-suggestion-list">
|
||||
{featureSuggestions.map((suggestion, index) => (
|
||||
<div
|
||||
key={`suggestion-${index}`}
|
||||
className="roadmap-suggestion-card"
|
||||
data-testid={`feature-suggestion-${milestone.id}-${index}`}
|
||||
>
|
||||
<div className="roadmap-suggestion-content">
|
||||
<span className="roadmap-suggestion-card-title">{suggestion.title}</span>
|
||||
{suggestion.description && (
|
||||
<p className="roadmap-suggestion-card-desc">{suggestion.description}</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
className="roadmap-suggestion-accept-btn"
|
||||
onClick={() => onAcceptFeatureSuggestion?.(index)}
|
||||
title="Accept this suggestion"
|
||||
aria-label="Accept"
|
||||
data-testid={`accept-feature-${milestone.id}-${index}`}
|
||||
>
|
||||
<Check size={12} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -789,6 +871,12 @@ export function RoadmapsView({ projectId, addToast }: RoadmapsViewProps) {
|
||||
acceptMilestoneSuggestion,
|
||||
acceptAllMilestoneSuggestions,
|
||||
clearMilestoneSuggestions,
|
||||
featureSuggestionsByMilestoneId,
|
||||
isGeneratingFeatureSuggestions,
|
||||
generateFeatureSuggestions,
|
||||
acceptFeatureSuggestion,
|
||||
acceptAllFeatureSuggestions,
|
||||
clearFeatureSuggestions,
|
||||
} = useRoadmaps({ projectId });
|
||||
|
||||
// Goal prompt state for milestone suggestion generation
|
||||
@@ -1290,6 +1378,56 @@ export function RoadmapsView({ projectId, addToast }: RoadmapsViewProps) {
|
||||
setGoalPrompt("");
|
||||
}, [clearMilestoneSuggestions]);
|
||||
|
||||
// Feature suggestion handlers
|
||||
const handleGenerateFeatureSuggestions = useCallback(
|
||||
async (milestoneId: string) => {
|
||||
try {
|
||||
await generateFeatureSuggestions(milestoneId, { count: 5 }, {
|
||||
onError: (err) => addToast(err.message, "error"),
|
||||
});
|
||||
} catch {
|
||||
// Error handled in callback
|
||||
}
|
||||
},
|
||||
[generateFeatureSuggestions, addToast]
|
||||
);
|
||||
|
||||
const handleAcceptFeatureSuggestion = useCallback(
|
||||
async (milestoneId: string, index: number) => {
|
||||
try {
|
||||
await acceptFeatureSuggestion(milestoneId, index, {
|
||||
onError: (err) => addToast(err.message, "error"),
|
||||
});
|
||||
addToast("Feature added", "success");
|
||||
} catch {
|
||||
// Error handled in callback
|
||||
}
|
||||
},
|
||||
[acceptFeatureSuggestion, addToast]
|
||||
);
|
||||
|
||||
const handleAcceptAllFeatureSuggestions = useCallback(
|
||||
async (milestoneId: string) => {
|
||||
const suggestions = featureSuggestionsByMilestoneId[milestoneId] || [];
|
||||
try {
|
||||
await acceptAllFeatureSuggestions(milestoneId, {
|
||||
onError: (err) => addToast(err.message, "error"),
|
||||
});
|
||||
addToast(`${suggestions.length} features added`, "success");
|
||||
} catch {
|
||||
// Error handled in callback
|
||||
}
|
||||
},
|
||||
[acceptAllFeatureSuggestions, featureSuggestionsByMilestoneId, addToast]
|
||||
);
|
||||
|
||||
const handleClearFeatureSuggestions = useCallback(
|
||||
(milestoneId: string) => {
|
||||
clearFeatureSuggestions(milestoneId);
|
||||
},
|
||||
[clearFeatureSuggestions]
|
||||
);
|
||||
|
||||
const handleCreateFeature = useCallback(
|
||||
async (milestoneId: string, input: RoadmapFeatureCreateInput) => {
|
||||
try {
|
||||
@@ -1606,6 +1744,13 @@ export function RoadmapsView({ projectId, addToast }: RoadmapsViewProps) {
|
||||
onFeatureDrop={handleFeatureDrop}
|
||||
onFeatureDragLeave={handleFeatureDragLeave}
|
||||
onFeatureDropOnMilestone={handleFeatureDropOnMilestone}
|
||||
// Feature suggestion props
|
||||
featureSuggestions={featureSuggestionsByMilestoneId[milestone.id]}
|
||||
isGeneratingFeatureSuggestions={isGeneratingFeatureSuggestions(milestone.id)}
|
||||
onGenerateFeatureSuggestions={() => handleGenerateFeatureSuggestions(milestone.id)}
|
||||
onAcceptFeatureSuggestion={(index) => handleAcceptFeatureSuggestion(milestone.id, index)}
|
||||
onAcceptAllFeatureSuggestions={() => handleAcceptAllFeatureSuggestions(milestone.id)}
|
||||
onClearFeatureSuggestions={() => handleClearFeatureSuggestions(milestone.id)}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
|
||||
@@ -26,6 +26,7 @@ vi.mock("../../api", () => ({
|
||||
reorderRoadmapMilestones: vi.fn(),
|
||||
reorderRoadmapFeatures: vi.fn(),
|
||||
moveRoadmapFeature: vi.fn(),
|
||||
generateFeatureSuggestions: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock lucide-react icons
|
||||
@@ -38,6 +39,7 @@ vi.mock("lucide-react", () => ({
|
||||
Check: (props: unknown) => <span data-testid="check-icon" {...props}>Check</span>,
|
||||
X: (props: unknown) => <span data-testid="x-icon" {...props}>X</span>,
|
||||
GripVertical: (props: unknown) => <span data-testid="grip-icon" {...props}>Grip</span>,
|
||||
Sparkles: (props: unknown) => <span data-testid="sparkles-icon" {...props}>Sparkles</span>,
|
||||
}));
|
||||
|
||||
const mockRoadmaps: Roadmap[] = [
|
||||
@@ -506,4 +508,24 @@ describe("RoadmapsView", () => {
|
||||
|
||||
expect(screen.getByText("Select a roadmap from the sidebar to view its milestones.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe("Feature suggestions", () => {
|
||||
it("shows AI Suggestions button when roadmap is selected", async () => {
|
||||
render(<RoadmapsView addToast={mockAddToast} />);
|
||||
|
||||
// Wait for roadmap to load
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Q2 Roadmap")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Select roadmap
|
||||
const roadmapItem = screen.getByTestId("roadmap-item-RM-001");
|
||||
fireEvent.click(roadmapItem);
|
||||
|
||||
// Wait for milestone to load and button to appear
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("generate-features-RMS-001")).toBeInTheDocument();
|
||||
}, { timeout: 3000 });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,6 +19,7 @@ vi.mock("../../api", () => ({
|
||||
reorderRoadmapMilestones: vi.fn(),
|
||||
reorderRoadmapFeatures: vi.fn(),
|
||||
moveRoadmapFeature: vi.fn(),
|
||||
generateFeatureSuggestions: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockRoadmaps = [
|
||||
@@ -680,4 +681,313 @@ describe("useRoadmaps", () => {
|
||||
).rejects.toThrow("Feature not found");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Feature suggestions", () => {
|
||||
it("generates feature suggestions for a milestone", async () => {
|
||||
const mockSuggestions = [
|
||||
{ title: "Feature 1", description: "Description 1" },
|
||||
{ title: "Feature 2", description: "Description 2" },
|
||||
];
|
||||
|
||||
(api.generateFeatureSuggestions as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
suggestions: mockSuggestions,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useRoadmaps());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
result.current.selectRoadmap("RM-001");
|
||||
await waitFor(() => {
|
||||
expect(result.current.selectedRoadmapId).toBe("RM-001");
|
||||
});
|
||||
|
||||
await result.current.generateFeatureSuggestions("RMS-001", { count: 5 });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.featureSuggestionsByMilestoneId["RMS-001"]).toEqual(mockSuggestions);
|
||||
});
|
||||
|
||||
expect(api.generateFeatureSuggestions).toHaveBeenCalledWith(
|
||||
"RMS-001",
|
||||
{ count: 5 },
|
||||
undefined
|
||||
);
|
||||
});
|
||||
|
||||
it("generates feature suggestions with prompt", async () => {
|
||||
(api.generateFeatureSuggestions as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
suggestions: [{ title: "Auth Feature" }],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useRoadmaps());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
await result.current.generateFeatureSuggestions("RMS-001", { prompt: "Focus on auth", count: 3 });
|
||||
|
||||
expect(api.generateFeatureSuggestions).toHaveBeenCalledWith(
|
||||
"RMS-001",
|
||||
{ prompt: "Focus on auth", count: 3 },
|
||||
undefined
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts a feature suggestion", async () => {
|
||||
const mockFeature = {
|
||||
id: "RF-NEW",
|
||||
milestoneId: "RMS-001",
|
||||
title: "New Feature",
|
||||
description: "New description",
|
||||
orderIndex: 0,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
};
|
||||
|
||||
(api.createRoadmapFeature as ReturnType<typeof vi.fn>).mockResolvedValue(mockFeature);
|
||||
|
||||
const { result } = renderHook(() => useRoadmaps());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
result.current.selectRoadmap("RM-001");
|
||||
await waitFor(() => {
|
||||
expect(result.current.selectedRoadmapId).toBe("RM-001");
|
||||
});
|
||||
|
||||
// Manually set suggestions (simulating what generateFeatureSuggestions would do)
|
||||
(api.generateFeatureSuggestions as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
suggestions: [{ title: "New Feature", description: "New description" }],
|
||||
});
|
||||
|
||||
await result.current.generateFeatureSuggestions("RMS-001");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.featureSuggestionsByMilestoneId["RMS-001"]).toHaveLength(1);
|
||||
});
|
||||
|
||||
// Accept the suggestion
|
||||
await result.current.acceptFeatureSuggestion("RMS-001", 0);
|
||||
|
||||
// API should be called with the correct arguments
|
||||
expect(api.createRoadmapFeature).toHaveBeenCalledWith(
|
||||
"RMS-001",
|
||||
{ title: "New Feature", description: "New description" },
|
||||
undefined
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts all feature suggestions sequentially", async () => {
|
||||
const mockFeatures = [
|
||||
{
|
||||
id: "RF-NEW-1",
|
||||
milestoneId: "RMS-001",
|
||||
title: "Feature 1",
|
||||
orderIndex: 0,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
},
|
||||
{
|
||||
id: "RF-NEW-2",
|
||||
milestoneId: "RMS-001",
|
||||
title: "Feature 2",
|
||||
orderIndex: 1,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
},
|
||||
];
|
||||
|
||||
(api.createRoadmapFeature as ReturnType<typeof vi.fn>)
|
||||
.mockResolvedValueOnce(mockFeatures[0])
|
||||
.mockResolvedValueOnce(mockFeatures[1]);
|
||||
|
||||
const { result } = renderHook(() => useRoadmaps());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
result.current.selectRoadmap("RM-001");
|
||||
await waitFor(() => {
|
||||
expect(result.current.selectedRoadmapId).toBe("RM-001");
|
||||
});
|
||||
|
||||
// Set suggestions
|
||||
(api.generateFeatureSuggestions as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
suggestions: [
|
||||
{ title: "Feature 1" },
|
||||
{ title: "Feature 2" },
|
||||
],
|
||||
});
|
||||
|
||||
await result.current.generateFeatureSuggestions("RMS-001");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.featureSuggestionsByMilestoneId["RMS-001"]).toHaveLength(2);
|
||||
});
|
||||
|
||||
// Capture suggestions before accepting
|
||||
const suggestionsToAccept = [...(result.current.featureSuggestionsByMilestoneId["RMS-001"] || [])];
|
||||
expect(suggestionsToAccept).toHaveLength(2);
|
||||
|
||||
// Accept all
|
||||
await result.current.acceptAllFeatureSuggestions("RMS-001");
|
||||
|
||||
// Verify sequential calls (not parallel)
|
||||
expect(api.createRoadmapFeature).toHaveBeenCalledTimes(2);
|
||||
expect(api.createRoadmapFeature).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"RMS-001",
|
||||
{ title: "Feature 1", description: undefined },
|
||||
undefined
|
||||
);
|
||||
expect(api.createRoadmapFeature).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"RMS-001",
|
||||
{ title: "Feature 2", description: undefined },
|
||||
undefined
|
||||
);
|
||||
});
|
||||
|
||||
it("clears feature suggestions for a milestone", async () => {
|
||||
const { result } = renderHook(() => useRoadmaps());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
result.current.selectRoadmap("RM-001");
|
||||
await waitFor(() => {
|
||||
expect(result.current.selectedRoadmapId).toBe("RM-001");
|
||||
});
|
||||
|
||||
// Set suggestions
|
||||
(api.generateFeatureSuggestions as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
suggestions: [{ title: "Feature 1" }, { title: "Feature 2" }],
|
||||
});
|
||||
|
||||
await result.current.generateFeatureSuggestions("RMS-001");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.featureSuggestionsByMilestoneId["RMS-001"]).toHaveLength(2);
|
||||
});
|
||||
|
||||
// Clear suggestions
|
||||
result.current.clearFeatureSuggestions("RMS-001");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.featureSuggestionsByMilestoneId["RMS-001"]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("is isolated per milestone", async () => {
|
||||
const { result } = renderHook(() => useRoadmaps());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
result.current.selectRoadmap("RM-001");
|
||||
await waitFor(() => {
|
||||
expect(result.current.selectedRoadmapId).toBe("RM-001");
|
||||
});
|
||||
|
||||
// Set suggestions for milestone 1
|
||||
(api.generateFeatureSuggestions as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
suggestions: [{ title: "MS1 Feature" }],
|
||||
});
|
||||
|
||||
await result.current.generateFeatureSuggestions("RMS-001");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.featureSuggestionsByMilestoneId["RMS-001"]).toHaveLength(1);
|
||||
});
|
||||
|
||||
// Set suggestions for milestone 2
|
||||
(api.generateFeatureSuggestions as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
suggestions: [{ title: "MS2 Feature" }],
|
||||
});
|
||||
|
||||
await result.current.generateFeatureSuggestions("RMS-002");
|
||||
|
||||
await waitFor(() => {
|
||||
// Verify suggestions are isolated
|
||||
expect(result.current.featureSuggestionsByMilestoneId["RMS-001"]).toHaveLength(1);
|
||||
expect(result.current.featureSuggestionsByMilestoneId["RMS-001"][0].title).toBe("MS1 Feature");
|
||||
expect(result.current.featureSuggestionsByMilestoneId["RMS-002"]).toHaveLength(1);
|
||||
expect(result.current.featureSuggestionsByMilestoneId["RMS-002"][0].title).toBe("MS2 Feature");
|
||||
});
|
||||
});
|
||||
|
||||
it("returns correct loading state for feature suggestions", async () => {
|
||||
let resolveGenerate: (value: { suggestions: Array<{ title: string }> }) => void;
|
||||
(api.generateFeatureSuggestions as ReturnType<typeof vi.fn>).mockImplementation(() => {
|
||||
return new Promise((resolve) => {
|
||||
resolveGenerate = resolve;
|
||||
});
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useRoadmaps());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
// Start generating
|
||||
const generatePromise = result.current.generateFeatureSuggestions("RMS-001");
|
||||
|
||||
// Wait for loading state to update
|
||||
await waitFor(() => {
|
||||
expect(result.current.isGeneratingFeatureSuggestions("RMS-001")).toBe(true);
|
||||
});
|
||||
|
||||
// Complete generation
|
||||
resolveGenerate!({ suggestions: [{ title: "Feature" }] });
|
||||
await generatePromise;
|
||||
|
||||
await waitFor(() => {
|
||||
// Check loading state is false
|
||||
expect(result.current.isGeneratingFeatureSuggestions("RMS-001")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it("clears feature suggestions when project changes", async () => {
|
||||
const { result, rerender } = renderHook(
|
||||
({ projectId }: { projectId?: string }) => useRoadmaps({ projectId }),
|
||||
{ initialProps: { projectId: "proj-1" } }
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
result.current.selectRoadmap("RM-001");
|
||||
await waitFor(() => {
|
||||
expect(result.current.selectedRoadmapId).toBe("RM-001");
|
||||
});
|
||||
|
||||
// Set suggestions
|
||||
(api.generateFeatureSuggestions as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
suggestions: [{ title: "Feature" }],
|
||||
});
|
||||
|
||||
await result.current.generateFeatureSuggestions("RMS-001");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.featureSuggestionsByMilestoneId["RMS-001"]).toHaveLength(1);
|
||||
});
|
||||
|
||||
// Change project
|
||||
rerender({ projectId: "proj-2" });
|
||||
|
||||
// Suggestions should be cleared
|
||||
expect(result.current.featureSuggestionsByMilestoneId["RMS-001"]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,6 +19,12 @@ export interface MilestoneSuggestion {
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/** A suggested feature from AI generation */
|
||||
export interface FeatureSuggestion {
|
||||
title: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface UseRoadmapsOptions {
|
||||
/** When provided, fetches roadmaps for this project */
|
||||
projectId?: string;
|
||||
@@ -90,6 +96,20 @@ export interface UseRoadmapsResult {
|
||||
/** Clear all pending milestone suggestions */
|
||||
clearMilestoneSuggestions: () => void;
|
||||
|
||||
// Feature suggestion callbacks (ephemeral, scoped by milestone)
|
||||
/** Pending feature suggestions by milestone ID (ephemeral, in-memory only) */
|
||||
featureSuggestionsByMilestoneId: Record<string, FeatureSuggestion[]>;
|
||||
/** Whether feature suggestions are being generated for a specific milestone */
|
||||
isGeneratingFeatureSuggestions: (milestoneId: string) => boolean;
|
||||
/** Generate feature suggestions for a specific milestone */
|
||||
generateFeatureSuggestions: (milestoneId: string, input?: { prompt?: string; count?: number }, opts?: { onSuccess?: () => void; onError?: (err: Error) => void }) => Promise<void>;
|
||||
/** Accept a single feature suggestion and create it as a feature */
|
||||
acceptFeatureSuggestion: (milestoneId: string, index: number, opts?: { onSuccess?: () => void; onError?: (err: Error) => void }) => Promise<void>;
|
||||
/** Accept all feature suggestions for a milestone (sequentially) */
|
||||
acceptAllFeatureSuggestions: (milestoneId: string, opts?: { onSuccess?: () => void; onError?: (err: Error) => void }) => Promise<void>;
|
||||
/** Clear pending feature suggestions for a specific milestone */
|
||||
clearFeatureSuggestions: (milestoneId: string) => void;
|
||||
|
||||
/** Refresh all roadmaps */
|
||||
refresh: () => Promise<void>;
|
||||
}
|
||||
@@ -108,6 +128,17 @@ export function useRoadmaps(options?: UseRoadmapsOptions): UseRoadmapsResult {
|
||||
const [milestoneSuggestions, setMilestoneSuggestions] = useState<MilestoneSuggestion[]>([]);
|
||||
const [isGeneratingSuggestions, setIsGeneratingSuggestions] = useState(false);
|
||||
|
||||
// Ephemeral feature suggestion state keyed by milestone ID (in-memory only, not persisted)
|
||||
const [featureSuggestionsByMilestoneId, setFeatureSuggestionsByMilestoneId] = useState<Record<string, FeatureSuggestion[]>>({});
|
||||
const [generatingFeatureSuggestions, setGeneratingFeatureSuggestions] = useState<Record<string, boolean>>({});
|
||||
|
||||
// Refs for feature suggestion state
|
||||
const featureSuggestionsByMilestoneIdRef = useRef(featureSuggestionsByMilestoneId);
|
||||
const generatingFeatureSuggestionsRef = useRef(generatingFeatureSuggestions);
|
||||
|
||||
featureSuggestionsByMilestoneIdRef.current = featureSuggestionsByMilestoneId;
|
||||
generatingFeatureSuggestionsRef.current = generatingFeatureSuggestions;
|
||||
|
||||
// Track previous projectId to detect changes
|
||||
const previousProjectIdRef = useRef<string | undefined>(projectId);
|
||||
// Project context version for stale-response protection
|
||||
@@ -137,6 +168,8 @@ export function useRoadmaps(options?: UseRoadmapsOptions): UseRoadmapsResult {
|
||||
// Clear ephemeral suggestion state
|
||||
setMilestoneSuggestions([]);
|
||||
setIsGeneratingSuggestions(false);
|
||||
setFeatureSuggestionsByMilestoneId({});
|
||||
setGeneratingFeatureSuggestions({});
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
@@ -719,6 +752,195 @@ export function useRoadmaps(options?: UseRoadmapsOptions): UseRoadmapsResult {
|
||||
setIsGeneratingSuggestions(false);
|
||||
}, []);
|
||||
|
||||
// ── Feature Suggestion Actions (Ephemeral, Milestone-Scoped) ───────────────────────────────────
|
||||
|
||||
const isGeneratingFeatureSuggestions = useCallback((milestoneId: string): boolean => {
|
||||
return generatingFeatureSuggestionsRef.current[milestoneId] ?? false;
|
||||
}, []);
|
||||
|
||||
const generateFeatureSuggestions = useCallback(async (
|
||||
milestoneId: string,
|
||||
input?: { prompt?: string; count?: number },
|
||||
opts?: { onSuccess?: () => void; onError?: (err: Error) => void }
|
||||
) => {
|
||||
// Capture project context version for stale-response protection
|
||||
const contextVersionAtStart = projectContextVersionRef.current;
|
||||
const requestProjectId = projectIdRef.current;
|
||||
|
||||
// Set loading state for this milestone
|
||||
setGeneratingFeatureSuggestions((prev) => ({ ...prev, [milestoneId]: true }));
|
||||
|
||||
try {
|
||||
const response = await api.generateFeatureSuggestions(
|
||||
milestoneId,
|
||||
input,
|
||||
requestProjectId
|
||||
);
|
||||
|
||||
// Check for stale response
|
||||
if (projectContextVersionRef.current !== contextVersionAtStart) {
|
||||
// Project context changed during fetch - discard response
|
||||
return;
|
||||
}
|
||||
|
||||
setFeatureSuggestionsByMilestoneId((prev) => ({
|
||||
...prev,
|
||||
[milestoneId]: response.suggestions,
|
||||
}));
|
||||
opts?.onSuccess?.();
|
||||
} catch (err) {
|
||||
// Check for stale response
|
||||
if (projectContextVersionRef.current !== contextVersionAtStart) {
|
||||
// Project context changed during fetch - discard error
|
||||
return;
|
||||
}
|
||||
|
||||
const error = err instanceof Error ? err : new Error("Failed to generate feature suggestions");
|
||||
opts?.onError?.(error);
|
||||
throw error;
|
||||
} finally {
|
||||
// Only clear loading state if context hasn't changed
|
||||
if (projectContextVersionRef.current === contextVersionAtStart) {
|
||||
setGeneratingFeatureSuggestions((prev) => ({ ...prev, [milestoneId]: false }));
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const acceptFeatureSuggestion = useCallback(async (
|
||||
milestoneId: string,
|
||||
index: number,
|
||||
opts?: { onSuccess?: () => void; onError?: (err: Error) => void }
|
||||
) => {
|
||||
// Capture state for stale-response protection
|
||||
const contextVersionAtStart = projectContextVersionRef.current;
|
||||
const currentSuggestions = featureSuggestionsByMilestoneIdRef.current[milestoneId] || [];
|
||||
|
||||
if (index < 0 || index >= currentSuggestions.length) {
|
||||
const error = new Error("Invalid suggestion index");
|
||||
opts?.onError?.(error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
const suggestion = currentSuggestions[index];
|
||||
|
||||
// Optimistic update: remove from suggestions immediately
|
||||
setFeatureSuggestionsByMilestoneId((prev) => {
|
||||
const milestoneSuggestions = prev[milestoneId] || [];
|
||||
return {
|
||||
...prev,
|
||||
[milestoneId]: milestoneSuggestions.filter((_, i) => i !== index),
|
||||
};
|
||||
});
|
||||
|
||||
try {
|
||||
await api.createRoadmapFeature(
|
||||
milestoneId,
|
||||
{ title: suggestion.title, description: suggestion.description },
|
||||
projectIdRef.current
|
||||
);
|
||||
|
||||
// Check for stale response
|
||||
if (projectContextVersionRef.current !== contextVersionAtStart) {
|
||||
// Project context changed - re-add to suggestions (optimistic rollback)
|
||||
setFeatureSuggestionsByMilestoneId((prev) => {
|
||||
const milestoneSuggestions = prev[milestoneId] || [];
|
||||
const updated = [...milestoneSuggestions];
|
||||
updated.splice(index, 0, suggestion);
|
||||
return { ...prev, [milestoneId]: updated };
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Refresh the roadmap to get the new feature
|
||||
if (selectedRoadmapIdRef.current) {
|
||||
void fetchSelectedRoadmap(selectedRoadmapIdRef.current);
|
||||
}
|
||||
|
||||
opts?.onSuccess?.();
|
||||
} catch (err) {
|
||||
// Rollback: re-add to suggestions
|
||||
setFeatureSuggestionsByMilestoneId((prev) => {
|
||||
const milestoneSuggestions = prev[milestoneId] || [];
|
||||
const updated = [...milestoneSuggestions];
|
||||
updated.splice(index, 0, suggestion);
|
||||
return { ...prev, [milestoneId]: updated };
|
||||
});
|
||||
|
||||
const error = err instanceof Error ? err : new Error("Failed to accept suggestion");
|
||||
opts?.onError?.(error);
|
||||
throw error;
|
||||
}
|
||||
}, [fetchSelectedRoadmap]);
|
||||
|
||||
const acceptAllFeatureSuggestions = useCallback(async (
|
||||
milestoneId: string,
|
||||
opts?: { onSuccess?: () => void; onError?: (err: Error) => void }
|
||||
) => {
|
||||
// Capture current suggestions (they will be cleared sequentially)
|
||||
const suggestionsToAccept = [...(featureSuggestionsByMilestoneIdRef.current[milestoneId] || [])];
|
||||
if (suggestionsToAccept.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Capture state for stale-response protection
|
||||
const contextVersionAtStart = projectContextVersionRef.current;
|
||||
|
||||
// Clear suggestions for this milestone immediately (optimistic)
|
||||
setFeatureSuggestionsByMilestoneId((prev) => ({
|
||||
...prev,
|
||||
[milestoneId]: [],
|
||||
}));
|
||||
|
||||
// Accept sequentially to preserve order
|
||||
for (let i = 0; i < suggestionsToAccept.length; i++) {
|
||||
// Check for stale response
|
||||
if (projectContextVersionRef.current !== contextVersionAtStart) {
|
||||
// Project context changed - stop accepting
|
||||
break;
|
||||
}
|
||||
|
||||
const suggestion = suggestionsToAccept[i];
|
||||
|
||||
try {
|
||||
await api.createRoadmapFeature(
|
||||
milestoneId,
|
||||
{ title: suggestion.title, description: suggestion.description },
|
||||
projectIdRef.current
|
||||
);
|
||||
} catch (err) {
|
||||
// On error, stop accepting and report
|
||||
const error = err instanceof Error ? err : new Error("Failed to accept all suggestions");
|
||||
opts?.onError?.(error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for stale response
|
||||
if (projectContextVersionRef.current !== contextVersionAtStart) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Refresh the roadmap to get all new features
|
||||
if (selectedRoadmapIdRef.current) {
|
||||
void fetchSelectedRoadmap(selectedRoadmapIdRef.current);
|
||||
}
|
||||
|
||||
opts?.onSuccess?.();
|
||||
}, [fetchSelectedRoadmap]);
|
||||
|
||||
const clearFeatureSuggestions = useCallback((milestoneId: string) => {
|
||||
setFeatureSuggestionsByMilestoneId((prev) => {
|
||||
const updated = { ...prev };
|
||||
delete updated[milestoneId];
|
||||
return updated;
|
||||
});
|
||||
setGeneratingFeatureSuggestions((prev) => {
|
||||
const updated = { ...prev };
|
||||
delete updated[milestoneId];
|
||||
return updated;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
await fetchRoadmaps();
|
||||
if (selectedRoadmapIdRef.current) {
|
||||
@@ -753,6 +975,12 @@ export function useRoadmaps(options?: UseRoadmapsOptions): UseRoadmapsResult {
|
||||
acceptMilestoneSuggestion,
|
||||
acceptAllMilestoneSuggestions,
|
||||
clearMilestoneSuggestions,
|
||||
featureSuggestionsByMilestoneId,
|
||||
isGeneratingFeatureSuggestions,
|
||||
generateFeatureSuggestions,
|
||||
acceptFeatureSuggestion,
|
||||
acceptAllFeatureSuggestions,
|
||||
clearFeatureSuggestions,
|
||||
refresh,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -30720,6 +30720,35 @@ html .column.drag-over * {
|
||||
border-color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* AI Suggestion button */
|
||||
.roadmaps-view__suggest-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
background: var(--accent, #58a6ff);
|
||||
color: #fff;
|
||||
border: 1px solid var(--accent, #58a6ff);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-fast), transform var(--transition-fast);
|
||||
}
|
||||
|
||||
.roadmaps-view__suggest-btn:hover {
|
||||
background: var(--accent-hover, #4c94e6);
|
||||
border-color: var(--accent-hover, #4c94e6);
|
||||
}
|
||||
|
||||
.roadmaps-view__suggest-btn:active {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
.roadmaps-view__suggest-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Feature list */
|
||||
.roadmaps-view__feature-list {
|
||||
flex: 1;
|
||||
|
||||
@@ -2,6 +2,8 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import {
|
||||
validateSuggestionInput,
|
||||
generateMilestoneSuggestions,
|
||||
validateFeatureSuggestionInput,
|
||||
generateFeatureSuggestions,
|
||||
ValidationError,
|
||||
ParseError,
|
||||
__resetSuggestionState,
|
||||
@@ -616,4 +618,793 @@ describe("roadmap-suggestions", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateFeatureSuggestionInput", () => {
|
||||
it("accepts valid input with all fields", () => {
|
||||
const input = {
|
||||
prompt: "Focus on user authentication features",
|
||||
count: 5,
|
||||
};
|
||||
|
||||
expect(() => validateFeatureSuggestionInput(input)).not.toThrow();
|
||||
});
|
||||
|
||||
it("accepts valid input without optional fields", () => {
|
||||
const input = {};
|
||||
|
||||
expect(() => validateFeatureSuggestionInput(input)).not.toThrow();
|
||||
});
|
||||
|
||||
it("accepts empty object", () => {
|
||||
expect(() => validateFeatureSuggestionInput({})).not.toThrow();
|
||||
});
|
||||
|
||||
it("accepts input with only count", () => {
|
||||
const input = {
|
||||
count: 3,
|
||||
};
|
||||
|
||||
expect(() => validateFeatureSuggestionInput(input)).not.toThrow();
|
||||
});
|
||||
|
||||
it("accepts count at minimum boundary (1)", () => {
|
||||
const input = {
|
||||
count: 1,
|
||||
};
|
||||
|
||||
expect(() => validateFeatureSuggestionInput(input)).not.toThrow();
|
||||
});
|
||||
|
||||
it("accepts count at maximum boundary (10)", () => {
|
||||
const input = {
|
||||
count: 10,
|
||||
};
|
||||
|
||||
expect(() => validateFeatureSuggestionInput(input)).not.toThrow();
|
||||
});
|
||||
|
||||
it("rejects null input", () => {
|
||||
expect(() => validateFeatureSuggestionInput(null)).toThrow(ValidationError);
|
||||
});
|
||||
|
||||
it("rejects non-object input", () => {
|
||||
expect(() => validateFeatureSuggestionInput("string")).toThrow(ValidationError);
|
||||
expect(() => validateFeatureSuggestionInput(123)).toThrow(ValidationError);
|
||||
});
|
||||
|
||||
it("rejects array input", () => {
|
||||
expect(() => validateFeatureSuggestionInput([])).toThrow(ValidationError);
|
||||
expect(() => validateFeatureSuggestionInput([{ prompt: "test" }])).toThrow(ValidationError);
|
||||
});
|
||||
|
||||
it("rejects non-string prompt", () => {
|
||||
expect(() =>
|
||||
validateFeatureSuggestionInput({ prompt: 123 })
|
||||
).toThrow(ValidationError);
|
||||
expect(() =>
|
||||
validateFeatureSuggestionInput({ prompt: null })
|
||||
).toThrow(ValidationError);
|
||||
expect(() =>
|
||||
validateFeatureSuggestionInput({ prompt: [] })
|
||||
).toThrow(ValidationError);
|
||||
});
|
||||
|
||||
it("rejects prompt exceeding max length", () => {
|
||||
const longPrompt = "a".repeat(2001);
|
||||
expect(() =>
|
||||
validateFeatureSuggestionInput({ prompt: longPrompt })
|
||||
).toThrow(ValidationError);
|
||||
});
|
||||
|
||||
it("accepts prompt at exactly max length", () => {
|
||||
const maxPrompt = "a".repeat(2000);
|
||||
expect(() =>
|
||||
validateFeatureSuggestionInput({ prompt: maxPrompt })
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("rejects non-integer count", () => {
|
||||
expect(() =>
|
||||
validateFeatureSuggestionInput({ count: 3.5 })
|
||||
).toThrow(ValidationError);
|
||||
});
|
||||
|
||||
it("rejects count below minimum", () => {
|
||||
expect(() =>
|
||||
validateFeatureSuggestionInput({ count: 0 })
|
||||
).toThrow(ValidationError);
|
||||
expect(() =>
|
||||
validateFeatureSuggestionInput({ count: -1 })
|
||||
).toThrow(ValidationError);
|
||||
});
|
||||
|
||||
it("rejects count above maximum", () => {
|
||||
expect(() =>
|
||||
validateFeatureSuggestionInput({ count: 11 })
|
||||
).toThrow(ValidationError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("generateFeatureSuggestions", () => {
|
||||
const rootDir = "/test/project";
|
||||
|
||||
const baseContext = {
|
||||
roadmapTitle: "E-Commerce Platform",
|
||||
roadmapDescription: "Build a modern e-commerce platform",
|
||||
milestoneTitle: "User Authentication",
|
||||
milestoneDescription: "Implement user login and management",
|
||||
existingFeatureTitles: [],
|
||||
};
|
||||
|
||||
it("generates feature suggestions successfully", async () => {
|
||||
const mockSession = {
|
||||
prompt: vi.fn().mockResolvedValue(undefined),
|
||||
dispose: vi.fn(),
|
||||
state: {
|
||||
messages: [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: '[\n {"title": "Login Form", "description": "Basic login form UI"}, {"title": "OAuth Integration", "description": "Support social login"}\n]',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const mockCreateKbAgent = vi.fn().mockResolvedValue({
|
||||
session: mockSession,
|
||||
});
|
||||
|
||||
__setCreateKbAgent(mockCreateKbAgent);
|
||||
|
||||
const suggestions = await generateFeatureSuggestions(
|
||||
baseContext,
|
||||
5,
|
||||
undefined,
|
||||
rootDir
|
||||
);
|
||||
|
||||
expect(suggestions).toHaveLength(2);
|
||||
expect(suggestions[0]).toEqual({
|
||||
title: "Login Form",
|
||||
description: "Basic login form UI",
|
||||
});
|
||||
expect(suggestions[1]).toEqual({
|
||||
title: "OAuth Integration",
|
||||
description: "Support social login",
|
||||
});
|
||||
});
|
||||
|
||||
it("includes milestone context in system prompt", async () => {
|
||||
const mockSession = {
|
||||
prompt: vi.fn().mockResolvedValue(undefined),
|
||||
dispose: vi.fn(),
|
||||
state: {
|
||||
messages: [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: '[\n {"title": "Feature", "description": "A feature"}\n]',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const mockCreateKbAgent = vi.fn().mockResolvedValue({
|
||||
session: mockSession,
|
||||
});
|
||||
|
||||
__setCreateKbAgent(mockCreateKbAgent);
|
||||
|
||||
await generateFeatureSuggestions(baseContext, 5, undefined, rootDir);
|
||||
|
||||
expect(mockCreateKbAgent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
cwd: rootDir,
|
||||
systemPrompt: expect.stringContaining("User Authentication"),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("includes existing features in context when present", async () => {
|
||||
const contextWithExistingFeatures = {
|
||||
...baseContext,
|
||||
existingFeatureTitles: ["Login Form", "Password Reset"],
|
||||
};
|
||||
|
||||
const mockSession = {
|
||||
prompt: vi.fn().mockResolvedValue(undefined),
|
||||
dispose: vi.fn(),
|
||||
state: {
|
||||
messages: [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: '[\n {"title": "New Feature", "description": "A new feature"}\n]',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const mockCreateKbAgent = vi.fn().mockResolvedValue({
|
||||
session: mockSession,
|
||||
});
|
||||
|
||||
__setCreateKbAgent(mockCreateKbAgent);
|
||||
|
||||
await generateFeatureSuggestions(
|
||||
contextWithExistingFeatures,
|
||||
5,
|
||||
undefined,
|
||||
rootDir
|
||||
);
|
||||
|
||||
expect(mockCreateKbAgent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
systemPrompt: expect.stringContaining("Login Form"),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("includes optional prompt in user message", async () => {
|
||||
const mockSession = {
|
||||
prompt: vi.fn().mockResolvedValue(undefined),
|
||||
dispose: vi.fn(),
|
||||
state: {
|
||||
messages: [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: '[\n {"title": "Feature", "description": "A feature"}\n]',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const mockCreateKbAgent = vi.fn().mockResolvedValue({
|
||||
session: mockSession,
|
||||
});
|
||||
|
||||
__setCreateKbAgent(mockCreateKbAgent);
|
||||
|
||||
await generateFeatureSuggestions(
|
||||
baseContext,
|
||||
5,
|
||||
"Focus on security features",
|
||||
rootDir
|
||||
);
|
||||
|
||||
expect(mockSession.prompt).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Focus on security features")
|
||||
);
|
||||
});
|
||||
|
||||
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": "Feature"}\n]',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const mockCreateKbAgent = vi.fn().mockResolvedValue({
|
||||
session: mockSession,
|
||||
});
|
||||
|
||||
__setCreateKbAgent(mockCreateKbAgent);
|
||||
|
||||
await generateFeatureSuggestions(baseContext, 3, undefined, rootDir);
|
||||
|
||||
expect(mockSession.prompt).toHaveBeenCalledWith(
|
||||
expect.stringContaining("3 features")
|
||||
);
|
||||
});
|
||||
|
||||
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": "Feature"}\n]',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const mockCreateKbAgent = vi.fn().mockResolvedValue({
|
||||
session: mockSession,
|
||||
});
|
||||
|
||||
__setCreateKbAgent(mockCreateKbAgent);
|
||||
|
||||
await generateFeatureSuggestions(baseContext, 5, undefined, rootDir);
|
||||
|
||||
expect(mockSession.dispose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("throws when AI service is unavailable", async () => {
|
||||
__setCreateKbAgent(undefined);
|
||||
|
||||
await expect(
|
||||
generateFeatureSuggestions(baseContext, 5, undefined, 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(
|
||||
generateFeatureSuggestions(baseContext, 5, undefined)
|
||||
).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": "Feature", "description": "A feature"}\n]\n```',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const mockCreateKbAgent = vi.fn().mockResolvedValue({
|
||||
session: mockSession,
|
||||
});
|
||||
|
||||
__setCreateKbAgent(mockCreateKbAgent);
|
||||
|
||||
const suggestions = await generateFeatureSuggestions(
|
||||
baseContext,
|
||||
5,
|
||||
undefined,
|
||||
rootDir
|
||||
);
|
||||
|
||||
expect(suggestions).toHaveLength(1);
|
||||
expect(suggestions[0].title).toBe("Feature");
|
||||
});
|
||||
|
||||
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": "Feature 1", "description": "First feature"}\n]',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const mockCreateKbAgent = vi.fn().mockResolvedValue({
|
||||
session: mockSession,
|
||||
});
|
||||
|
||||
__setCreateKbAgent(mockCreateKbAgent);
|
||||
|
||||
const suggestions = await generateFeatureSuggestions(
|
||||
baseContext,
|
||||
5,
|
||||
undefined,
|
||||
rootDir
|
||||
);
|
||||
|
||||
expect(suggestions).toHaveLength(1);
|
||||
expect(suggestions[0].title).toBe("Feature 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": "No Description Feature"}, {"title": "With Description", "description": "Has description"}]',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const mockCreateKbAgent = vi.fn().mockResolvedValue({
|
||||
session: mockSession,
|
||||
});
|
||||
|
||||
__setCreateKbAgent(mockCreateKbAgent);
|
||||
|
||||
const suggestions = await generateFeatureSuggestions(
|
||||
baseContext,
|
||||
5,
|
||||
undefined,
|
||||
rootDir
|
||||
);
|
||||
|
||||
expect(suggestions).toHaveLength(2);
|
||||
expect(suggestions[0]).toEqual({ title: "No Description Feature", description: undefined });
|
||||
expect(suggestions[1]).toEqual({ title: "With Description", description: "Has description" });
|
||||
});
|
||||
|
||||
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 generateFeatureSuggestions(
|
||||
baseContext,
|
||||
2,
|
||||
undefined,
|
||||
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 generateFeatureSuggestions(
|
||||
baseContext,
|
||||
5,
|
||||
undefined,
|
||||
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 features without JSON",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const mockCreateKbAgent = vi.fn().mockResolvedValue({
|
||||
session: mockSession,
|
||||
});
|
||||
|
||||
__setCreateKbAgent(mockCreateKbAgent);
|
||||
|
||||
await expect(
|
||||
generateFeatureSuggestions(baseContext, 5, undefined, 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(
|
||||
generateFeatureSuggestions(baseContext, 5, undefined, rootDir)
|
||||
).rejects.toThrow(ParseError);
|
||||
});
|
||||
|
||||
it("throws ParseError when feature 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(
|
||||
generateFeatureSuggestions(baseContext, 5, undefined, 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": "Feature", "description": "A feature"}\n]',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const mockCreateKbAgent = vi.fn().mockResolvedValue({
|
||||
session: mockSession,
|
||||
});
|
||||
|
||||
__setCreateKbAgent(mockCreateKbAgent);
|
||||
|
||||
await generateFeatureSuggestions(
|
||||
baseContext,
|
||||
5,
|
||||
undefined,
|
||||
rootDir,
|
||||
"openai",
|
||||
"gpt-4o"
|
||||
);
|
||||
|
||||
expect(mockCreateKbAgent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
defaultProvider: "openai",
|
||||
defaultModelId: "gpt-4o",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("filters out invalid items and returns valid ones", async () => {
|
||||
const mockSession = {
|
||||
prompt: vi.fn().mockResolvedValue(undefined),
|
||||
dispose: vi.fn(),
|
||||
state: {
|
||||
messages: [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: '[\n {"title": "Valid Feature"}, {"title": ""}, {"title": " "}, {"title": "Also Valid", "description": "Has desc"}, {"title": null}]\n]',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const mockCreateKbAgent = vi.fn().mockResolvedValue({
|
||||
session: mockSession,
|
||||
});
|
||||
|
||||
__setCreateKbAgent(mockCreateKbAgent);
|
||||
|
||||
const suggestions = await generateFeatureSuggestions(
|
||||
baseContext,
|
||||
5,
|
||||
undefined,
|
||||
rootDir
|
||||
);
|
||||
|
||||
// Should filter out empty/whitespace titles and return valid ones
|
||||
expect(suggestions).toHaveLength(2);
|
||||
expect(suggestions[0]).toEqual({ title: "Valid Feature", description: undefined });
|
||||
expect(suggestions[1]).toEqual({ title: "Also Valid", description: "Has desc" });
|
||||
});
|
||||
|
||||
it("throws ParseError when all items are invalid", async () => {
|
||||
const mockSession = {
|
||||
prompt: vi.fn().mockResolvedValue(undefined),
|
||||
dispose: vi.fn(),
|
||||
state: {
|
||||
messages: [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: '[\n {"description": "Only has desc"}, {"title": " "}, {"title": null}]\n]',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const mockCreateKbAgent = vi.fn().mockResolvedValue({
|
||||
session: mockSession,
|
||||
});
|
||||
|
||||
__setCreateKbAgent(mockCreateKbAgent);
|
||||
|
||||
await expect(
|
||||
generateFeatureSuggestions(baseContext, 5, undefined, rootDir)
|
||||
).rejects.toThrow(ParseError);
|
||||
});
|
||||
|
||||
it("filters out non-object items", async () => {
|
||||
const mockSession = {
|
||||
prompt: vi.fn().mockResolvedValue(undefined),
|
||||
dispose: vi.fn(),
|
||||
state: {
|
||||
messages: [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: '[\n {"title": "Valid"}, "string item", null, {"title": "Also Valid"}]\n]',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const mockCreateKbAgent = vi.fn().mockResolvedValue({
|
||||
session: mockSession,
|
||||
});
|
||||
|
||||
__setCreateKbAgent(mockCreateKbAgent);
|
||||
|
||||
const suggestions = await generateFeatureSuggestions(
|
||||
baseContext,
|
||||
5,
|
||||
undefined,
|
||||
rootDir
|
||||
);
|
||||
|
||||
// Should filter out non-object items and return valid ones
|
||||
expect(suggestions).toHaveLength(2);
|
||||
expect(suggestions[0]).toEqual({ title: "Valid", description: undefined });
|
||||
expect(suggestions[1]).toEqual({ title: "Also Valid", description: undefined });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -301,18 +301,21 @@ function parseMilestoneSuggestions(text: string): MilestoneSuggestion[] {
|
||||
throw new ParseError("AI response must be a JSON array of milestone suggestions");
|
||||
}
|
||||
|
||||
// Validate and normalize each item
|
||||
// Validate and normalize each item - filter invalid entries per spec
|
||||
const suggestions: MilestoneSuggestion[] = [];
|
||||
for (let i = 0; i < parsed.length; i++) {
|
||||
const item = parsed[i];
|
||||
|
||||
// Skip items that are not objects
|
||||
if (!item || typeof item !== "object") {
|
||||
throw new ParseError(`Item ${i + 1} in AI response is invalid`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const { title, description } = item as Record<string, unknown>;
|
||||
|
||||
// Skip entries with empty/whitespace-only titles per spec
|
||||
if (typeof title !== "string" || !title.trim()) {
|
||||
throw new ParseError(`Item ${i + 1} in AI response is missing a valid title`);
|
||||
continue;
|
||||
}
|
||||
|
||||
suggestions.push({
|
||||
@@ -323,8 +326,9 @@ function parseMilestoneSuggestions(text: string): MilestoneSuggestion[] {
|
||||
});
|
||||
}
|
||||
|
||||
// If zero valid rows remain after filtering, return 500 error per spec
|
||||
if (suggestions.length === 0) {
|
||||
throw new ParseError("AI returned no milestone suggestions");
|
||||
throw new ParseError("AI returned no valid milestone suggestions");
|
||||
}
|
||||
|
||||
return suggestions;
|
||||
@@ -478,6 +482,364 @@ export async function generateMilestoneSuggestions(
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// FEATURE SUGGESTION GENERATION
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/** Input for generating feature suggestions within a milestone */
|
||||
export interface GenerateFeatureSuggestionsInput {
|
||||
/** Optional prompt to guide feature generation */
|
||||
prompt?: string;
|
||||
/** Number of features to generate (default 5, max 10) */
|
||||
count?: number;
|
||||
}
|
||||
|
||||
/** A suggested feature with title and optional description */
|
||||
export interface FeatureSuggestion {
|
||||
title: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/** Context about the milestone for feature generation */
|
||||
export interface FeatureSuggestionContext {
|
||||
/** Roadmap title */
|
||||
roadmapTitle: string;
|
||||
/** Roadmap description (optional) */
|
||||
roadmapDescription?: string;
|
||||
/** Milestone title */
|
||||
milestoneTitle: string;
|
||||
/** Milestone description (optional) */
|
||||
milestoneDescription?: string;
|
||||
/** Existing feature titles in this milestone */
|
||||
existingFeatureTitles: string[];
|
||||
}
|
||||
|
||||
/** System prompt for feature suggestion generation */
|
||||
export const FEATURE_SUGGESTION_SYSTEM_PROMPT = `You are a feature planning assistant for a product roadmap system.
|
||||
|
||||
Your job is to suggest concrete, actionable features that belong within a specific milestone.
|
||||
|
||||
## Guidelines
|
||||
|
||||
1. **Be specific**: Feature titles should clearly describe what will be built (e.g., "User profile avatar upload", "API rate limiting")
|
||||
2. **Actionable scope**: Each feature should be achievable in 1-2 weeks of focused work
|
||||
3. **Add context**: Include a brief description explaining the feature's purpose and key aspects
|
||||
4. **Avoid duplication**: Do NOT suggest features that are similar to existing ones already planned
|
||||
5. **Order matters**: List features in the order they should be implemented within this milestone
|
||||
|
||||
## Context
|
||||
|
||||
The features should fit within the following milestone:
|
||||
{MILESTONE_CONTEXT}
|
||||
|
||||
## Output Format
|
||||
|
||||
Respond with ONLY a valid JSON array of feature suggestions:
|
||||
|
||||
[
|
||||
{
|
||||
"title": "Feature Title",
|
||||
"description": "Brief description of the feature (1-2 sentences)"
|
||||
},
|
||||
...
|
||||
]
|
||||
|
||||
Do NOT include any markdown formatting, code fences, or additional text. Only output the JSON array.`;
|
||||
|
||||
/** Maximum length for feature generation prompt */
|
||||
const MAX_FEATURE_PROMPT_LENGTH = 2000;
|
||||
|
||||
/**
|
||||
* Validate the input for generating feature suggestions.
|
||||
* Throws with a descriptive error message on validation failure.
|
||||
*/
|
||||
export function validateFeatureSuggestionInput(input: unknown): asserts input is GenerateFeatureSuggestionsInput {
|
||||
if (!input || typeof input !== "object") {
|
||||
throw new ValidationError("Request body must be an object");
|
||||
}
|
||||
|
||||
// Arrays are objects in JS, but not valid input
|
||||
if (Array.isArray(input)) {
|
||||
throw new ValidationError("Request body must be an object, not an array");
|
||||
}
|
||||
|
||||
const { prompt, count } = input as Record<string, unknown>;
|
||||
|
||||
// Validate prompt (optional)
|
||||
if (prompt !== undefined) {
|
||||
if (typeof prompt !== "string") {
|
||||
throw new ValidationError("prompt must be a string");
|
||||
}
|
||||
|
||||
if (prompt.length > MAX_FEATURE_PROMPT_LENGTH) {
|
||||
throw new ValidationError(
|
||||
`prompt exceeds maximum length of ${MAX_FEATURE_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}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the milestone context string for the system prompt.
|
||||
*/
|
||||
function buildMilestoneContextString(context: FeatureSuggestionContext): string {
|
||||
const lines: string[] = [];
|
||||
|
||||
lines.push(`Roadmap: ${context.roadmapTitle}`);
|
||||
if (context.roadmapDescription) {
|
||||
lines.push(`Description: ${context.roadmapDescription}`);
|
||||
}
|
||||
|
||||
lines.push("");
|
||||
lines.push(`Milestone: ${context.milestoneTitle}`);
|
||||
if (context.milestoneDescription) {
|
||||
lines.push(`Description: ${context.milestoneDescription}`);
|
||||
}
|
||||
|
||||
if (context.existingFeatureTitles.length > 0) {
|
||||
lines.push("");
|
||||
lines.push("Existing features in this milestone:");
|
||||
for (const title of context.existingFeatureTitles) {
|
||||
lines.push(` - ${title}`);
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse AI response for feature suggestions with robust extraction and recovery.
|
||||
*/
|
||||
function parseFeatureSuggestions(text: string): FeatureSuggestion[] {
|
||||
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 feature suggestions");
|
||||
}
|
||||
|
||||
// Validate and normalize each item - filter invalid entries per spec
|
||||
const suggestions: FeatureSuggestion[] = [];
|
||||
for (let i = 0; i < parsed.length; i++) {
|
||||
const item = parsed[i];
|
||||
|
||||
// Skip items that are not objects
|
||||
if (!item || typeof item !== "object") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const { title, description } = item as Record<string, unknown>;
|
||||
|
||||
// Skip entries with empty/whitespace-only titles per spec
|
||||
if (typeof title !== "string" || !title.trim()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
suggestions.push({
|
||||
title: title.trim(),
|
||||
description: typeof description === "string" && description.trim()
|
||||
? description.trim()
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
// If zero valid rows remain after filtering, return 500 error per spec
|
||||
if (suggestions.length === 0) {
|
||||
throw new ParseError("AI returned no valid feature suggestions");
|
||||
}
|
||||
|
||||
return suggestions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate feature suggestions for a specific milestone.
|
||||
*
|
||||
* @param context - Context about the milestone (roadmap info, milestone info, existing features)
|
||||
* @param count - Number of suggestions to generate (default 5, max 10)
|
||||
* @param prompt - Optional additional prompt to guide generation
|
||||
* @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 feature suggestions
|
||||
*/
|
||||
export async function generateFeatureSuggestions(
|
||||
context: FeatureSuggestionContext,
|
||||
count: number = DEFAULT_SUGGESTION_COUNT,
|
||||
prompt?: string,
|
||||
rootDir?: string,
|
||||
modelProvider?: string,
|
||||
modelId?: string,
|
||||
): Promise<FeatureSuggestion[]> {
|
||||
// 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");
|
||||
}
|
||||
|
||||
// Build the milestone context string
|
||||
const milestoneContextStr = buildMilestoneContextString(context);
|
||||
|
||||
// Build the system prompt with dynamic context
|
||||
const systemPrompt = FEATURE_SUGGESTION_SYSTEM_PROMPT.replace(
|
||||
"{MILESTONE_CONTEXT}",
|
||||
milestoneContextStr
|
||||
);
|
||||
|
||||
let agent: ReturnType<typeof createKbAgent> | undefined;
|
||||
|
||||
try {
|
||||
// Create AI agent with feature suggestion system prompt
|
||||
agent = await createKbAgent({
|
||||
cwd: rootDir,
|
||||
systemPrompt,
|
||||
tools: "readonly",
|
||||
...(modelProvider && modelId
|
||||
? {
|
||||
defaultProvider: modelProvider,
|
||||
defaultModelId: modelId,
|
||||
}
|
||||
: {}),
|
||||
onThinking: () => {
|
||||
// Ignore thinking output for feature suggestions
|
||||
},
|
||||
onText: () => {
|
||||
// Ignore incremental text
|
||||
},
|
||||
});
|
||||
|
||||
// Build the user message
|
||||
let userMessage = `Please suggest ${count} features for the milestone described above.`;
|
||||
if (prompt && prompt.trim()) {
|
||||
userMessage += `\n\nAdditional guidance:\n${prompt.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: FeatureSuggestion[] | undefined;
|
||||
let lastError: Error | undefined;
|
||||
|
||||
for (let attempt = 0; attempt <= MAX_PARSE_RETRIES; attempt++) {
|
||||
try {
|
||||
suggestions = parseFeatureSuggestions(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 feature suggestions in this format: " +
|
||||
'[{"title": "Feature 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 {
|
||||
|
||||
@@ -45,6 +45,8 @@ import { writeSSEEvent } from "./sse-buffer.js";
|
||||
import {
|
||||
generateMilestoneSuggestions,
|
||||
validateSuggestionInput,
|
||||
generateFeatureSuggestions,
|
||||
validateFeatureSuggestionInput,
|
||||
ValidationError as SuggestionValidationError,
|
||||
ParseError as SuggestionParseError,
|
||||
ServiceUnavailableError as SuggestionServiceUnavailableError,
|
||||
@@ -2779,6 +2781,83 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
}
|
||||
});
|
||||
|
||||
// ── Roadmap Feature Suggestions ──────────────────────────────────────────
|
||||
|
||||
// Generate feature suggestions for a milestone
|
||||
router.post("/roadmaps/milestones/:milestoneId/suggestions/features", async (req, res) => {
|
||||
try {
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const roadmapStore = scopedStore.getRoadmapStore();
|
||||
const { milestoneId } = req.params;
|
||||
|
||||
// Get the milestone to find the roadmap
|
||||
const milestone = roadmapStore.getMilestone(milestoneId);
|
||||
if (!milestone) {
|
||||
throw notFound(`Milestone ${milestoneId} not found`);
|
||||
}
|
||||
|
||||
// Get the roadmap for context
|
||||
const roadmap = roadmapStore.getRoadmap(milestone.roadmapId);
|
||||
if (!roadmap) {
|
||||
throw notFound(`Roadmap ${milestone.roadmapId} not found`);
|
||||
}
|
||||
|
||||
// Get existing features for this milestone
|
||||
const existingFeatures = roadmapStore.listFeatures(milestoneId);
|
||||
const existingFeatureTitles = existingFeatures.map((f) => f.title);
|
||||
|
||||
// Validate input
|
||||
let input: { prompt?: string; count?: number };
|
||||
try {
|
||||
validateFeatureSuggestionInput(req.body);
|
||||
input = req.body as { prompt?: string; count?: number };
|
||||
} catch (err) {
|
||||
if (err instanceof SuggestionValidationError) {
|
||||
throw badRequest(err.message);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Build the context for feature suggestion
|
||||
const context = {
|
||||
roadmapTitle: roadmap.title,
|
||||
roadmapDescription: roadmap.description,
|
||||
milestoneTitle: milestone.title,
|
||||
milestoneDescription: milestone.description,
|
||||
existingFeatureTitles,
|
||||
};
|
||||
|
||||
// Get project root directory for AI context
|
||||
const rootDir = scopedStore.getRootDir();
|
||||
|
||||
// Generate suggestions
|
||||
try {
|
||||
const suggestions = await generateFeatureSuggestions(
|
||||
context,
|
||||
input.count,
|
||||
input.prompt,
|
||||
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 feature suggestions");
|
||||
}
|
||||
});
|
||||
|
||||
// List all tasks
|
||||
router.get("/tasks", async (req, res) => {
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user