feat(FN-1920): merge fusion/fn-1920
This commit is contained in:
5
.changeset/fix-roadmap-suggestion-timeout.md
Normal file
5
.changeset/fix-roadmap-suggestion-timeout.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@gsxdsm/fusion": patch
|
||||
---
|
||||
|
||||
Fix roadmap AI suggestion generation hanging by adding 120-second timeout protection to `generateMilestoneSuggestions()` and `generateFeatureSuggestions()` functions, and a 130-second route-level safety-net timeout on both suggestion endpoints. Previously, if the AI provider was slow or unresponsive, HTTP requests would hang indefinitely. The fix ensures agent sessions are properly disposed on timeout and returns a 503 Service Unavailable response with a descriptive error message.
|
||||
@@ -7,15 +7,25 @@ import { createRoadmapRouter } from "./roadmap-routes.js";
|
||||
import { ApiError } from "./api-error.js";
|
||||
import type { Roadmap, RoadmapMilestone, RoadmapFeature, RoadmapStore } from "@fusion/core";
|
||||
|
||||
vi.mock("./roadmap-suggestions.js", () => ({
|
||||
generateMilestoneSuggestions: vi.fn().mockResolvedValue({ suggestions: [] }),
|
||||
validateSuggestionInput: vi.fn(),
|
||||
generateFeatureSuggestions: vi.fn().mockResolvedValue({ suggestions: [] }),
|
||||
validateFeatureSuggestionInput: vi.fn(),
|
||||
ValidationError: class extends Error { name = "ValidationError"; constructor(m: string) { super(m); } },
|
||||
ParseError: class extends Error { name = "ParseError"; constructor(m: string) { super(m); } },
|
||||
ServiceUnavailableError: class extends Error { name = "ServiceUnavailableError"; constructor(m: string) { super(m); } },
|
||||
}));
|
||||
|
||||
// vi.mock is hoisted
|
||||
vi.mock("./roadmap-suggestions.js", () => {
|
||||
// Define error classes inside the factory - these will be used by the mocked module
|
||||
class MockValidationError extends Error { name = "ValidationError"; constructor(m: string) { super(m); } }
|
||||
class MockParseError extends Error { name = "ParseError"; constructor(m: string) { super(m); } }
|
||||
class MockServiceUnavailableError extends Error { name = "ServiceUnavailableError"; constructor(m: string) { super(m); } }
|
||||
|
||||
return {
|
||||
generateMilestoneSuggestions: vi.fn().mockResolvedValue({ suggestions: [] }),
|
||||
validateSuggestionInput: vi.fn(),
|
||||
generateFeatureSuggestions: vi.fn().mockResolvedValue({ suggestions: [] }),
|
||||
validateFeatureSuggestionInput: vi.fn(),
|
||||
ValidationError: MockValidationError,
|
||||
ParseError: MockParseError,
|
||||
ServiceUnavailableError: MockServiceUnavailableError,
|
||||
SUGGESTION_TIMEOUT_MS: 120_000,
|
||||
};
|
||||
});
|
||||
|
||||
const mockGetOrCreateProjectStore = vi.fn();
|
||||
vi.mock("./project-store-resolver.js", () => ({
|
||||
@@ -564,4 +574,57 @@ describe("Roadmap Routes", () => {
|
||||
expect(response.body.description).toBe("Feature desc");
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/roadmaps/:roadmapId/suggestions/milestones", () => {
|
||||
it("returns 503 when generation times out", async () => {
|
||||
// Import the mocked module
|
||||
const mod = await import("./roadmap-suggestions.js");
|
||||
|
||||
// Create an instance of the mocked ServiceUnavailableError
|
||||
const error = new mod.ServiceUnavailableError("AI suggestion generation timed out. Please try again.");
|
||||
|
||||
// Mock to throw ServiceUnavailableError with timeout message
|
||||
(mod.generateMilestoneSuggestions as ReturnType<typeof vi.fn>).mockRejectedValue(error);
|
||||
|
||||
const roadmap = mockRoadmapStore.createRoadmap({ title: "Test Roadmap" });
|
||||
|
||||
const response = await performRequest(
|
||||
app,
|
||||
"POST",
|
||||
"/api/roadmaps/" + roadmap.id + "/suggestions/milestones",
|
||||
JSON.stringify({ goalPrompt: "Build a platform", count: 5 }),
|
||||
{ "Content-Type": "application/json" }
|
||||
);
|
||||
|
||||
expect(response.status).toBe(503);
|
||||
expect(response.body.error).toContain("timed out");
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/roadmaps/milestones/:milestoneId/suggestions/features", () => {
|
||||
it("returns 503 when generation times out", async () => {
|
||||
// Import the mocked module - vi.mocked helps with type inference
|
||||
const mod = vi.mocked(await import("./roadmap-suggestions.js"));
|
||||
|
||||
// Create an instance of the mocked ServiceUnavailableError
|
||||
const error = new mod.ServiceUnavailableError("AI suggestion generation timed out. Please try again.");
|
||||
|
||||
// Mock to throw ServiceUnavailableError with timeout message
|
||||
mod.generateFeatureSuggestions.mockRejectedValue(error);
|
||||
|
||||
const roadmap = mockRoadmapStore.createRoadmap({ title: "Test Roadmap" });
|
||||
const milestone = mockRoadmapStore.createMilestone(roadmap.id, { title: "Phase 1" });
|
||||
|
||||
const response = await performRequest(
|
||||
app,
|
||||
"POST",
|
||||
"/api/roadmaps/milestones/" + milestone.id + "/suggestions/features",
|
||||
JSON.stringify({ count: 5 }),
|
||||
{ "Content-Type": "application/json" }
|
||||
);
|
||||
|
||||
expect(response.status).toBe(503);
|
||||
expect(response.body.error).toContain("timed out");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -50,6 +50,7 @@ import {
|
||||
ValidationError as SuggestionValidationError,
|
||||
ParseError as SuggestionParseError,
|
||||
ServiceUnavailableError as SuggestionServiceUnavailableError,
|
||||
SUGGESTION_TIMEOUT_MS,
|
||||
} from "./roadmap-suggestions.js";
|
||||
import { getOrCreateProjectStore } from "./project-store-resolver.js";
|
||||
|
||||
@@ -452,6 +453,21 @@ export function createRoadmapRouter(store: TaskStore): Router {
|
||||
* Generate milestone suggestions using AI.
|
||||
*/
|
||||
router.post("/:roadmapId/suggestions/milestones", async (req, res) => {
|
||||
// Route-level timeout as safety net (slightly longer than internal timeout)
|
||||
const ROUTE_TIMEOUT_MS = SUGGESTION_TIMEOUT_MS + 10_000;
|
||||
let routeTimedOut = false;
|
||||
const routeTimeoutId = setTimeout(() => {
|
||||
routeTimedOut = true;
|
||||
if (!res.headersSent) {
|
||||
res.status(503).json({ error: "Request timed out" });
|
||||
}
|
||||
}, ROUTE_TIMEOUT_MS);
|
||||
|
||||
// Clean up timeout on connection close
|
||||
res.on("close", () => {
|
||||
if (routeTimeoutId) clearTimeout(routeTimeoutId);
|
||||
});
|
||||
|
||||
try {
|
||||
const roadmapStore = getScopedStore().getRoadmapStore();
|
||||
const scopedStore = getScopedStore();
|
||||
@@ -500,6 +516,8 @@ export function createRoadmapRouter(store: TaskStore): Router {
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) throw err;
|
||||
rethrowAsApiError(err, "Failed to generate milestone suggestions");
|
||||
} finally {
|
||||
if (routeTimeoutId) clearTimeout(routeTimeoutId);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -508,6 +526,21 @@ export function createRoadmapRouter(store: TaskStore): Router {
|
||||
* Generate feature suggestions using AI.
|
||||
*/
|
||||
router.post("/milestones/:milestoneId/suggestions/features", async (req, res) => {
|
||||
// Route-level timeout as safety net (slightly longer than internal timeout)
|
||||
const ROUTE_TIMEOUT_MS = SUGGESTION_TIMEOUT_MS + 10_000;
|
||||
let routeTimedOut = false;
|
||||
const routeTimeoutId = setTimeout(() => {
|
||||
routeTimedOut = true;
|
||||
if (!res.headersSent) {
|
||||
res.status(503).json({ error: "Request timed out" });
|
||||
}
|
||||
}, ROUTE_TIMEOUT_MS);
|
||||
|
||||
// Clean up timeout on connection close
|
||||
res.on("close", () => {
|
||||
if (routeTimeoutId) clearTimeout(routeTimeoutId);
|
||||
});
|
||||
|
||||
try {
|
||||
const roadmapStore = getScopedStore().getRoadmapStore();
|
||||
const scopedStore = getScopedStore();
|
||||
@@ -576,6 +609,8 @@ export function createRoadmapRouter(store: TaskStore): Router {
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError) throw err;
|
||||
rethrowAsApiError(err, "Failed to generate feature suggestions");
|
||||
} finally {
|
||||
if (routeTimeoutId) clearTimeout(routeTimeoutId);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
generateFeatureSuggestions,
|
||||
ValidationError,
|
||||
ParseError,
|
||||
ServiceUnavailableError,
|
||||
SUGGESTION_TIMEOUT_MS,
|
||||
__resetSuggestionState,
|
||||
__setCreateKbAgent,
|
||||
} from "./roadmap-suggestions";
|
||||
@@ -617,6 +619,43 @@ describe("roadmap-suggestions", () => {
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("times out when AI prompt hangs", async () => {
|
||||
// Create a mock session whose prompt hangs (never resolves)
|
||||
const mockSession = {
|
||||
prompt: vi.fn().mockReturnValue(
|
||||
new Promise<undefined>(() => {
|
||||
// Never resolves - simulates hanging AI
|
||||
})
|
||||
),
|
||||
dispose: vi.fn(),
|
||||
state: {
|
||||
messages: [],
|
||||
},
|
||||
};
|
||||
|
||||
const mockCreateKbAgent = vi.fn().mockResolvedValue({
|
||||
session: mockSession,
|
||||
});
|
||||
|
||||
__setCreateKbAgent(mockCreateKbAgent);
|
||||
|
||||
// Use fake timers
|
||||
vi.useFakeTimers();
|
||||
|
||||
try {
|
||||
const promise = generateMilestoneSuggestions("Test goal", 5, rootDir);
|
||||
|
||||
// Advance timers past the timeout threshold
|
||||
await vi.advanceTimersByTimeAsync(SUGGESTION_TIMEOUT_MS + 100);
|
||||
|
||||
// The promise should reject with ServiceUnavailableError
|
||||
await expect(promise).rejects.toThrow(ServiceUnavailableError);
|
||||
await expect(promise).rejects.toThrow(/timed out/i);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateFeatureSuggestionInput", () => {
|
||||
@@ -1406,5 +1445,42 @@ describe("roadmap-suggestions", () => {
|
||||
expect(suggestions[0]).toEqual({ title: "Valid", description: undefined });
|
||||
expect(suggestions[1]).toEqual({ title: "Also Valid", description: undefined });
|
||||
});
|
||||
|
||||
it("times out when AI prompt hangs", async () => {
|
||||
// Create a mock session whose prompt hangs (never resolves)
|
||||
const mockSession = {
|
||||
prompt: vi.fn().mockReturnValue(
|
||||
new Promise<undefined>(() => {
|
||||
// Never resolves - simulates hanging AI
|
||||
})
|
||||
),
|
||||
dispose: vi.fn(),
|
||||
state: {
|
||||
messages: [],
|
||||
},
|
||||
};
|
||||
|
||||
const mockCreateKbAgent = vi.fn().mockResolvedValue({
|
||||
session: mockSession,
|
||||
});
|
||||
|
||||
__setCreateKbAgent(mockCreateKbAgent);
|
||||
|
||||
// Use fake timers
|
||||
vi.useFakeTimers();
|
||||
|
||||
try {
|
||||
const promise = generateFeatureSuggestions(baseContext, 5, undefined, rootDir);
|
||||
|
||||
// Advance timers past the timeout threshold
|
||||
await vi.advanceTimersByTimeAsync(SUGGESTION_TIMEOUT_MS + 100);
|
||||
|
||||
// The promise should reject with ServiceUnavailableError
|
||||
await expect(promise).rejects.toThrow(ServiceUnavailableError);
|
||||
await expect(promise).rejects.toThrow(/timed out/i);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,8 +13,6 @@
|
||||
* - Error mapping (validation 400, not found 404, AI/parser 500/503)
|
||||
*/
|
||||
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
// Dynamic import for @fusion/engine to avoid resolution issues in test environment
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let createKbAgent: any;
|
||||
@@ -97,6 +95,9 @@ Do NOT include any markdown formatting, code fences, or additional text. Only ou
|
||||
/** 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;
|
||||
|
||||
@@ -364,122 +365,132 @@ export async function generateMilestoneSuggestions(
|
||||
throw new Error("rootDir is required for AI-powered suggestion generation");
|
||||
}
|
||||
|
||||
// Create a unique session ID for this generation
|
||||
const sessionId = randomUUID();
|
||||
// Race AI generation against a timeout to prevent hanging requests
|
||||
const result = await Promise.race([
|
||||
(async () => {
|
||||
let agent: ReturnType<typeof createKbAgent> | undefined;
|
||||
|
||||
let agent: ReturnType<typeof createKbAgent> | undefined;
|
||||
|
||||
try {
|
||||
// Create AI agent with milestone suggestion system prompt
|
||||
agent = await createKbAgent({
|
||||
cwd: rootDir,
|
||||
systemPrompt: MILESTONE_SUGGESTION_SYSTEM_PROMPT,
|
||||
tools: "readonly",
|
||||
...(modelProvider && modelId
|
||||
? {
|
||||
defaultProvider: modelProvider,
|
||||
defaultModelId: modelId,
|
||||
}
|
||||
: {}),
|
||||
onThinking: () => {
|
||||
// Ignore thinking output for milestone suggestions
|
||||
},
|
||||
onText: () => {
|
||||
// Ignore incremental text
|
||||
},
|
||||
});
|
||||
|
||||
// Send the goal prompt with count instruction
|
||||
const userMessage = `Please suggest ${count} milestones for the following roadmap goal:\n\n${goalPrompt.trim()}`;
|
||||
|
||||
// Get response from AI
|
||||
await agent.session.prompt(userMessage);
|
||||
|
||||
// Extract response text from agent state
|
||||
interface AgentMessage {
|
||||
role: string;
|
||||
content?: string | Array<{ type: string; text: string }>;
|
||||
}
|
||||
const lastMessage = (agent.session.state.messages as AgentMessage[])
|
||||
.filter((m: AgentMessage) => m.role === "assistant")
|
||||
.pop();
|
||||
|
||||
let responseText = "";
|
||||
if (lastMessage?.content) {
|
||||
if (typeof lastMessage.content === "string") {
|
||||
responseText = lastMessage.content;
|
||||
} else if (Array.isArray(lastMessage.content)) {
|
||||
responseText = lastMessage.content
|
||||
.filter((c: { type: string; text: string }): c is { type: "text"; text: string } => c.type === "text")
|
||||
.map((c: { type: string; text: string }) => c.text)
|
||||
.join("");
|
||||
}
|
||||
}
|
||||
|
||||
// Parse the JSON response with retry
|
||||
let suggestions: MilestoneSuggestion[] | undefined;
|
||||
let lastError: Error | undefined;
|
||||
|
||||
for (let attempt = 0; attempt <= MAX_PARSE_RETRIES; attempt++) {
|
||||
try {
|
||||
suggestions = parseMilestoneSuggestions(responseText);
|
||||
break;
|
||||
} catch (err) {
|
||||
lastError = err instanceof Error ? err : new Error(String(err));
|
||||
// Create AI agent with milestone suggestion system prompt
|
||||
agent = await createKbAgent({
|
||||
cwd: rootDir,
|
||||
systemPrompt: MILESTONE_SUGGESTION_SYSTEM_PROMPT,
|
||||
tools: "readonly",
|
||||
...(modelProvider && modelId
|
||||
? {
|
||||
defaultProvider: modelProvider,
|
||||
defaultModelId: modelId,
|
||||
}
|
||||
: {}),
|
||||
onThinking: () => {
|
||||
// Ignore thinking output for milestone suggestions
|
||||
},
|
||||
onText: () => {
|
||||
// Ignore incremental text
|
||||
},
|
||||
});
|
||||
|
||||
if (attempt < MAX_PARSE_RETRIES) {
|
||||
// Retry: ask the AI to reformat as clean JSON
|
||||
// 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 {
|
||||
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."
|
||||
);
|
||||
suggestions = parseMilestoneSuggestions(responseText);
|
||||
break;
|
||||
} catch (err) {
|
||||
lastError = err instanceof Error ? err : new Error(String(err));
|
||||
|
||||
// Get the new response text
|
||||
const retryMessage = (agent.session.state.messages as AgentMessage[])
|
||||
.filter((m: AgentMessage) => m.role === "assistant")
|
||||
.pop();
|
||||
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."
|
||||
);
|
||||
|
||||
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("");
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
responseText = retryText;
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
// Retry prompt itself failed — give up
|
||||
break;
|
||||
// Ignore disposal errors
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})(),
|
||||
new Promise<never>((_, reject) =>
|
||||
setTimeout(
|
||||
() => reject(new ServiceUnavailableError("AI suggestion generation timed out. Please try again.")),
|
||||
SUGGESTION_TIMEOUT_MS
|
||||
)
|
||||
),
|
||||
]);
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
@@ -722,122 +733,135 @@ export async function generateFeatureSuggestions(
|
||||
milestoneContextStr
|
||||
);
|
||||
|
||||
let agent: ReturnType<typeof createKbAgent> | undefined;
|
||||
// Race AI generation against a timeout to prevent hanging requests
|
||||
const result = await Promise.race([
|
||||
(async () => {
|
||||
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));
|
||||
// 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
|
||||
},
|
||||
});
|
||||
|
||||
if (attempt < MAX_PARSE_RETRIES) {
|
||||
// Retry: ask the AI to reformat as clean JSON
|
||||
// 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 {
|
||||
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."
|
||||
);
|
||||
suggestions = parseFeatureSuggestions(responseText);
|
||||
break;
|
||||
} catch (err) {
|
||||
lastError = err instanceof Error ? err : new Error(String(err));
|
||||
|
||||
// Get the new response text
|
||||
const retryMessage = (agent.session.state.messages as AgentMessage[])
|
||||
.filter((m: AgentMessage) => m.role === "assistant")
|
||||
.pop();
|
||||
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."
|
||||
);
|
||||
|
||||
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("");
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
responseText = retryText;
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
// Retry prompt itself failed — give up
|
||||
break;
|
||||
// Ignore disposal errors
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})(),
|
||||
new Promise<never>((_, reject) =>
|
||||
setTimeout(
|
||||
() => reject(new ServiceUnavailableError("AI suggestion generation timed out. Please try again.")),
|
||||
SUGGESTION_TIMEOUT_MS
|
||||
)
|
||||
),
|
||||
]);
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── Custom Errors ───────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user