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:
Kacper
2026-01-04 13:12:45 +01:00
81 changed files with 6933 additions and 1958 deletions

View File

@@ -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);

View File

@@ -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.

View File

@@ -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

View File

@@ -96,7 +96,7 @@ export function createGenerateTitleHandler(): (req: Request, res: Response) => P
systemPrompt: SYSTEM_PROMPT,
maxTurns: 1,
allowedTools: [],
permissionMode: 'acceptEdits',
permissionMode: 'default',
},
});

View 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);

View 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;
}

View 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) });
}
};
}

View 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) });
}
};
}

View 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) });
}
};
}

View 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) });
}
};
}

View 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) });
}
};
}

View 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) });
}
};
}

View 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) });
}
};
}

View 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) });
}
};
}

View 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) });
}
};
}

View 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) });
}
};
}

View 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) });
}
};
}

View 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) });
}
};
}

View 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) });
}
};
}

View File

@@ -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),
});
}
};
}

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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
});
});
});

View File

@@ -73,7 +73,8 @@ describe('claude-provider.ts', () => {
maxTurns: 10,
cwd: '/test/dir',
allowedTools: ['Read', 'Write'],
permissionMode: 'default',
permissionMode: 'bypassPermissions',
allowDangerouslySkipPermissions: true,
}),
});
});

View 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');
});
});
});
});