Files
fusion/packages/dashboard/src/roadmap-suggestions.ts
Fusion 0b9bf62a2a refactor(FN-2162): rename kb-agent identifiers to fn-agent
- Rename core loader, dashboard server chat/planning routes, and frontend agent IDs/storage keys from kb-agent to fn-agent naming
- Update dashboard hooks and components (agent list, chat view, quick chat) to use the new fn agent key prefixes consistently
- Refresh engine, dashboard, core, and CLI tests/mocks to remove remaining kb-agent route and temp prefix references
- Update storage/gap-analysis docs to reflect fn agent key names and add a @gsxdsm/fusion patch changeset for the rename
2026-04-19 20:48:14 -07:00

908 lines
30 KiB
TypeScript

/**
* 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)
*/
// Dynamic import for @fusion/engine to avoid resolution issues in test environment
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let createFnAgent: any;
// Track if engine has been initialized (prevents multiple imports)
let engineInitialized = false;
// Flag to indicate if createFnAgent was explicitly set (even to undefined)
let createFnAgentExplicitlySet = false;
// Initialize the import (this runs in actual server, mocked in tests)
async function initEngine(): Promise<void> {
if (engineInitialized) return;
// If createFnAgent was explicitly set (even to undefined), don't try to import
if (createFnAgentExplicitlySet) {
engineInitialized = true;
return;
}
if (!createFnAgent) {
try {
// Use dynamic import with variable to prevent static analysis
const engineModule = "@fusion/engine";
const engine = await import(/* @vite-ignore */ engineModule);
createFnAgent = engine.createFnAgent;
} catch {
// Allow failure in test environments - agent functionality will be stubbed
createFnAgent = 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;
/** Timeout for AI suggestion generation (2 minutes) */
export const SUGGESTION_TIMEOUT_MS = 120_000;
/** 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 - 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") {
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 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 createFnAgent
await initEngine();
if (!createFnAgent) {
throw new ServiceUnavailableError("AI service is not available");
}
if (!rootDir) {
throw new Error("rootDir is required for AI-powered suggestion generation");
}
// Race AI generation against a timeout to prevent hanging requests
const result = await Promise.race([
(async () => {
let agent: ReturnType<typeof createFnAgent> | undefined;
try {
// Create AI agent with milestone suggestion system prompt
agent = await createFnAgent({
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 (inside the raced promise so cleanup happens when this settles)
if (agent) {
try {
agent.session.dispose?.();
} catch {
// Ignore disposal errors
}
}
}
})(),
new Promise<never>((_, reject) =>
setTimeout(
() => reject(new ServiceUnavailableError("AI suggestion generation timed out. Please try again.")),
SUGGESTION_TIMEOUT_MS
)
),
]);
return result;
}
// ═══════════════════════════════════════════════════════════════════════════════
// 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 createFnAgent
await initEngine();
if (!createFnAgent) {
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
);
// Race AI generation against a timeout to prevent hanging requests
const result = await Promise.race([
(async () => {
let agent: ReturnType<typeof createFnAgent> | undefined;
try {
// Create AI agent with feature suggestion system prompt
agent = await createFnAgent({
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 (inside the raced promise so cleanup happens when this settles)
if (agent) {
try {
agent.session.dispose?.();
} catch {
// Ignore disposal errors
}
}
}
})(),
new Promise<never>((_, reject) =>
setTimeout(
() => reject(new ServiceUnavailableError("AI suggestion generation timed out. Please try again.")),
SUGGESTION_TIMEOUT_MS
)
),
]);
return result;
}
// ── 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 {
createFnAgent = undefined;
engineInitialized = false;
createFnAgentExplicitlySet = false;
}
/**
* Inject a mock createFnAgent function. Used for testing only.
*/
export function __setCreateFnAgent(mock: typeof createFnAgent): void {
createFnAgent = mock;
createFnAgentExplicitlySet = true;
}