mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-31 20:03:37 +00:00
Merge v0.8.0rc into feat/cursor-cli
Resolved conflicts: - sdk-options.ts: kept HEAD (MCP & thinking level features) - auto-mode-service.ts: kept HEAD (MCP features + fallback code) - agent-output-modal.tsx: used v0.8.0rc (effectiveViewMode + pr-8 spacing) - feature-suggestions-dialog.tsx: accepted deletion - electron.ts: used v0.8.0rc (Ideation types) - package-lock.json: regenerated Fixed sdk-options.test.ts to expect 'default' permissionMode for read-only operations. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -61,6 +61,8 @@ import { createMCPRoutes } from './routes/mcp/index.js';
|
||||
import { MCPTestService } from './services/mcp-test-service.js';
|
||||
import { createPipelineRoutes } from './routes/pipeline/index.js';
|
||||
import { pipelineService } from './services/pipeline-service.js';
|
||||
import { createIdeationRoutes } from './routes/ideation/index.js';
|
||||
import { IdeationService } from './services/ideation-service.js';
|
||||
|
||||
// Load environment variables
|
||||
dotenv.config();
|
||||
@@ -165,6 +167,7 @@ const featureLoader = new FeatureLoader();
|
||||
const autoModeService = new AutoModeService(events, settingsService);
|
||||
const claudeUsageService = new ClaudeUsageService();
|
||||
const mcpTestService = new MCPTestService(settingsService);
|
||||
const ideationService = new IdeationService(events, settingsService, featureLoader);
|
||||
|
||||
// Initialize services
|
||||
(async () => {
|
||||
@@ -218,6 +221,7 @@ app.use('/api/context', createContextRoutes(settingsService));
|
||||
app.use('/api/backlog-plan', createBacklogPlanRoutes(events, settingsService));
|
||||
app.use('/api/mcp', createMCPRoutes(mcpTestService));
|
||||
app.use('/api/pipeline', createPipelineRoutes(pipelineService));
|
||||
app.use('/api/ideation', createIdeationRoutes(events, ideationService, featureLoader));
|
||||
|
||||
// Create HTTP server
|
||||
const server = createServer(app);
|
||||
|
||||
@@ -191,41 +191,6 @@ export async function getMCPServersFromSettings(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get MCP permission settings from global settings.
|
||||
*
|
||||
* @param settingsService - Optional settings service instance
|
||||
* @param logPrefix - Prefix for log messages (e.g., '[AgentService]')
|
||||
* @returns Promise resolving to MCP permission settings
|
||||
*/
|
||||
export async function getMCPPermissionSettings(
|
||||
settingsService?: SettingsService | null,
|
||||
logPrefix = '[SettingsHelper]'
|
||||
): Promise<{ mcpAutoApproveTools: boolean; mcpUnrestrictedTools: boolean }> {
|
||||
// Default to true for autonomous workflow. Security is enforced when adding servers
|
||||
// via the security warning dialog that explains the risks.
|
||||
const defaults = { mcpAutoApproveTools: true, mcpUnrestrictedTools: true };
|
||||
|
||||
if (!settingsService) {
|
||||
return defaults;
|
||||
}
|
||||
|
||||
try {
|
||||
const globalSettings = await settingsService.getGlobalSettings();
|
||||
const result = {
|
||||
mcpAutoApproveTools: globalSettings.mcpAutoApproveTools ?? true,
|
||||
mcpUnrestrictedTools: globalSettings.mcpUnrestrictedTools ?? true,
|
||||
};
|
||||
logger.info(
|
||||
`${logPrefix} MCP permission settings: autoApprove=${result.mcpAutoApproveTools}, unrestricted=${result.mcpUnrestrictedTools}`
|
||||
);
|
||||
return result;
|
||||
} catch (error) {
|
||||
logger.error(`${logPrefix} Failed to load MCP permission settings:`, error);
|
||||
return defaults;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a settings MCPServerConfig to SDK McpServerConfig format.
|
||||
* Validates required fields and throws informative errors if missing.
|
||||
|
||||
@@ -70,20 +70,13 @@ export class ClaudeProvider extends BaseProvider {
|
||||
const maxThinkingTokens = getThinkingTokenBudget(thinkingLevel);
|
||||
|
||||
// Build Claude SDK options
|
||||
// MCP permission logic - determines how to handle tool permissions when MCP servers are configured.
|
||||
// This logic mirrors buildMcpOptions() in sdk-options.ts but is applied here since
|
||||
// the provider is the final point where SDK options are constructed.
|
||||
// AUTONOMOUS MODE: Always bypass permissions for fully autonomous operation
|
||||
const hasMcpServers = options.mcpServers && Object.keys(options.mcpServers).length > 0;
|
||||
// Default to true for autonomous workflow. Security is enforced when adding servers
|
||||
// via the security warning dialog that explains the risks.
|
||||
const mcpAutoApprove = options.mcpAutoApproveTools ?? true;
|
||||
const mcpUnrestricted = options.mcpUnrestrictedTools ?? true;
|
||||
const defaultTools = ['Read', 'Write', 'Edit', 'Glob', 'Grep', 'Bash', 'WebSearch', 'WebFetch'];
|
||||
|
||||
// Determine permission mode based on settings
|
||||
const shouldBypassPermissions = hasMcpServers && mcpAutoApprove;
|
||||
// Determine if we should restrict tools (only when no MCP or unrestricted is disabled)
|
||||
const shouldRestrictTools = !hasMcpServers || !mcpUnrestricted;
|
||||
// AUTONOMOUS MODE: Always bypass permissions and allow unrestricted tools
|
||||
// Only restrict tools when no MCP servers are configured
|
||||
const shouldRestrictTools = !hasMcpServers;
|
||||
|
||||
const sdkOptions: Options = {
|
||||
model,
|
||||
@@ -95,10 +88,9 @@ export class ClaudeProvider extends BaseProvider {
|
||||
// Only restrict tools if explicitly set OR (no MCP / unrestricted disabled)
|
||||
...(allowedTools && shouldRestrictTools && { allowedTools }),
|
||||
...(!allowedTools && shouldRestrictTools && { allowedTools: defaultTools }),
|
||||
// When MCP servers are configured and auto-approve is enabled, use bypassPermissions
|
||||
permissionMode: shouldBypassPermissions ? 'bypassPermissions' : 'default',
|
||||
// Required when using bypassPermissions mode
|
||||
...(shouldBypassPermissions && { allowDangerouslySkipPermissions: true }),
|
||||
// AUTONOMOUS MODE: Always bypass permissions and allow dangerous operations
|
||||
permissionMode: 'bypassPermissions',
|
||||
allowDangerouslySkipPermissions: true,
|
||||
abortController,
|
||||
// Resume existing SDK session if we have a session ID
|
||||
...(sdkSessionId && conversationHistory && conversationHistory.length > 0
|
||||
|
||||
@@ -96,7 +96,7 @@ export function createGenerateTitleHandler(): (req: Request, res: Response) => P
|
||||
systemPrompt: SYSTEM_PROMPT,
|
||||
maxTurns: 1,
|
||||
allowedTools: [],
|
||||
permissionMode: 'acceptEdits',
|
||||
permissionMode: 'default',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
12
apps/server/src/routes/ideation/common.ts
Normal file
12
apps/server/src/routes/ideation/common.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Common utilities for ideation routes
|
||||
*/
|
||||
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { getErrorMessage as getErrorMessageShared, createLogError } from '../common.js';
|
||||
|
||||
const logger = createLogger('Ideation');
|
||||
|
||||
// Re-export shared utilities
|
||||
export { getErrorMessageShared as getErrorMessage };
|
||||
export const logError = createLogError(logger);
|
||||
109
apps/server/src/routes/ideation/index.ts
Normal file
109
apps/server/src/routes/ideation/index.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Ideation routes - HTTP API for brainstorming and idea management
|
||||
*/
|
||||
|
||||
import { Router } from 'express';
|
||||
import type { EventEmitter } from '../../lib/events.js';
|
||||
import { validatePathParams } from '../../middleware/validate-paths.js';
|
||||
import type { IdeationService } from '../../services/ideation-service.js';
|
||||
import type { FeatureLoader } from '../../services/feature-loader.js';
|
||||
|
||||
// Route handlers
|
||||
import { createSessionStartHandler } from './routes/session-start.js';
|
||||
import { createSessionMessageHandler } from './routes/session-message.js';
|
||||
import { createSessionStopHandler } from './routes/session-stop.js';
|
||||
import { createSessionGetHandler } from './routes/session-get.js';
|
||||
import { createIdeasListHandler } from './routes/ideas-list.js';
|
||||
import { createIdeasCreateHandler } from './routes/ideas-create.js';
|
||||
import { createIdeasGetHandler } from './routes/ideas-get.js';
|
||||
import { createIdeasUpdateHandler } from './routes/ideas-update.js';
|
||||
import { createIdeasDeleteHandler } from './routes/ideas-delete.js';
|
||||
import { createAnalyzeHandler, createGetAnalysisHandler } from './routes/analyze.js';
|
||||
import { createConvertHandler } from './routes/convert.js';
|
||||
import { createAddSuggestionHandler } from './routes/add-suggestion.js';
|
||||
import { createPromptsHandler, createPromptsByCategoryHandler } from './routes/prompts.js';
|
||||
import { createSuggestionsGenerateHandler } from './routes/suggestions-generate.js';
|
||||
|
||||
export function createIdeationRoutes(
|
||||
events: EventEmitter,
|
||||
ideationService: IdeationService,
|
||||
featureLoader: FeatureLoader
|
||||
): Router {
|
||||
const router = Router();
|
||||
|
||||
// Session management
|
||||
router.post(
|
||||
'/session/start',
|
||||
validatePathParams('projectPath'),
|
||||
createSessionStartHandler(ideationService)
|
||||
);
|
||||
router.post('/session/message', createSessionMessageHandler(ideationService));
|
||||
router.post('/session/stop', createSessionStopHandler(events, ideationService));
|
||||
router.post(
|
||||
'/session/get',
|
||||
validatePathParams('projectPath'),
|
||||
createSessionGetHandler(ideationService)
|
||||
);
|
||||
|
||||
// Ideas CRUD
|
||||
router.post(
|
||||
'/ideas/list',
|
||||
validatePathParams('projectPath'),
|
||||
createIdeasListHandler(ideationService)
|
||||
);
|
||||
router.post(
|
||||
'/ideas/create',
|
||||
validatePathParams('projectPath'),
|
||||
createIdeasCreateHandler(events, ideationService)
|
||||
);
|
||||
router.post(
|
||||
'/ideas/get',
|
||||
validatePathParams('projectPath'),
|
||||
createIdeasGetHandler(ideationService)
|
||||
);
|
||||
router.post(
|
||||
'/ideas/update',
|
||||
validatePathParams('projectPath'),
|
||||
createIdeasUpdateHandler(events, ideationService)
|
||||
);
|
||||
router.post(
|
||||
'/ideas/delete',
|
||||
validatePathParams('projectPath'),
|
||||
createIdeasDeleteHandler(events, ideationService)
|
||||
);
|
||||
|
||||
// Project analysis
|
||||
router.post('/analyze', validatePathParams('projectPath'), createAnalyzeHandler(ideationService));
|
||||
router.post(
|
||||
'/analysis',
|
||||
validatePathParams('projectPath'),
|
||||
createGetAnalysisHandler(ideationService)
|
||||
);
|
||||
|
||||
// Convert to feature
|
||||
router.post(
|
||||
'/convert',
|
||||
validatePathParams('projectPath'),
|
||||
createConvertHandler(events, ideationService, featureLoader)
|
||||
);
|
||||
|
||||
// Add suggestion to board as a feature
|
||||
router.post(
|
||||
'/add-suggestion',
|
||||
validatePathParams('projectPath'),
|
||||
createAddSuggestionHandler(ideationService, featureLoader)
|
||||
);
|
||||
|
||||
// Guided prompts (no validation needed - static data)
|
||||
router.get('/prompts', createPromptsHandler(ideationService));
|
||||
router.get('/prompts/:category', createPromptsByCategoryHandler(ideationService));
|
||||
|
||||
// Generate suggestions (structured output)
|
||||
router.post(
|
||||
'/suggestions/generate',
|
||||
validatePathParams('projectPath'),
|
||||
createSuggestionsGenerateHandler(ideationService)
|
||||
);
|
||||
|
||||
return router;
|
||||
}
|
||||
70
apps/server/src/routes/ideation/routes/add-suggestion.ts
Normal file
70
apps/server/src/routes/ideation/routes/add-suggestion.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* POST /add-suggestion - Add an analysis suggestion to the board as a feature
|
||||
*
|
||||
* This endpoint converts an AnalysisSuggestion to a Feature using the
|
||||
* IdeationService's mapIdeaCategoryToFeatureCategory for consistent category mapping.
|
||||
* This ensures a single source of truth for the conversion logic.
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { IdeationService } from '../../../services/ideation-service.js';
|
||||
import type { FeatureLoader } from '../../../services/feature-loader.js';
|
||||
import type { AnalysisSuggestion } from '@automaker/types';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createAddSuggestionHandler(
|
||||
ideationService: IdeationService,
|
||||
featureLoader: FeatureLoader
|
||||
) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, suggestion } = req.body as {
|
||||
projectPath: string;
|
||||
suggestion: AnalysisSuggestion;
|
||||
};
|
||||
|
||||
if (!projectPath) {
|
||||
res.status(400).json({ success: false, error: 'projectPath is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!suggestion) {
|
||||
res.status(400).json({ success: false, error: 'suggestion is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!suggestion.title) {
|
||||
res.status(400).json({ success: false, error: 'suggestion.title is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!suggestion.category) {
|
||||
res.status(400).json({ success: false, error: 'suggestion.category is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Build description with rationale if provided
|
||||
const description = suggestion.rationale
|
||||
? `${suggestion.description}\n\n**Rationale:** ${suggestion.rationale}`
|
||||
: suggestion.description;
|
||||
|
||||
// Use the service's category mapping for consistency
|
||||
const featureCategory = ideationService.mapSuggestionCategoryToFeatureCategory(
|
||||
suggestion.category
|
||||
);
|
||||
|
||||
// Create the feature
|
||||
const feature = await featureLoader.create(projectPath, {
|
||||
title: suggestion.title,
|
||||
description,
|
||||
category: featureCategory,
|
||||
status: 'backlog',
|
||||
});
|
||||
|
||||
res.json({ success: true, featureId: feature.id });
|
||||
} catch (error) {
|
||||
logError(error, 'Add suggestion to board failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
49
apps/server/src/routes/ideation/routes/analyze.ts
Normal file
49
apps/server/src/routes/ideation/routes/analyze.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* POST /analyze - Analyze project and generate suggestions
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { IdeationService } from '../../../services/ideation-service.js';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createAnalyzeHandler(ideationService: IdeationService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath } = req.body as { projectPath: string };
|
||||
|
||||
if (!projectPath) {
|
||||
res.status(400).json({ success: false, error: 'projectPath is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Start analysis - results come via WebSocket events
|
||||
ideationService.analyzeProject(projectPath).catch((error) => {
|
||||
logError(error, 'Analyze project failed (async)');
|
||||
});
|
||||
|
||||
res.json({ success: true, message: 'Analysis started' });
|
||||
} catch (error) {
|
||||
logError(error, 'Analyze project failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function createGetAnalysisHandler(ideationService: IdeationService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath } = req.body as { projectPath: string };
|
||||
|
||||
if (!projectPath) {
|
||||
res.status(400).json({ success: false, error: 'projectPath is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await ideationService.getCachedAnalysis(projectPath);
|
||||
res.json({ success: true, result });
|
||||
} catch (error) {
|
||||
logError(error, 'Get analysis failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
77
apps/server/src/routes/ideation/routes/convert.ts
Normal file
77
apps/server/src/routes/ideation/routes/convert.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* POST /convert - Convert an idea to a feature
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { EventEmitter } from '../../../lib/events.js';
|
||||
import type { IdeationService } from '../../../services/ideation-service.js';
|
||||
import type { FeatureLoader } from '../../../services/feature-loader.js';
|
||||
import type { ConvertToFeatureOptions } from '@automaker/types';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createConvertHandler(
|
||||
events: EventEmitter,
|
||||
ideationService: IdeationService,
|
||||
featureLoader: FeatureLoader
|
||||
) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, ideaId, keepIdea, column, dependencies, tags } = req.body as {
|
||||
projectPath: string;
|
||||
ideaId: string;
|
||||
} & ConvertToFeatureOptions;
|
||||
|
||||
if (!projectPath) {
|
||||
res.status(400).json({ success: false, error: 'projectPath is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ideaId) {
|
||||
res.status(400).json({ success: false, error: 'ideaId is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Convert idea to feature structure
|
||||
const featureData = await ideationService.convertToFeature(projectPath, ideaId);
|
||||
|
||||
// Apply any options from the request
|
||||
if (column) {
|
||||
featureData.status = column;
|
||||
}
|
||||
if (dependencies && dependencies.length > 0) {
|
||||
featureData.dependencies = dependencies;
|
||||
}
|
||||
if (tags && tags.length > 0) {
|
||||
featureData.tags = tags;
|
||||
}
|
||||
|
||||
// Create the feature using FeatureLoader
|
||||
const feature = await featureLoader.create(projectPath, featureData);
|
||||
|
||||
// Delete the idea unless keepIdea is explicitly true
|
||||
if (!keepIdea) {
|
||||
await ideationService.deleteIdea(projectPath, ideaId);
|
||||
|
||||
// Emit idea deleted event
|
||||
events.emit('ideation:idea-deleted', {
|
||||
projectPath,
|
||||
ideaId,
|
||||
});
|
||||
}
|
||||
|
||||
// Emit idea converted event to notify frontend
|
||||
events.emit('ideation:idea-converted', {
|
||||
projectPath,
|
||||
ideaId,
|
||||
featureId: feature.id,
|
||||
keepIdea: !!keepIdea,
|
||||
});
|
||||
|
||||
// Return featureId as expected by the frontend API interface
|
||||
res.json({ success: true, featureId: feature.id });
|
||||
} catch (error) {
|
||||
logError(error, 'Convert to feature failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
51
apps/server/src/routes/ideation/routes/ideas-create.ts
Normal file
51
apps/server/src/routes/ideation/routes/ideas-create.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* POST /ideas/create - Create a new idea
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { EventEmitter } from '../../../lib/events.js';
|
||||
import type { IdeationService } from '../../../services/ideation-service.js';
|
||||
import type { CreateIdeaInput } from '@automaker/types';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createIdeasCreateHandler(events: EventEmitter, ideationService: IdeationService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, idea } = req.body as {
|
||||
projectPath: string;
|
||||
idea: CreateIdeaInput;
|
||||
};
|
||||
|
||||
if (!projectPath) {
|
||||
res.status(400).json({ success: false, error: 'projectPath is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!idea) {
|
||||
res.status(400).json({ success: false, error: 'idea is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!idea.title || !idea.description || !idea.category) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: 'idea must have title, description, and category',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const created = await ideationService.createIdea(projectPath, idea);
|
||||
|
||||
// Emit idea created event for frontend notification
|
||||
events.emit('ideation:idea-created', {
|
||||
projectPath,
|
||||
idea: created,
|
||||
});
|
||||
|
||||
res.json({ success: true, idea: created });
|
||||
} catch (error) {
|
||||
logError(error, 'Create idea failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
42
apps/server/src/routes/ideation/routes/ideas-delete.ts
Normal file
42
apps/server/src/routes/ideation/routes/ideas-delete.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* POST /ideas/delete - Delete an idea
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { EventEmitter } from '../../../lib/events.js';
|
||||
import type { IdeationService } from '../../../services/ideation-service.js';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createIdeasDeleteHandler(events: EventEmitter, ideationService: IdeationService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, ideaId } = req.body as {
|
||||
projectPath: string;
|
||||
ideaId: string;
|
||||
};
|
||||
|
||||
if (!projectPath) {
|
||||
res.status(400).json({ success: false, error: 'projectPath is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ideaId) {
|
||||
res.status(400).json({ success: false, error: 'ideaId is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
await ideationService.deleteIdea(projectPath, ideaId);
|
||||
|
||||
// Emit idea deleted event for frontend notification
|
||||
events.emit('ideation:idea-deleted', {
|
||||
projectPath,
|
||||
ideaId,
|
||||
});
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
logError(error, 'Delete idea failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
39
apps/server/src/routes/ideation/routes/ideas-get.ts
Normal file
39
apps/server/src/routes/ideation/routes/ideas-get.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* POST /ideas/get - Get a single idea
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { IdeationService } from '../../../services/ideation-service.js';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createIdeasGetHandler(ideationService: IdeationService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, ideaId } = req.body as {
|
||||
projectPath: string;
|
||||
ideaId: string;
|
||||
};
|
||||
|
||||
if (!projectPath) {
|
||||
res.status(400).json({ success: false, error: 'projectPath is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ideaId) {
|
||||
res.status(400).json({ success: false, error: 'ideaId is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const idea = await ideationService.getIdea(projectPath, ideaId);
|
||||
if (!idea) {
|
||||
res.status(404).json({ success: false, error: 'Idea not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ success: true, idea });
|
||||
} catch (error) {
|
||||
logError(error, 'Get idea failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
26
apps/server/src/routes/ideation/routes/ideas-list.ts
Normal file
26
apps/server/src/routes/ideation/routes/ideas-list.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* POST /ideas/list - List all ideas for a project
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { IdeationService } from '../../../services/ideation-service.js';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createIdeasListHandler(ideationService: IdeationService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath } = req.body as { projectPath: string };
|
||||
|
||||
if (!projectPath) {
|
||||
res.status(400).json({ success: false, error: 'projectPath is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const ideas = await ideationService.getIdeas(projectPath);
|
||||
res.json({ success: true, ideas });
|
||||
} catch (error) {
|
||||
logError(error, 'List ideas failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
54
apps/server/src/routes/ideation/routes/ideas-update.ts
Normal file
54
apps/server/src/routes/ideation/routes/ideas-update.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* POST /ideas/update - Update an idea
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { EventEmitter } from '../../../lib/events.js';
|
||||
import type { IdeationService } from '../../../services/ideation-service.js';
|
||||
import type { UpdateIdeaInput } from '@automaker/types';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createIdeasUpdateHandler(events: EventEmitter, ideationService: IdeationService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, ideaId, updates } = req.body as {
|
||||
projectPath: string;
|
||||
ideaId: string;
|
||||
updates: UpdateIdeaInput;
|
||||
};
|
||||
|
||||
if (!projectPath) {
|
||||
res.status(400).json({ success: false, error: 'projectPath is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ideaId) {
|
||||
res.status(400).json({ success: false, error: 'ideaId is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!updates) {
|
||||
res.status(400).json({ success: false, error: 'updates is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const idea = await ideationService.updateIdea(projectPath, ideaId, updates);
|
||||
if (!idea) {
|
||||
res.status(404).json({ success: false, error: 'Idea not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Emit idea updated event for frontend notification
|
||||
events.emit('ideation:idea-updated', {
|
||||
projectPath,
|
||||
ideaId,
|
||||
idea,
|
||||
});
|
||||
|
||||
res.json({ success: true, idea });
|
||||
} catch (error) {
|
||||
logError(error, 'Update idea failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
42
apps/server/src/routes/ideation/routes/prompts.ts
Normal file
42
apps/server/src/routes/ideation/routes/prompts.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* GET /prompts - Get all guided prompts
|
||||
* GET /prompts/:category - Get prompts for a specific category
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { IdeationService } from '../../../services/ideation-service.js';
|
||||
import type { IdeaCategory } from '@automaker/types';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createPromptsHandler(ideationService: IdeationService) {
|
||||
return async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const prompts = ideationService.getAllPrompts();
|
||||
const categories = ideationService.getPromptCategories();
|
||||
res.json({ success: true, prompts, categories });
|
||||
} catch (error) {
|
||||
logError(error, 'Get prompts failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function createPromptsByCategoryHandler(ideationService: IdeationService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { category } = req.params as { category: string };
|
||||
|
||||
const validCategories = ideationService.getPromptCategories().map((c) => c.id);
|
||||
if (!validCategories.includes(category as IdeaCategory)) {
|
||||
res.status(400).json({ success: false, error: 'Invalid category' });
|
||||
return;
|
||||
}
|
||||
|
||||
const prompts = ideationService.getPromptsByCategory(category as IdeaCategory);
|
||||
res.json({ success: true, prompts });
|
||||
} catch (error) {
|
||||
logError(error, 'Get prompts by category failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
45
apps/server/src/routes/ideation/routes/session-get.ts
Normal file
45
apps/server/src/routes/ideation/routes/session-get.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* POST /session/get - Get an ideation session with messages
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { IdeationService } from '../../../services/ideation-service.js';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createSessionGetHandler(ideationService: IdeationService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, sessionId } = req.body as {
|
||||
projectPath: string;
|
||||
sessionId: string;
|
||||
};
|
||||
|
||||
if (!projectPath) {
|
||||
res.status(400).json({ success: false, error: 'projectPath is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!sessionId) {
|
||||
res.status(400).json({ success: false, error: 'sessionId is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const session = await ideationService.getSession(projectPath, sessionId);
|
||||
if (!session) {
|
||||
res.status(404).json({ success: false, error: 'Session not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
const isRunning = ideationService.isSessionRunning(sessionId);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
session: { ...session, isRunning },
|
||||
messages: session.messages,
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Get session failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
40
apps/server/src/routes/ideation/routes/session-message.ts
Normal file
40
apps/server/src/routes/ideation/routes/session-message.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* POST /session/message - Send a message in an ideation session
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { IdeationService } from '../../../services/ideation-service.js';
|
||||
import type { SendMessageOptions } from '@automaker/types';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createSessionMessageHandler(ideationService: IdeationService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { sessionId, message, options } = req.body as {
|
||||
sessionId: string;
|
||||
message: string;
|
||||
options?: SendMessageOptions;
|
||||
};
|
||||
|
||||
if (!sessionId) {
|
||||
res.status(400).json({ success: false, error: 'sessionId is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!message) {
|
||||
res.status(400).json({ success: false, error: 'message is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
// This is async but we don't await - responses come via WebSocket
|
||||
ideationService.sendMessage(sessionId, message, options).catch((error) => {
|
||||
logError(error, 'Send message failed (async)');
|
||||
});
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
logError(error, 'Send message failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
30
apps/server/src/routes/ideation/routes/session-start.ts
Normal file
30
apps/server/src/routes/ideation/routes/session-start.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* POST /session/start - Start a new ideation session
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { IdeationService } from '../../../services/ideation-service.js';
|
||||
import type { StartSessionOptions } from '@automaker/types';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createSessionStartHandler(ideationService: IdeationService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, options } = req.body as {
|
||||
projectPath: string;
|
||||
options?: StartSessionOptions;
|
||||
};
|
||||
|
||||
if (!projectPath) {
|
||||
res.status(400).json({ success: false, error: 'projectPath is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const session = await ideationService.startSession(projectPath, options);
|
||||
res.json({ success: true, session });
|
||||
} catch (error) {
|
||||
logError(error, 'Start session failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
39
apps/server/src/routes/ideation/routes/session-stop.ts
Normal file
39
apps/server/src/routes/ideation/routes/session-stop.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* POST /session/stop - Stop an ideation session
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { EventEmitter } from '../../../lib/events.js';
|
||||
import type { IdeationService } from '../../../services/ideation-service.js';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
export function createSessionStopHandler(events: EventEmitter, ideationService: IdeationService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { sessionId, projectPath } = req.body as {
|
||||
sessionId: string;
|
||||
projectPath?: string;
|
||||
};
|
||||
|
||||
if (!sessionId) {
|
||||
res.status(400).json({ success: false, error: 'sessionId is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
await ideationService.stopSession(sessionId);
|
||||
|
||||
// Emit session stopped event for frontend notification
|
||||
// Note: The service also emits 'ideation:session-ended' internally,
|
||||
// but we emit here as well for route-level consistency with other routes
|
||||
events.emit('ideation:session-ended', {
|
||||
sessionId,
|
||||
projectPath,
|
||||
});
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
logError(error, 'Stop session failed');
|
||||
res.status(500).json({ success: false, error: getErrorMessage(error) });
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Generate suggestions route - Returns structured AI suggestions for a prompt
|
||||
*/
|
||||
|
||||
import type { Request, Response } from 'express';
|
||||
import type { IdeationService } from '../../../services/ideation-service.js';
|
||||
import { createLogger } from '@automaker/utils';
|
||||
import { getErrorMessage, logError } from '../common.js';
|
||||
|
||||
const logger = createLogger('ideation:suggestions-generate');
|
||||
|
||||
export function createSuggestionsGenerateHandler(ideationService: IdeationService) {
|
||||
return async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const { projectPath, promptId, category, count } = req.body;
|
||||
|
||||
if (!projectPath) {
|
||||
res.status(400).json({ success: false, error: 'projectPath is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!promptId) {
|
||||
res.status(400).json({ success: false, error: 'promptId is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!category) {
|
||||
res.status(400).json({ success: false, error: 'category is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Default to 10 suggestions, allow 1-20
|
||||
const suggestionCount = Math.min(Math.max(count || 10, 1), 20);
|
||||
|
||||
logger.info(`Generating ${suggestionCount} suggestions for prompt: ${promptId}`);
|
||||
|
||||
const suggestions = await ideationService.generateSuggestions(
|
||||
projectPath,
|
||||
promptId,
|
||||
category,
|
||||
suggestionCount
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
suggestions,
|
||||
});
|
||||
} catch (error) {
|
||||
logError(error, 'Failed to generate suggestions');
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -23,7 +23,6 @@ import {
|
||||
getEnableSandboxModeSetting,
|
||||
filterClaudeMdFromContext,
|
||||
getMCPServersFromSettings,
|
||||
getMCPPermissionSettings,
|
||||
getPromptCustomization,
|
||||
} from '../lib/settings-helpers.js';
|
||||
|
||||
@@ -242,9 +241,6 @@ export class AgentService {
|
||||
// Load MCP servers from settings (global setting only)
|
||||
const mcpServers = await getMCPServersFromSettings(this.settingsService, '[AgentService]');
|
||||
|
||||
// Load MCP permission settings (global setting only)
|
||||
const mcpPermissions = await getMCPPermissionSettings(this.settingsService, '[AgentService]');
|
||||
|
||||
// Load project context files (CLAUDE.md, CODE_QUALITY.md, etc.)
|
||||
const contextResult = await loadContextFiles({
|
||||
projectPath: effectiveWorkDir,
|
||||
@@ -274,8 +270,6 @@ export class AgentService {
|
||||
enableSandboxMode,
|
||||
thinkingLevel: effectiveThinkingLevel, // Pass thinking level for Claude models
|
||||
mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined,
|
||||
mcpAutoApproveTools: mcpPermissions.mcpAutoApproveTools,
|
||||
mcpUnrestrictedTools: mcpPermissions.mcpUnrestrictedTools,
|
||||
});
|
||||
|
||||
// Extract model, maxTurns, and allowedTools from SDK options
|
||||
@@ -300,8 +294,6 @@ export class AgentService {
|
||||
sandbox: sdkOptions.sandbox, // Pass sandbox configuration
|
||||
sdkSessionId: session.sdkSessionId, // Pass SDK session ID for resuming
|
||||
mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined, // Pass MCP servers configuration
|
||||
mcpAutoApproveTools: mcpPermissions.mcpAutoApproveTools, // Pass MCP auto-approve setting
|
||||
mcpUnrestrictedTools: mcpPermissions.mcpUnrestrictedTools, // Pass MCP unrestricted tools setting
|
||||
};
|
||||
|
||||
// Build prompt content with images
|
||||
|
||||
1722
apps/server/src/services/ideation-service.ts
Normal file
1722
apps/server/src/services/ideation-service.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { getMCPServersFromSettings, getMCPPermissionSettings } from '@/lib/settings-helpers.js';
|
||||
import { getMCPServersFromSettings } from '@/lib/settings-helpers.js';
|
||||
import type { SettingsService } from '@/services/settings-service.js';
|
||||
|
||||
// Mock the logger
|
||||
@@ -286,93 +286,4 @@ describe('settings-helpers.ts', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMCPPermissionSettings', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should return defaults when settingsService is null', async () => {
|
||||
const result = await getMCPPermissionSettings(null);
|
||||
expect(result).toEqual({
|
||||
mcpAutoApproveTools: true,
|
||||
mcpUnrestrictedTools: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return defaults when settingsService is undefined', async () => {
|
||||
const result = await getMCPPermissionSettings(undefined);
|
||||
expect(result).toEqual({
|
||||
mcpAutoApproveTools: true,
|
||||
mcpUnrestrictedTools: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return settings from service', async () => {
|
||||
const mockSettingsService = {
|
||||
getGlobalSettings: vi.fn().mockResolvedValue({
|
||||
mcpAutoApproveTools: false,
|
||||
mcpUnrestrictedTools: false,
|
||||
}),
|
||||
} as unknown as SettingsService;
|
||||
|
||||
const result = await getMCPPermissionSettings(mockSettingsService);
|
||||
expect(result).toEqual({
|
||||
mcpAutoApproveTools: false,
|
||||
mcpUnrestrictedTools: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should default to true when settings are undefined', async () => {
|
||||
const mockSettingsService = {
|
||||
getGlobalSettings: vi.fn().mockResolvedValue({}),
|
||||
} as unknown as SettingsService;
|
||||
|
||||
const result = await getMCPPermissionSettings(mockSettingsService);
|
||||
expect(result).toEqual({
|
||||
mcpAutoApproveTools: true,
|
||||
mcpUnrestrictedTools: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle mixed settings', async () => {
|
||||
const mockSettingsService = {
|
||||
getGlobalSettings: vi.fn().mockResolvedValue({
|
||||
mcpAutoApproveTools: true,
|
||||
mcpUnrestrictedTools: false,
|
||||
}),
|
||||
} as unknown as SettingsService;
|
||||
|
||||
const result = await getMCPPermissionSettings(mockSettingsService);
|
||||
expect(result).toEqual({
|
||||
mcpAutoApproveTools: true,
|
||||
mcpUnrestrictedTools: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return defaults and log error on exception', async () => {
|
||||
const mockSettingsService = {
|
||||
getGlobalSettings: vi.fn().mockRejectedValue(new Error('Settings error')),
|
||||
} as unknown as SettingsService;
|
||||
|
||||
const result = await getMCPPermissionSettings(mockSettingsService, '[Test]');
|
||||
expect(result).toEqual({
|
||||
mcpAutoApproveTools: true,
|
||||
mcpUnrestrictedTools: true,
|
||||
});
|
||||
// Logger will be called with error, but we don't need to assert it
|
||||
});
|
||||
|
||||
it('should use custom log prefix', async () => {
|
||||
const mockSettingsService = {
|
||||
getGlobalSettings: vi.fn().mockResolvedValue({
|
||||
mcpAutoApproveTools: true,
|
||||
mcpUnrestrictedTools: true,
|
||||
}),
|
||||
} as unknown as SettingsService;
|
||||
|
||||
await getMCPPermissionSettings(mockSettingsService, '[CustomPrefix]');
|
||||
// Logger will be called with custom prefix, but we don't need to assert it
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -73,7 +73,8 @@ describe('claude-provider.ts', () => {
|
||||
maxTurns: 10,
|
||||
cwd: '/test/dir',
|
||||
allowedTools: ['Read', 'Write'],
|
||||
permissionMode: 'default',
|
||||
permissionMode: 'bypassPermissions',
|
||||
allowDangerouslySkipPermissions: true,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
788
apps/server/tests/unit/services/ideation-service.test.ts
Normal file
788
apps/server/tests/unit/services/ideation-service.test.ts
Normal file
@@ -0,0 +1,788 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { IdeationService } from '@/services/ideation-service.js';
|
||||
import type { EventEmitter } from '@/lib/events.js';
|
||||
import type { SettingsService } from '@/services/settings-service.js';
|
||||
import type { FeatureLoader } from '@/services/feature-loader.js';
|
||||
import * as secureFs from '@/lib/secure-fs.js';
|
||||
import * as platform from '@automaker/platform';
|
||||
import * as utils from '@automaker/utils';
|
||||
import type {
|
||||
CreateIdeaInput,
|
||||
UpdateIdeaInput,
|
||||
Idea,
|
||||
IdeationSession,
|
||||
StartSessionOptions,
|
||||
} from '@automaker/types';
|
||||
import { ProviderFactory } from '@/providers/provider-factory.js';
|
||||
|
||||
// Create a shared mock logger instance for assertions using vi.hoisted
|
||||
const mockLogger = vi.hoisted(() => ({
|
||||
info: vi.fn(),
|
||||
error: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('@/lib/secure-fs.js');
|
||||
vi.mock('@automaker/platform');
|
||||
vi.mock('@automaker/utils', async () => {
|
||||
const actual = await vi.importActual<typeof import('@automaker/utils')>('@automaker/utils');
|
||||
return {
|
||||
...actual,
|
||||
createLogger: vi.fn(() => mockLogger),
|
||||
loadContextFiles: vi.fn(),
|
||||
isAbortError: vi.fn(),
|
||||
};
|
||||
});
|
||||
vi.mock('@/providers/provider-factory.js');
|
||||
vi.mock('@/lib/sdk-options.js', () => ({
|
||||
createChatOptions: vi.fn(() => ({
|
||||
model: 'claude-sonnet-4-20250514',
|
||||
systemPrompt: 'test prompt',
|
||||
})),
|
||||
validateWorkingDirectory: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('IdeationService', () => {
|
||||
let service: IdeationService;
|
||||
let mockEvents: EventEmitter;
|
||||
let mockSettingsService: SettingsService;
|
||||
let mockFeatureLoader: FeatureLoader;
|
||||
const testProjectPath = '/test/project';
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Create mock event emitter
|
||||
mockEvents = {
|
||||
emit: vi.fn(),
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
removeAllListeners: vi.fn(),
|
||||
} as unknown as EventEmitter;
|
||||
|
||||
// Create mock settings service
|
||||
mockSettingsService = {} as SettingsService;
|
||||
|
||||
// Create mock feature loader
|
||||
mockFeatureLoader = {
|
||||
getAll: vi.fn().mockResolvedValue([]),
|
||||
} as unknown as FeatureLoader;
|
||||
|
||||
// Mock platform functions
|
||||
vi.mocked(platform.ensureIdeationDir).mockResolvedValue(undefined);
|
||||
vi.mocked(platform.getIdeaDir).mockReturnValue(
|
||||
'/test/project/.automaker/ideation/ideas/idea-123'
|
||||
);
|
||||
vi.mocked(platform.getIdeaPath).mockReturnValue(
|
||||
'/test/project/.automaker/ideation/ideas/idea-123/idea.json'
|
||||
);
|
||||
vi.mocked(platform.getIdeasDir).mockReturnValue('/test/project/.automaker/ideation/ideas');
|
||||
vi.mocked(platform.getIdeationSessionPath).mockReturnValue(
|
||||
'/test/project/.automaker/ideation/sessions/session-123.json'
|
||||
);
|
||||
vi.mocked(platform.getIdeationSessionsDir).mockReturnValue(
|
||||
'/test/project/.automaker/ideation/sessions'
|
||||
);
|
||||
vi.mocked(platform.getIdeationAnalysisPath).mockReturnValue(
|
||||
'/test/project/.automaker/ideation/analysis.json'
|
||||
);
|
||||
|
||||
// Mock utils (already mocked above, but reset return values)
|
||||
vi.mocked(utils.loadContextFiles).mockResolvedValue({
|
||||
formattedPrompt: 'Test context',
|
||||
files: [],
|
||||
});
|
||||
vi.mocked(utils.isAbortError).mockReturnValue(false);
|
||||
|
||||
service = new IdeationService(mockEvents, mockSettingsService, mockFeatureLoader);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Session Management Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('Session Management', () => {
|
||||
describe('startSession', () => {
|
||||
it('should create a new session with default options', async () => {
|
||||
vi.mocked(secureFs.writeFile).mockResolvedValue(undefined);
|
||||
|
||||
const session = await service.startSession(testProjectPath);
|
||||
|
||||
expect(session).toBeDefined();
|
||||
expect(session.id).toMatch(/^session-/);
|
||||
expect(session.projectPath).toBe(testProjectPath);
|
||||
expect(session.status).toBe('active');
|
||||
expect(session.createdAt).toBeDefined();
|
||||
expect(session.updatedAt).toBeDefined();
|
||||
expect(platform.ensureIdeationDir).toHaveBeenCalledWith(testProjectPath);
|
||||
expect(secureFs.writeFile).toHaveBeenCalled();
|
||||
expect(mockEvents.emit).toHaveBeenCalledWith('ideation:session-started', {
|
||||
sessionId: session.id,
|
||||
projectPath: testProjectPath,
|
||||
});
|
||||
});
|
||||
|
||||
it('should create session with custom options', async () => {
|
||||
vi.mocked(secureFs.writeFile).mockResolvedValue(undefined);
|
||||
|
||||
const options: StartSessionOptions = {
|
||||
promptCategory: 'features',
|
||||
promptId: 'new-features',
|
||||
};
|
||||
|
||||
const session = await service.startSession(testProjectPath, options);
|
||||
|
||||
expect(session.promptCategory).toBe('features');
|
||||
expect(session.promptId).toBe('new-features');
|
||||
});
|
||||
|
||||
it('should send initial message if provided in options', async () => {
|
||||
vi.mocked(secureFs.writeFile).mockResolvedValue(undefined);
|
||||
vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify({ features: [] }));
|
||||
|
||||
// Mock provider
|
||||
const mockProvider = {
|
||||
executeQuery: vi.fn().mockReturnValue({
|
||||
async *[Symbol.asyncIterator]() {
|
||||
yield {
|
||||
type: 'result',
|
||||
subtype: 'success',
|
||||
result: 'AI response',
|
||||
};
|
||||
},
|
||||
}),
|
||||
};
|
||||
vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(mockProvider as any);
|
||||
|
||||
const options: StartSessionOptions = {
|
||||
initialMessage: 'Hello, AI!',
|
||||
};
|
||||
|
||||
await service.startSession(testProjectPath, options);
|
||||
|
||||
// Give time for the async message to process
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
expect(mockProvider.executeQuery).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSession', () => {
|
||||
it('should return null for non-existent session', async () => {
|
||||
vi.mocked(secureFs.readFile).mockRejectedValue(new Error('ENOENT'));
|
||||
|
||||
const result = await service.getSession(testProjectPath, 'non-existent');
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should return active session from memory', async () => {
|
||||
vi.mocked(secureFs.writeFile).mockResolvedValue(undefined);
|
||||
|
||||
const session = await service.startSession(testProjectPath);
|
||||
const retrieved = await service.getSession(testProjectPath, session.id);
|
||||
|
||||
expect(retrieved).toBeDefined();
|
||||
expect(retrieved?.id).toBe(session.id);
|
||||
expect(retrieved?.messages).toEqual([]);
|
||||
});
|
||||
|
||||
it('should load session from disk if not in memory', async () => {
|
||||
const mockSession: IdeationSession = {
|
||||
id: 'session-123',
|
||||
projectPath: testProjectPath,
|
||||
status: 'active',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
};
|
||||
|
||||
const sessionData = {
|
||||
session: mockSession,
|
||||
messages: [],
|
||||
};
|
||||
|
||||
vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify(sessionData));
|
||||
|
||||
const result = await service.getSession(testProjectPath, 'session-123');
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result?.id).toBe('session-123');
|
||||
});
|
||||
});
|
||||
|
||||
describe('stopSession', () => {
|
||||
it('should stop an active session', async () => {
|
||||
vi.mocked(secureFs.writeFile).mockResolvedValue(undefined);
|
||||
|
||||
const session = await service.startSession(testProjectPath);
|
||||
await service.stopSession(session.id);
|
||||
|
||||
expect(mockEvents.emit).toHaveBeenCalledWith('ideation:session-ended', {
|
||||
sessionId: session.id,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle stopping non-existent session gracefully', async () => {
|
||||
await expect(service.stopSession('non-existent')).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isSessionRunning', () => {
|
||||
it('should return false for non-existent session', () => {
|
||||
expect(service.isSessionRunning('non-existent')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for idle session', async () => {
|
||||
vi.mocked(secureFs.writeFile).mockResolvedValue(undefined);
|
||||
|
||||
const session = await service.startSession(testProjectPath);
|
||||
expect(service.isSessionRunning(session.id)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Ideas CRUD Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('Ideas CRUD', () => {
|
||||
describe('createIdea', () => {
|
||||
it('should create a new idea with required fields', async () => {
|
||||
vi.mocked(secureFs.mkdir).mockResolvedValue(undefined);
|
||||
vi.mocked(secureFs.writeFile).mockResolvedValue(undefined);
|
||||
|
||||
const input: CreateIdeaInput = {
|
||||
title: 'Test Idea',
|
||||
description: 'This is a test idea',
|
||||
category: 'features',
|
||||
};
|
||||
|
||||
const idea = await service.createIdea(testProjectPath, input);
|
||||
|
||||
expect(idea).toBeDefined();
|
||||
expect(idea.id).toMatch(/^idea-/);
|
||||
expect(idea.title).toBe('Test Idea');
|
||||
expect(idea.description).toBe('This is a test idea');
|
||||
expect(idea.category).toBe('features');
|
||||
expect(idea.status).toBe('raw');
|
||||
expect(idea.impact).toBe('medium');
|
||||
expect(idea.effort).toBe('medium');
|
||||
expect(secureFs.mkdir).toHaveBeenCalled();
|
||||
expect(secureFs.writeFile).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should create idea with all optional fields', async () => {
|
||||
vi.mocked(secureFs.mkdir).mockResolvedValue(undefined);
|
||||
vi.mocked(secureFs.writeFile).mockResolvedValue(undefined);
|
||||
|
||||
const input: CreateIdeaInput = {
|
||||
title: 'Full Idea',
|
||||
description: 'Complete idea',
|
||||
category: 'features',
|
||||
status: 'refined',
|
||||
impact: 'high',
|
||||
effort: 'low',
|
||||
conversationId: 'conv-123',
|
||||
sourcePromptId: 'prompt-123',
|
||||
userStories: ['Story 1', 'Story 2'],
|
||||
notes: 'Additional notes',
|
||||
};
|
||||
|
||||
const idea = await service.createIdea(testProjectPath, input);
|
||||
|
||||
expect(idea.status).toBe('refined');
|
||||
expect(idea.impact).toBe('high');
|
||||
expect(idea.effort).toBe('low');
|
||||
expect(idea.conversationId).toBe('conv-123');
|
||||
expect(idea.sourcePromptId).toBe('prompt-123');
|
||||
expect(idea.userStories).toEqual(['Story 1', 'Story 2']);
|
||||
expect(idea.notes).toBe('Additional notes');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getIdeas', () => {
|
||||
it('should return empty array when ideas directory does not exist', async () => {
|
||||
vi.mocked(secureFs.access).mockRejectedValue(new Error('ENOENT'));
|
||||
|
||||
const ideas = await service.getIdeas(testProjectPath);
|
||||
|
||||
expect(ideas).toEqual([]);
|
||||
});
|
||||
|
||||
it('should load all ideas from disk', async () => {
|
||||
vi.mocked(secureFs.access).mockResolvedValue(undefined);
|
||||
vi.mocked(secureFs.readdir).mockResolvedValue([
|
||||
{ name: 'idea-1', isDirectory: () => true } as any,
|
||||
{ name: 'idea-2', isDirectory: () => true } as any,
|
||||
]);
|
||||
|
||||
const idea1: Idea = {
|
||||
id: 'idea-1',
|
||||
title: 'Idea 1',
|
||||
description: 'First idea',
|
||||
category: 'features',
|
||||
status: 'raw',
|
||||
impact: 'medium',
|
||||
effort: 'medium',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
};
|
||||
|
||||
const idea2: Idea = {
|
||||
id: 'idea-2',
|
||||
title: 'Idea 2',
|
||||
description: 'Second idea',
|
||||
category: 'bugs',
|
||||
status: 'refined',
|
||||
impact: 'high',
|
||||
effort: 'low',
|
||||
createdAt: '2024-01-02T00:00:00.000Z',
|
||||
updatedAt: '2024-01-02T00:00:00.000Z',
|
||||
};
|
||||
|
||||
vi.mocked(secureFs.readFile)
|
||||
.mockResolvedValueOnce(JSON.stringify(idea1))
|
||||
.mockResolvedValueOnce(JSON.stringify(idea2));
|
||||
|
||||
const ideas = await service.getIdeas(testProjectPath);
|
||||
|
||||
expect(ideas).toHaveLength(2);
|
||||
expect(ideas[0].id).toBe('idea-2'); // Sorted by updatedAt descending
|
||||
expect(ideas[1].id).toBe('idea-1');
|
||||
});
|
||||
|
||||
it('should skip invalid idea files', async () => {
|
||||
vi.mocked(secureFs.access).mockResolvedValue(undefined);
|
||||
vi.mocked(secureFs.readdir).mockResolvedValue([
|
||||
{ name: 'idea-1', isDirectory: () => true } as any,
|
||||
{ name: 'idea-2', isDirectory: () => true } as any,
|
||||
]);
|
||||
|
||||
const validIdea: Idea = {
|
||||
id: 'idea-1',
|
||||
title: 'Valid Idea',
|
||||
description: 'Valid',
|
||||
category: 'features',
|
||||
status: 'raw',
|
||||
impact: 'medium',
|
||||
effort: 'medium',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
};
|
||||
|
||||
vi.mocked(secureFs.readFile)
|
||||
.mockResolvedValueOnce(JSON.stringify(validIdea))
|
||||
.mockRejectedValueOnce(new Error('Invalid JSON'));
|
||||
|
||||
const ideas = await service.getIdeas(testProjectPath);
|
||||
|
||||
expect(ideas).toHaveLength(1);
|
||||
expect(ideas[0].id).toBe('idea-1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getIdea', () => {
|
||||
it('should return idea by id', async () => {
|
||||
const mockIdea: Idea = {
|
||||
id: 'idea-123',
|
||||
title: 'Test Idea',
|
||||
description: 'Test',
|
||||
category: 'features',
|
||||
status: 'raw',
|
||||
impact: 'medium',
|
||||
effort: 'medium',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
};
|
||||
|
||||
vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify(mockIdea));
|
||||
|
||||
const idea = await service.getIdea(testProjectPath, 'idea-123');
|
||||
|
||||
expect(idea).toBeDefined();
|
||||
expect(idea?.id).toBe('idea-123');
|
||||
});
|
||||
|
||||
it('should return null for non-existent idea', async () => {
|
||||
vi.mocked(secureFs.readFile).mockRejectedValue(new Error('ENOENT'));
|
||||
|
||||
const idea = await service.getIdea(testProjectPath, 'non-existent');
|
||||
|
||||
expect(idea).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateIdea', () => {
|
||||
it('should update idea fields', async () => {
|
||||
const existingIdea: Idea = {
|
||||
id: 'idea-123',
|
||||
title: 'Original Title',
|
||||
description: 'Original',
|
||||
category: 'features',
|
||||
status: 'raw',
|
||||
impact: 'medium',
|
||||
effort: 'medium',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
};
|
||||
|
||||
vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify(existingIdea));
|
||||
vi.mocked(secureFs.writeFile).mockResolvedValue(undefined);
|
||||
|
||||
const updates: UpdateIdeaInput = {
|
||||
title: 'Updated Title',
|
||||
status: 'refined',
|
||||
};
|
||||
|
||||
const updated = await service.updateIdea(testProjectPath, 'idea-123', updates);
|
||||
|
||||
expect(updated).toBeDefined();
|
||||
expect(updated?.title).toBe('Updated Title');
|
||||
expect(updated?.status).toBe('refined');
|
||||
expect(updated?.description).toBe('Original'); // Unchanged
|
||||
expect(updated?.updatedAt).not.toBe('2024-01-01T00:00:00.000Z'); // Should be updated
|
||||
});
|
||||
|
||||
it('should return null for non-existent idea', async () => {
|
||||
vi.mocked(secureFs.readFile).mockRejectedValue(new Error('ENOENT'));
|
||||
|
||||
const updated = await service.updateIdea(testProjectPath, 'non-existent', {
|
||||
title: 'New Title',
|
||||
});
|
||||
|
||||
expect(updated).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteIdea', () => {
|
||||
it('should delete idea directory', async () => {
|
||||
vi.mocked(secureFs.rm).mockResolvedValue(undefined);
|
||||
|
||||
await service.deleteIdea(testProjectPath, 'idea-123');
|
||||
|
||||
expect(secureFs.rm).toHaveBeenCalledWith(
|
||||
expect.stringContaining('idea-123'),
|
||||
expect.objectContaining({ recursive: true })
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle non-existent idea gracefully', async () => {
|
||||
vi.mocked(secureFs.rm).mockRejectedValue(new Error('ENOENT'));
|
||||
|
||||
await expect(service.deleteIdea(testProjectPath, 'non-existent')).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('archiveIdea', () => {
|
||||
it('should set idea status to archived', async () => {
|
||||
const existingIdea: Idea = {
|
||||
id: 'idea-123',
|
||||
title: 'Test',
|
||||
description: 'Test',
|
||||
category: 'features',
|
||||
status: 'raw',
|
||||
impact: 'medium',
|
||||
effort: 'medium',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
};
|
||||
|
||||
vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify(existingIdea));
|
||||
vi.mocked(secureFs.writeFile).mockResolvedValue(undefined);
|
||||
|
||||
const archived = await service.archiveIdea(testProjectPath, 'idea-123');
|
||||
|
||||
expect(archived).toBeDefined();
|
||||
expect(archived?.status).toBe('archived');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Conversion Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('Idea to Feature Conversion', () => {
|
||||
describe('convertToFeature', () => {
|
||||
it('should convert idea to feature with basic fields', async () => {
|
||||
const mockIdea: Idea = {
|
||||
id: 'idea-123',
|
||||
title: 'Add Dark Mode',
|
||||
description: 'Implement dark mode theme',
|
||||
category: 'feature',
|
||||
status: 'refined',
|
||||
impact: 'high',
|
||||
effort: 'medium',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
};
|
||||
|
||||
vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify(mockIdea));
|
||||
|
||||
const feature = await service.convertToFeature(testProjectPath, 'idea-123');
|
||||
|
||||
expect(feature).toBeDefined();
|
||||
expect(feature.id).toMatch(/^feature-/);
|
||||
expect(feature.title).toBe('Add Dark Mode');
|
||||
expect(feature.description).toBe('Implement dark mode theme');
|
||||
expect(feature.category).toBe('ui'); // features -> ui mapping
|
||||
expect(feature.status).toBe('backlog');
|
||||
});
|
||||
|
||||
it('should include user stories in feature description', async () => {
|
||||
const mockIdea: Idea = {
|
||||
id: 'idea-123',
|
||||
title: 'Test',
|
||||
description: 'Base description',
|
||||
category: 'features',
|
||||
status: 'refined',
|
||||
impact: 'medium',
|
||||
effort: 'medium',
|
||||
userStories: ['As a user, I want X', 'As a user, I want Y'],
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
};
|
||||
|
||||
vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify(mockIdea));
|
||||
|
||||
const feature = await service.convertToFeature(testProjectPath, 'idea-123');
|
||||
|
||||
expect(feature.description).toContain('Base description');
|
||||
expect(feature.description).toContain('## User Stories');
|
||||
expect(feature.description).toContain('As a user, I want X');
|
||||
expect(feature.description).toContain('As a user, I want Y');
|
||||
});
|
||||
|
||||
it('should include notes in feature description', async () => {
|
||||
const mockIdea: Idea = {
|
||||
id: 'idea-123',
|
||||
title: 'Test',
|
||||
description: 'Base description',
|
||||
category: 'features',
|
||||
status: 'refined',
|
||||
impact: 'medium',
|
||||
effort: 'medium',
|
||||
notes: 'Important implementation notes',
|
||||
createdAt: '2024-01-01T00:00:00.000Z',
|
||||
updatedAt: '2024-01-01T00:00:00.000Z',
|
||||
};
|
||||
|
||||
vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify(mockIdea));
|
||||
|
||||
const feature = await service.convertToFeature(testProjectPath, 'idea-123');
|
||||
|
||||
expect(feature.description).toContain('Base description');
|
||||
expect(feature.description).toContain('## Notes');
|
||||
expect(feature.description).toContain('Important implementation notes');
|
||||
});
|
||||
|
||||
it('should throw error for non-existent idea', async () => {
|
||||
vi.mocked(secureFs.readFile).mockRejectedValue(new Error('ENOENT'));
|
||||
|
||||
await expect(service.convertToFeature(testProjectPath, 'non-existent')).rejects.toThrow(
|
||||
'Idea non-existent not found'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Project Analysis Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('Project Analysis', () => {
|
||||
describe('analyzeProject', () => {
|
||||
it('should analyze project and generate suggestions', async () => {
|
||||
vi.mocked(secureFs.readFile).mockResolvedValue(
|
||||
JSON.stringify({
|
||||
name: 'test-project',
|
||||
dependencies: {},
|
||||
})
|
||||
);
|
||||
vi.mocked(secureFs.writeFile).mockResolvedValue(undefined);
|
||||
vi.mocked(secureFs.access).mockResolvedValue(undefined);
|
||||
vi.mocked(secureFs.readdir).mockResolvedValue([]);
|
||||
|
||||
const result = await service.analyzeProject(testProjectPath);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.projectPath).toBe(testProjectPath);
|
||||
expect(result.analyzedAt).toBeDefined();
|
||||
expect(result.suggestions).toBeDefined();
|
||||
expect(Array.isArray(result.suggestions)).toBe(true);
|
||||
expect(mockEvents.emit).toHaveBeenCalledWith(
|
||||
'ideation:analysis',
|
||||
expect.objectContaining({
|
||||
type: 'ideation:analysis-started',
|
||||
})
|
||||
);
|
||||
expect(mockEvents.emit).toHaveBeenCalledWith(
|
||||
'ideation:analysis',
|
||||
expect.objectContaining({
|
||||
type: 'ideation:analysis-complete',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should emit error event on failure', async () => {
|
||||
// Mock writeFile to fail (this is called after gatherProjectStructure and isn't caught)
|
||||
vi.mocked(secureFs.readFile).mockResolvedValue(
|
||||
JSON.stringify({
|
||||
name: 'test-project',
|
||||
dependencies: {},
|
||||
})
|
||||
);
|
||||
vi.mocked(secureFs.writeFile).mockRejectedValue(new Error('Write failed'));
|
||||
|
||||
await expect(service.analyzeProject(testProjectPath)).rejects.toThrow();
|
||||
|
||||
expect(mockEvents.emit).toHaveBeenCalledWith(
|
||||
'ideation:analysis',
|
||||
expect.objectContaining({
|
||||
type: 'ideation:analysis-error',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCachedAnalysis', () => {
|
||||
it('should return cached analysis if exists', async () => {
|
||||
const mockAnalysis = {
|
||||
projectPath: testProjectPath,
|
||||
analyzedAt: '2024-01-01T00:00:00.000Z',
|
||||
totalFiles: 10,
|
||||
suggestions: [],
|
||||
summary: 'Test summary',
|
||||
};
|
||||
|
||||
vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify(mockAnalysis));
|
||||
|
||||
const result = await service.getCachedAnalysis(testProjectPath);
|
||||
|
||||
expect(result).toEqual(mockAnalysis);
|
||||
});
|
||||
|
||||
it('should return null if cache does not exist', async () => {
|
||||
vi.mocked(secureFs.readFile).mockRejectedValue(new Error('ENOENT'));
|
||||
|
||||
const result = await service.getCachedAnalysis(testProjectPath);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Prompt Management Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('Prompt Management', () => {
|
||||
describe('getPromptCategories', () => {
|
||||
it('should return list of prompt categories', () => {
|
||||
const categories = service.getPromptCategories();
|
||||
|
||||
expect(Array.isArray(categories)).toBe(true);
|
||||
expect(categories.length).toBeGreaterThan(0);
|
||||
expect(categories[0]).toHaveProperty('id');
|
||||
expect(categories[0]).toHaveProperty('name');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAllPrompts', () => {
|
||||
it('should return all guided prompts', () => {
|
||||
const prompts = service.getAllPrompts();
|
||||
|
||||
expect(Array.isArray(prompts)).toBe(true);
|
||||
expect(prompts.length).toBeGreaterThan(0);
|
||||
expect(prompts[0]).toHaveProperty('id');
|
||||
expect(prompts[0]).toHaveProperty('category');
|
||||
expect(prompts[0]).toHaveProperty('title');
|
||||
expect(prompts[0]).toHaveProperty('prompt');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPromptsByCategory', () => {
|
||||
it('should return prompts filtered by category', () => {
|
||||
const allPrompts = service.getAllPrompts();
|
||||
const firstCategory = allPrompts[0].category;
|
||||
|
||||
const filtered = service.getPromptsByCategory(firstCategory);
|
||||
|
||||
expect(Array.isArray(filtered)).toBe(true);
|
||||
filtered.forEach((prompt) => {
|
||||
expect(prompt.category).toBe(firstCategory);
|
||||
});
|
||||
});
|
||||
|
||||
it('should return empty array for non-existent category', () => {
|
||||
const filtered = service.getPromptsByCategory('non-existent-category' as any);
|
||||
|
||||
expect(filtered).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Suggestions Generation Tests
|
||||
// ============================================================================
|
||||
|
||||
describe('Suggestion Generation', () => {
|
||||
describe('generateSuggestions', () => {
|
||||
it('should generate suggestions for a prompt', async () => {
|
||||
vi.mocked(secureFs.readFile).mockResolvedValue(JSON.stringify({}));
|
||||
|
||||
const mockProvider = {
|
||||
executeQuery: vi.fn().mockReturnValue({
|
||||
async *[Symbol.asyncIterator]() {
|
||||
yield {
|
||||
type: 'result',
|
||||
subtype: 'success',
|
||||
result: JSON.stringify([
|
||||
{
|
||||
title: 'Add user authentication',
|
||||
description: 'Implement auth',
|
||||
category: 'security',
|
||||
impact: 'high',
|
||||
effort: 'high',
|
||||
},
|
||||
]),
|
||||
};
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(mockProvider as any);
|
||||
|
||||
const prompts = service.getAllPrompts();
|
||||
const firstPrompt = prompts[0];
|
||||
|
||||
const suggestions = await service.generateSuggestions(
|
||||
testProjectPath,
|
||||
firstPrompt.id,
|
||||
'features',
|
||||
5
|
||||
);
|
||||
|
||||
expect(Array.isArray(suggestions)).toBe(true);
|
||||
expect(mockEvents.emit).toHaveBeenCalledWith(
|
||||
'ideation:suggestions',
|
||||
expect.objectContaining({
|
||||
type: 'started',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error for non-existent prompt', async () => {
|
||||
await expect(
|
||||
service.generateSuggestions(testProjectPath, 'non-existent', 'features', 5)
|
||||
).rejects.toThrow('Prompt non-existent not found');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user