diff --git a/.geminiignore b/.geminiignore new file mode 100644 index 000000000..703ef0201 --- /dev/null +++ b/.geminiignore @@ -0,0 +1,14 @@ +# Auto-generated by Automaker to speed up Gemini CLI startup +# Prevents Gemini CLI from scanning large directories during context discovery +.git +node_modules +dist +build +.next +.nuxt +coverage +.automaker +.worktrees +.vscode +.idea +*.lock diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index 3674111f6..4f682a0bd 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -13,6 +13,13 @@ jobs: e2e: runs-on: ubuntu-latest timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + # shardIndex: [1, 2, 3] + # shardTotal: [3] + shardIndex: [1] + shardTotal: [1] steps: - name: Checkout code @@ -91,7 +98,7 @@ jobs: curl -s http://localhost:3108/api/health | jq . 2>/dev/null || echo "Health check: $(curl -s http://localhost:3108/api/health 2>/dev/null || echo 'No response')" exit 0 fi - + # Check if server process is still running if ! kill -0 $SERVER_PID 2>/dev/null; then echo "ERROR: Server process died during wait!" @@ -99,7 +106,7 @@ jobs: cat backend.log exit 1 fi - + echo "Waiting... ($i/60)" sleep 1 done @@ -127,17 +134,23 @@ jobs: exit 1 - - name: Run E2E tests + - name: Run E2E tests (shard ${{ matrix.shardIndex }}/${{ matrix.shardTotal }}) # Playwright automatically starts the Vite frontend via webServer config # (see apps/ui/playwright.config.ts) - no need to start it manually - run: npm run test --workspace=apps/ui + run: npx playwright test --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} + working-directory: apps/ui env: CI: true - VITE_SERVER_URL: http://localhost:3108 - SERVER_URL: http://localhost:3108 VITE_SKIP_SETUP: 'true' # Keep UI-side login/defaults consistent AUTOMAKER_API_KEY: test-api-key-for-e2e-tests + # Backend is already started above - Playwright config sets + # AUTOMAKER_SERVER_PORT so the Vite proxy forwards /api/* to the backend. + # Do NOT set VITE_SERVER_URL here: it bypasses the Vite proxy and causes + # a cookie domain mismatch (cookies are bound to 127.0.0.1, but + # VITE_SERVER_URL=http://localhost:3108 makes the frontend call localhost). + TEST_USE_EXTERNAL_BACKEND: 'true' + TEST_SERVER_PORT: 3108 - name: Print backend logs on failure if: failure() @@ -155,7 +168,7 @@ jobs: uses: actions/upload-artifact@v4 if: always() with: - name: playwright-report + name: playwright-report-shard-${{ matrix.shardIndex }}-of-${{ matrix.shardTotal }} path: apps/ui/playwright-report/ retention-days: 7 @@ -163,12 +176,21 @@ jobs: uses: actions/upload-artifact@v4 if: always() with: - name: test-results + name: test-results-shard-${{ matrix.shardIndex }}-of-${{ matrix.shardTotal }} path: | apps/ui/test-results/ retention-days: 7 if-no-files-found: ignore + - name: Upload blob report for merging + uses: actions/upload-artifact@v4 + if: always() + with: + name: blob-report-shard-${{ matrix.shardIndex }}-of-${{ matrix.shardTotal }} + path: apps/ui/blob-report/ + retention-days: 1 + if-no-files-found: ignore + - name: Cleanup - Kill backend server if: always() run: | diff --git a/.gitignore b/.gitignore index dc7e2fd25..e9cb3275e 100644 --- a/.gitignore +++ b/.gitignore @@ -65,6 +65,17 @@ coverage/ *.lcov playwright-report/ blob-report/ +test/**/test-project-[0-9]*/ +test/opus-thinking-*/ +test/agent-session-test-*/ +test/feature-backlog-test-*/ +test/running-task-display-test-*/ +test/agent-output-modal-responsive-*/ +test/fixtures/ +test/board-bg-test-*/ +test/edit-feature-test-*/ +test/open-project-test-*/ + # Environment files (keep .example) .env diff --git a/apps/server/package.json b/apps/server/package.json index 75818b182..8fc0f5ded 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -1,6 +1,6 @@ { "name": "@automaker/server", - "version": "0.15.0", + "version": "1.0.0", "description": "Backend server for Automaker - provides API for both web and Electron modes", "author": "AutoMaker Team", "license": "SEE LICENSE IN LICENSE", diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index dcd45da80..2b48f662f 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -261,7 +261,10 @@ morgan.token('status-colored', (_req, res) => { app.use( morgan(':method :url :status-colored', { // Skip when request logging is disabled or for health check endpoints - skip: (req) => !requestLoggingEnabled || req.url === '/api/health', + skip: (req) => + !requestLoggingEnabled || + req.url === '/api/health' || + req.url === '/api/auto-mode/context-exists', }) ); // CORS configuration @@ -349,7 +352,9 @@ const ideationService = new IdeationService(events, settingsService, featureLoad // Initialize DevServerService with event emitter for real-time log streaming const devServerService = getDevServerService(); -devServerService.setEventEmitter(events); +devServerService.initialize(DATA_DIR, events).catch((err) => { + logger.error('Failed to initialize DevServerService:', err); +}); // Initialize Notification Service with event emitter for real-time updates const notificationService = getNotificationService(); @@ -434,21 +439,18 @@ eventHookService.initialize(events, settingsService, eventHistoryService, featur logger.info('[STARTUP] Feature state reconciliation complete - no stale states found'); } - // Resume interrupted features in the background after reconciliation. - // This uses the saved execution state to identify features that were running - // before the restart (their statuses have been reset to ready/backlog by - // reconciliation above). Running in background so it doesn't block startup. - if (totalReconciled > 0) { - for (const project of globalSettings.projects) { - autoModeService.resumeInterruptedFeatures(project.path).catch((err) => { - logger.warn( - `[STARTUP] Failed to resume interrupted features for ${project.path}:`, - err - ); - }); - } - logger.info('[STARTUP] Initiated background resume of interrupted features'); + // Resume interrupted features in the background for all projects. + // This handles features stuck in transient states (in_progress, pipeline_*) + // or explicitly marked as interrupted. Running in background so it doesn't block startup. + for (const project of globalSettings.projects) { + autoModeService.resumeInterruptedFeatures(project.path).catch((err) => { + logger.warn( + `[STARTUP] Failed to resume interrupted features for ${project.path}:`, + err + ); + }); } + logger.info('[STARTUP] Initiated background resume of interrupted features'); } } catch (err) { logger.warn('[STARTUP] Failed to reconcile feature states:', err); @@ -494,7 +496,7 @@ app.use( ); app.use('/api/auto-mode', createAutoModeRoutes(autoModeService)); app.use('/api/enhance-prompt', createEnhancePromptRoutes(settingsService)); -app.use('/api/worktree', createWorktreeRoutes(events, settingsService)); +app.use('/api/worktree', createWorktreeRoutes(events, settingsService, featureLoader)); app.use('/api/git', createGitRoutes()); app.use('/api/models', createModelsRoutes()); app.use('/api/spec-regeneration', createSpecRegenerationRoutes(events, settingsService)); diff --git a/apps/server/src/lib/git.ts b/apps/server/src/lib/git.ts index 697d532df..d60ccadea 100644 --- a/apps/server/src/lib/git.ts +++ b/apps/server/src/lib/git.ts @@ -13,6 +13,27 @@ import { createLogger } from '@automaker/utils'; const logger = createLogger('GitLib'); +// Extended PATH so git is found when the process does not inherit a full shell PATH +// (e.g. Electron, some CI, or IDE-launched processes). +const pathSeparator = process.platform === 'win32' ? ';' : ':'; +const extraPaths: string[] = + process.platform === 'win32' + ? ([ + process.env.LOCALAPPDATA && `${process.env.LOCALAPPDATA}\\Programs\\Git\\cmd`, + process.env.PROGRAMFILES && `${process.env.PROGRAMFILES}\\Git\\cmd`, + process.env['ProgramFiles(x86)'] && `${process.env['ProgramFiles(x86)']}\\Git\\cmd`, + ].filter(Boolean) as string[]) + : [ + '/opt/homebrew/bin', + '/usr/local/bin', + '/usr/bin', + '/home/linuxbrew/.linuxbrew/bin', + process.env.HOME ? `${process.env.HOME}/.local/bin` : '', + ].filter(Boolean); + +const extendedPath = [process.env.PATH, ...extraPaths].filter(Boolean).join(pathSeparator); +const gitEnv = { ...process.env, PATH: extendedPath }; + // ============================================================================ // Secure Command Execution // ============================================================================ @@ -65,7 +86,14 @@ export async function execGitCommand( command: 'git', args, cwd, - ...(env !== undefined ? { env } : {}), + env: + env !== undefined + ? { + ...gitEnv, + ...env, + PATH: [gitEnv.PATH, env.PATH].filter(Boolean).join(pathSeparator), + } + : gitEnv, ...(abortController !== undefined ? { abortController } : {}), }); diff --git a/apps/server/src/lib/settings-helpers.ts b/apps/server/src/lib/settings-helpers.ts index 48c06383d..66db5b1ac 100644 --- a/apps/server/src/lib/settings-helpers.ts +++ b/apps/server/src/lib/settings-helpers.ts @@ -689,6 +689,145 @@ export interface ProviderByModelIdResult { resolvedModel: string | undefined; } +/** Result from resolveProviderContext */ +export interface ProviderContextResult { + /** The provider configuration */ + provider: ClaudeCompatibleProvider | undefined; + /** Credentials for API key resolution */ + credentials: Credentials | undefined; + /** The resolved Claude model ID for SDK configuration */ + resolvedModel: string | undefined; + /** The original model config from the provider if found */ + modelConfig: import('@automaker/types').ProviderModel | undefined; +} + +/** + * Checks if a provider is enabled. + * Providers with enabled: undefined are treated as enabled (default state). + * Only explicitly set enabled: false means the provider is disabled. + */ +function isProviderEnabled(provider: ClaudeCompatibleProvider): boolean { + return provider.enabled !== false; +} + +/** + * Finds a model config in a provider's models array by ID (case-insensitive). + */ +function findModelInProvider( + provider: ClaudeCompatibleProvider, + modelId: string +): import('@automaker/types').ProviderModel | undefined { + return provider.models?.find( + (m) => m.id === modelId || m.id.toLowerCase() === modelId.toLowerCase() + ); +} + +/** + * Resolves the provider and Claude-compatible model configuration. + * + * This is the central logic for resolving provider context, supporting: + * 1. Explicit lookup by providerId (most reliable for persistence) + * 2. Fallback lookup by modelId across all enabled providers + * 3. Resolution of mapsToClaudeModel for SDK configuration + * + * @param settingsService - Settings service instance + * @param modelId - The model ID to resolve + * @param providerId - Optional explicit provider ID + * @param logPrefix - Prefix for log messages + * @returns Promise resolving to the provider context + */ +export async function resolveProviderContext( + settingsService: SettingsService, + modelId: string, + providerId?: string, + logPrefix = '[SettingsHelper]' +): Promise { + try { + const globalSettings = await settingsService.getGlobalSettings(); + const credentials = await settingsService.getCredentials(); + const providers = globalSettings.claudeCompatibleProviders || []; + + logger.debug( + `${logPrefix} Resolving provider context: modelId="${modelId}", providerId="${providerId ?? 'none'}", providers count=${providers.length}` + ); + + let provider: ClaudeCompatibleProvider | undefined; + let modelConfig: import('@automaker/types').ProviderModel | undefined; + + // 1. Try resolving by explicit providerId first (most reliable) + if (providerId) { + provider = providers.find((p) => p.id === providerId); + if (provider) { + if (!isProviderEnabled(provider)) { + logger.warn( + `${logPrefix} Explicitly requested provider "${provider.name}" (${providerId}) is disabled (enabled=${provider.enabled})` + ); + } else { + logger.debug( + `${logPrefix} Found provider "${provider.name}" (${providerId}), enabled=${provider.enabled ?? 'undefined (treated as enabled)'}` + ); + // Find the model config within this provider to check for mappings + modelConfig = findModelInProvider(provider, modelId); + if (!modelConfig && provider.models && provider.models.length > 0) { + logger.debug( + `${logPrefix} Model "${modelId}" not found in provider "${provider.name}". Available models: ${provider.models.map((m) => m.id).join(', ')}` + ); + } + } + } else { + logger.warn( + `${logPrefix} Explicitly requested provider "${providerId}" not found. Available providers: ${providers.map((p) => p.id).join(', ')}` + ); + } + } + + // 2. Fallback to model-based lookup across all providers if modelConfig not found + // Note: We still search even if provider was found, to get the modelConfig for mapping + if (!modelConfig) { + for (const p of providers) { + if (!isProviderEnabled(p) || p.id === providerId) continue; // Skip disabled or already checked + + const config = findModelInProvider(p, modelId); + + if (config) { + // Only override provider if we didn't find one by explicit ID + if (!provider) { + provider = p; + } + modelConfig = config; + logger.debug(`${logPrefix} Found model "${modelId}" in provider "${p.name}" (fallback)`); + break; + } + } + } + + // 3. Resolve the mapped Claude model if specified + let resolvedModel: string | undefined; + if (modelConfig?.mapsToClaudeModel) { + const { resolveModelString } = await import('@automaker/model-resolver'); + resolvedModel = resolveModelString(modelConfig.mapsToClaudeModel); + logger.debug( + `${logPrefix} Model "${modelId}" maps to Claude model "${modelConfig.mapsToClaudeModel}" -> "${resolvedModel}"` + ); + } + + // Log final result for debugging + logger.debug( + `${logPrefix} Provider context resolved: provider=${provider?.name ?? 'none'}, modelConfig=${modelConfig ? 'found' : 'not found'}, resolvedModel=${resolvedModel ?? modelId}` + ); + + return { provider, credentials, resolvedModel, modelConfig }; + } catch (error) { + logger.error(`${logPrefix} Failed to resolve provider context:`, error); + return { + provider: undefined, + credentials: undefined, + resolvedModel: undefined, + modelConfig: undefined, + }; + } +} + /** * Find a ClaudeCompatibleProvider by one of its model IDs. * Searches through all enabled providers to find one that contains the specified model. diff --git a/apps/server/src/providers/claude-provider.ts b/apps/server/src/providers/claude-provider.ts index 0923a626c..fe471e210 100644 --- a/apps/server/src/providers/claude-provider.ts +++ b/apps/server/src/providers/claude-provider.ts @@ -188,6 +188,7 @@ export class ClaudeProvider extends BaseProvider { async *executeQuery(options: ExecuteOptions): AsyncGenerator { // Validate that model doesn't have a provider prefix // AgentService should strip prefixes before passing to providers + // Claude doesn't use a provider prefix, so we don't need to specify an expected provider validateBareModelId(options.model, 'ClaudeProvider'); const { diff --git a/apps/server/src/providers/codex-provider.ts b/apps/server/src/providers/codex-provider.ts index 63d410362..3288f42ff 100644 --- a/apps/server/src/providers/codex-provider.ts +++ b/apps/server/src/providers/codex-provider.ts @@ -739,9 +739,9 @@ export class CodexProvider extends BaseProvider { } async *executeQuery(options: ExecuteOptions): AsyncGenerator { - // Validate that model doesn't have a provider prefix + // Validate that model doesn't have a provider prefix (except codex- which should already be stripped) // AgentService should strip prefixes before passing to providers - validateBareModelId(options.model, 'CodexProvider'); + validateBareModelId(options.model, 'CodexProvider', 'codex'); try { const mcpServers = options.mcpServers ?? {}; diff --git a/apps/server/src/providers/copilot-provider.ts b/apps/server/src/providers/copilot-provider.ts index b76c5cd4a..c5cc3a7e2 100644 --- a/apps/server/src/providers/copilot-provider.ts +++ b/apps/server/src/providers/copilot-provider.ts @@ -76,13 +76,18 @@ interface SdkToolExecutionStartEvent extends SdkEvent { }; } -interface SdkToolExecutionEndEvent extends SdkEvent { - type: 'tool.execution_end'; +interface SdkToolExecutionCompleteEvent extends SdkEvent { + type: 'tool.execution_complete'; data: { - toolName: string; toolCallId: string; - result?: string; - error?: string; + success: boolean; + result?: { + content: string; + }; + error?: { + message: string; + code?: string; + }; }; } @@ -94,6 +99,16 @@ interface SdkSessionErrorEvent extends SdkEvent { }; } +// ============================================================================= +// Constants +// ============================================================================= + +/** + * Prefix for error messages in tool results + * Consistent with GeminiProvider's error formatting + */ +const TOOL_ERROR_PREFIX = '[ERROR]' as const; + // ============================================================================= // Error Codes // ============================================================================= @@ -357,12 +372,19 @@ export class CopilotProvider extends CliProvider { }; } - case 'tool.execution_end': { - const toolResultEvent = sdkEvent as SdkToolExecutionEndEvent; - const isError = !!toolResultEvent.data.error; - const content = isError - ? `[ERROR] ${toolResultEvent.data.error}` - : toolResultEvent.data.result || ''; + /** + * Tool execution completed event + * Handles both successful results and errors from tool executions + * Error messages optionally include error codes for better debugging + */ + case 'tool.execution_complete': { + const toolResultEvent = sdkEvent as SdkToolExecutionCompleteEvent; + const error = toolResultEvent.data.error; + + // Format error message with optional code for better debugging + const content = error + ? `${TOOL_ERROR_PREFIX} ${error.message}${error.code ? ` (${error.code})` : ''}` + : toolResultEvent.data.result?.content || ''; return { type: 'assistant', @@ -628,7 +650,7 @@ export class CopilotProvider extends CliProvider { sessionComplete = true; pushEvent(event); } else { - // Push all other events (tool.execution_start, tool.execution_end, assistant.message, etc.) + // Push all other events (tool.execution_start, tool.execution_complete, assistant.message, etc.) pushEvent(event); } }); diff --git a/apps/server/src/providers/cursor-provider.ts b/apps/server/src/providers/cursor-provider.ts index 6c0d98e75..3903ea648 100644 --- a/apps/server/src/providers/cursor-provider.ts +++ b/apps/server/src/providers/cursor-provider.ts @@ -843,9 +843,10 @@ export class CursorProvider extends CliProvider { async *executeQuery(options: ExecuteOptions): AsyncGenerator { this.ensureCliDetected(); - // Validate that model doesn't have a provider prefix + // Validate that model doesn't have a provider prefix (except cursor- which should already be stripped) // AgentService should strip prefixes before passing to providers - validateBareModelId(options.model, 'CursorProvider'); + // Note: Cursor's Gemini models (e.g., "gemini-3-pro") legitimately start with "gemini-" + validateBareModelId(options.model, 'CursorProvider', 'cursor'); if (!this.cliPath) { throw this.createError( diff --git a/apps/server/src/providers/gemini-provider.ts b/apps/server/src/providers/gemini-provider.ts index f9425d907..9723de45a 100644 --- a/apps/server/src/providers/gemini-provider.ts +++ b/apps/server/src/providers/gemini-provider.ts @@ -546,8 +546,8 @@ export class GeminiProvider extends CliProvider { async *executeQuery(options: ExecuteOptions): AsyncGenerator { this.ensureCliDetected(); - // Validate that model doesn't have a provider prefix - validateBareModelId(options.model, 'GeminiProvider'); + // Validate that model doesn't have a provider prefix (except gemini- which should already be stripped) + validateBareModelId(options.model, 'GeminiProvider', 'gemini'); if (!this.cliPath) { throw this.createError( diff --git a/apps/server/src/providers/mock-provider.ts b/apps/server/src/providers/mock-provider.ts new file mode 100644 index 000000000..2880fecef --- /dev/null +++ b/apps/server/src/providers/mock-provider.ts @@ -0,0 +1,53 @@ +/** + * Mock Provider - No-op AI provider for E2E and CI testing + * + * When AUTOMAKER_MOCK_AGENT=true, the server uses this provider instead of + * real backends (Claude, Codex, etc.) so tests never call external APIs. + */ + +import type { ExecuteOptions } from '@automaker/types'; +import { BaseProvider } from './base-provider.js'; +import type { ProviderMessage, InstallationStatus, ModelDefinition } from './types.js'; + +const MOCK_TEXT = 'Mock agent output for testing.'; + +export class MockProvider extends BaseProvider { + getName(): string { + return 'mock'; + } + + async *executeQuery(_options: ExecuteOptions): AsyncGenerator { + yield { + type: 'assistant', + message: { + role: 'assistant', + content: [{ type: 'text', text: MOCK_TEXT }], + }, + }; + yield { + type: 'result', + subtype: 'success', + }; + } + + async detectInstallation(): Promise { + return { + installed: true, + method: 'sdk', + hasApiKey: true, + authenticated: true, + }; + } + + getAvailableModels(): ModelDefinition[] { + return [ + { + id: 'mock-model', + name: 'Mock Model', + modelString: 'mock-model', + provider: 'mock', + description: 'Mock model for testing', + }, + ]; + } +} diff --git a/apps/server/src/providers/opencode-provider.ts b/apps/server/src/providers/opencode-provider.ts index 8c58da156..c38e33996 100644 --- a/apps/server/src/providers/opencode-provider.ts +++ b/apps/server/src/providers/opencode-provider.ts @@ -1189,8 +1189,26 @@ export class OpencodeProvider extends CliProvider { * Format a display name for a model */ private formatModelDisplayName(model: OpenCodeModelInfo): string { + // Extract the last path segment for nested model IDs + // e.g., "arcee-ai/trinity-large-preview:free" → "trinity-large-preview:free" + let rawName = model.name; + if (rawName.includes('/')) { + rawName = rawName.split('/').pop()!; + } + + // Strip tier/pricing suffixes like ":free", ":extended" + const colonIdx = rawName.indexOf(':'); + let suffix = ''; + if (colonIdx !== -1) { + const tierPart = rawName.slice(colonIdx + 1); + if (/^(free|extended|beta|preview)$/i.test(tierPart)) { + suffix = ` (${tierPart.charAt(0).toUpperCase() + tierPart.slice(1)})`; + } + rawName = rawName.slice(0, colonIdx); + } + // Capitalize and format the model name - const formattedName = model.name + const formattedName = rawName .split('-') .map((part) => { // Handle version numbers like "4-5" -> "4.5" @@ -1218,7 +1236,7 @@ export class OpencodeProvider extends CliProvider { }; const providerDisplay = providerNames[model.provider] || model.provider; - return `${formattedName} (${providerDisplay})`; + return `${formattedName}${suffix} (${providerDisplay})`; } /** diff --git a/apps/server/src/providers/provider-factory.ts b/apps/server/src/providers/provider-factory.ts index a6dff69ee..2b5535084 100644 --- a/apps/server/src/providers/provider-factory.ts +++ b/apps/server/src/providers/provider-factory.ts @@ -67,6 +67,16 @@ export function registerProvider(name: string, registration: ProviderRegistratio providerRegistry.set(name.toLowerCase(), registration); } +/** Cached mock provider instance when AUTOMAKER_MOCK_AGENT is set (E2E/CI). */ +let mockProviderInstance: BaseProvider | null = null; + +function getMockProvider(): BaseProvider { + if (!mockProviderInstance) { + mockProviderInstance = new MockProvider(); + } + return mockProviderInstance; +} + export class ProviderFactory { /** * Determine which provider to use for a given model @@ -75,6 +85,9 @@ export class ProviderFactory { * @returns Provider name (ModelProvider type) */ static getProviderNameForModel(model: string): ModelProvider { + if (process.env.AUTOMAKER_MOCK_AGENT === 'true') { + return 'claude' as ModelProvider; // Name only; getProviderForModel returns MockProvider + } const lowerModel = model.toLowerCase(); // Get all registered providers sorted by priority (descending) @@ -113,6 +126,9 @@ export class ProviderFactory { modelId: string, options: { throwOnDisconnected?: boolean } = {} ): BaseProvider { + if (process.env.AUTOMAKER_MOCK_AGENT === 'true') { + return getMockProvider(); + } const { throwOnDisconnected = true } = options; const providerName = this.getProviderForModelName(modelId); @@ -142,6 +158,9 @@ export class ProviderFactory { * Get the provider name for a given model ID (without creating provider instance) */ static getProviderForModelName(modelId: string): string { + if (process.env.AUTOMAKER_MOCK_AGENT === 'true') { + return 'claude'; + } const lowerModel = modelId.toLowerCase(); // Get all registered providers sorted by priority (descending) @@ -272,6 +291,7 @@ export class ProviderFactory { // ============================================================================= // Import providers for registration side-effects +import { MockProvider } from './mock-provider.js'; import { ClaudeProvider } from './claude-provider.js'; import { CursorProvider } from './cursor-provider.js'; import { CodexProvider } from './codex-provider.js'; diff --git a/apps/server/src/routes/app-spec/generate-features-from-spec.ts b/apps/server/src/routes/app-spec/generate-features-from-spec.ts index 93daeb8ef..562484714 100644 --- a/apps/server/src/routes/app-spec/generate-features-from-spec.ts +++ b/apps/server/src/routes/app-spec/generate-features-from-spec.ts @@ -323,7 +323,7 @@ Your entire response should be valid JSON starting with { and ending with }. No } } - await parseAndCreateFeatures(projectPath, contentForParsing, events); + await parseAndCreateFeatures(projectPath, contentForParsing, events, settingsService); logger.debug('========== generateFeaturesFromSpec() completed =========='); } diff --git a/apps/server/src/routes/app-spec/parse-and-create-features.ts b/apps/server/src/routes/app-spec/parse-and-create-features.ts index 47846fccc..b7a474d29 100644 --- a/apps/server/src/routes/app-spec/parse-and-create-features.ts +++ b/apps/server/src/routes/app-spec/parse-and-create-features.ts @@ -9,13 +9,16 @@ import { createLogger, atomicWriteJson, DEFAULT_BACKUP_COUNT } from '@automaker/ import { getFeaturesDir } from '@automaker/platform'; import { extractJsonWithArray } from '../../lib/json-extractor.js'; import { getNotificationService } from '../../services/notification-service.js'; +import type { SettingsService } from '../../services/settings-service.js'; +import { resolvePhaseModel } from '@automaker/model-resolver'; const logger = createLogger('SpecRegeneration'); export async function parseAndCreateFeatures( projectPath: string, content: string, - events: EventEmitter + events: EventEmitter, + settingsService?: SettingsService ): Promise { logger.info('========== parseAndCreateFeatures() started =========='); logger.info(`Content length: ${content.length} chars`); @@ -23,6 +26,37 @@ export async function parseAndCreateFeatures( logger.info(content); logger.info('========== END CONTENT =========='); + // Load default model and planning settings from settingsService + let defaultModel: string | undefined; + let defaultPlanningMode: string = 'skip'; + let defaultRequirePlanApproval = false; + + if (settingsService) { + try { + const globalSettings = await settingsService.getGlobalSettings(); + const projectSettings = await settingsService.getProjectSettings(projectPath); + + const defaultModelEntry = + projectSettings.defaultFeatureModel ?? globalSettings.defaultFeatureModel; + if (defaultModelEntry) { + const resolved = resolvePhaseModel(defaultModelEntry); + defaultModel = resolved.model; + } + + defaultPlanningMode = globalSettings.defaultPlanningMode ?? 'skip'; + defaultRequirePlanApproval = globalSettings.defaultRequirePlanApproval ?? false; + + logger.info( + `[parseAndCreateFeatures] Using defaults: model=${defaultModel ?? 'none'}, planningMode=${defaultPlanningMode}, requirePlanApproval=${defaultRequirePlanApproval}` + ); + } catch (settingsError) { + logger.warn( + '[parseAndCreateFeatures] Failed to load settings, using defaults:', + settingsError + ); + } + } + try { // Extract JSON from response using shared utility logger.info('Extracting JSON from response using extractJsonWithArray...'); @@ -61,7 +95,7 @@ export async function parseAndCreateFeatures( const featureDir = path.join(featuresDir, feature.id); await secureFs.mkdir(featureDir, { recursive: true }); - const featureData = { + const featureData: Record = { id: feature.id, category: feature.category || 'Uncategorized', title: feature.title, @@ -70,10 +104,20 @@ export async function parseAndCreateFeatures( priority: feature.priority || 2, complexity: feature.complexity || 'moderate', dependencies: feature.dependencies || [], + planningMode: defaultPlanningMode, + requirePlanApproval: + defaultPlanningMode === 'skip' || defaultPlanningMode === 'lite' + ? false + : defaultRequirePlanApproval, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }; + // Apply default model if available from settings + if (defaultModel) { + featureData.model = defaultModel; + } + // Use atomic write with backup support for crash protection await atomicWriteJson(path.join(featureDir, 'feature.json'), featureData, { backupCount: DEFAULT_BACKUP_COUNT, diff --git a/apps/server/src/routes/backlog-plan/index.ts b/apps/server/src/routes/backlog-plan/index.ts index 4ab9e71de..d1b7e424d 100644 --- a/apps/server/src/routes/backlog-plan/index.ts +++ b/apps/server/src/routes/backlog-plan/index.ts @@ -25,7 +25,7 @@ export function createBacklogPlanRoutes( ); router.post('/stop', createStopHandler()); router.get('/status', validatePathParams('projectPath'), createStatusHandler()); - router.post('/apply', validatePathParams('projectPath'), createApplyHandler()); + router.post('/apply', validatePathParams('projectPath'), createApplyHandler(settingsService)); router.post('/clear', validatePathParams('projectPath'), createClearHandler()); return router; diff --git a/apps/server/src/routes/backlog-plan/routes/apply.ts b/apps/server/src/routes/backlog-plan/routes/apply.ts index e0fb71227..6efd16580 100644 --- a/apps/server/src/routes/backlog-plan/routes/apply.ts +++ b/apps/server/src/routes/backlog-plan/routes/apply.ts @@ -3,13 +3,23 @@ */ import type { Request, Response } from 'express'; -import type { BacklogPlanResult } from '@automaker/types'; +import { resolvePhaseModel } from '@automaker/model-resolver'; +import type { BacklogPlanResult, PhaseModelEntry, PlanningMode } from '@automaker/types'; import { FeatureLoader } from '../../../services/feature-loader.js'; +import type { SettingsService } from '../../../services/settings-service.js'; import { clearBacklogPlan, getErrorMessage, logError, logger } from '../common.js'; const featureLoader = new FeatureLoader(); -export function createApplyHandler() { +function normalizePhaseModelEntry( + entry: PhaseModelEntry | string | undefined | null +): PhaseModelEntry | undefined { + if (!entry) return undefined; + if (typeof entry === 'string') return { model: entry }; + return entry; +} + +export function createApplyHandler(settingsService?: SettingsService) { return async (req: Request, res: Response): Promise => { try { const { @@ -38,6 +48,23 @@ export function createApplyHandler() { return; } + let defaultPlanningMode: PlanningMode = 'skip'; + let defaultRequirePlanApproval = false; + let defaultModelEntry: PhaseModelEntry | undefined; + + if (settingsService) { + const globalSettings = await settingsService.getGlobalSettings(); + const projectSettings = await settingsService.getProjectSettings(projectPath); + + defaultPlanningMode = globalSettings.defaultPlanningMode ?? 'skip'; + defaultRequirePlanApproval = globalSettings.defaultRequirePlanApproval ?? false; + defaultModelEntry = normalizePhaseModelEntry( + projectSettings.defaultFeatureModel ?? globalSettings.defaultFeatureModel + ); + } + + const resolvedDefaultModel = resolvePhaseModel(defaultModelEntry); + const appliedChanges: string[] = []; // Load current features for dependency validation @@ -88,6 +115,12 @@ export function createApplyHandler() { if (!change.feature) continue; try { + const effectivePlanningMode = change.feature.planningMode ?? defaultPlanningMode; + const effectiveRequirePlanApproval = + effectivePlanningMode === 'skip' || effectivePlanningMode === 'lite' + ? false + : (change.feature.requirePlanApproval ?? defaultRequirePlanApproval); + // Create the new feature - use the AI-generated ID if provided const newFeature = await featureLoader.create(projectPath, { id: change.feature.id, // Use descriptive ID from AI if provided @@ -97,6 +130,12 @@ export function createApplyHandler() { dependencies: change.feature.dependencies, priority: change.feature.priority, status: 'backlog', + model: change.feature.model ?? resolvedDefaultModel.model, + thinkingLevel: change.feature.thinkingLevel ?? resolvedDefaultModel.thinkingLevel, + reasoningEffort: change.feature.reasoningEffort ?? resolvedDefaultModel.reasoningEffort, + providerId: change.feature.providerId ?? resolvedDefaultModel.providerId, + planningMode: effectivePlanningMode, + requirePlanApproval: effectiveRequirePlanApproval, branchName, }); diff --git a/apps/server/src/routes/features/index.ts b/apps/server/src/routes/features/index.ts index a4ea03b45..60ef92317 100644 --- a/apps/server/src/routes/features/index.ts +++ b/apps/server/src/routes/features/index.ts @@ -19,6 +19,11 @@ import { createAgentOutputHandler, createRawOutputHandler } from './routes/agent import { createGenerateTitleHandler } from './routes/generate-title.js'; import { createExportHandler } from './routes/export.js'; import { createImportHandler, createConflictCheckHandler } from './routes/import.js'; +import { + createOrphanedListHandler, + createOrphanedResolveHandler, + createOrphanedBulkResolveHandler, +} from './routes/orphaned.js'; export function createFeaturesRoutes( featureLoader: FeatureLoader, @@ -70,6 +75,21 @@ export function createFeaturesRoutes( validatePathParams('projectPath'), createConflictCheckHandler(featureLoader) ); + router.post( + '/orphaned', + validatePathParams('projectPath'), + createOrphanedListHandler(featureLoader, autoModeService) + ); + router.post( + '/orphaned/resolve', + validatePathParams('projectPath'), + createOrphanedResolveHandler(featureLoader, autoModeService) + ); + router.post( + '/orphaned/bulk-resolve', + validatePathParams('projectPath'), + createOrphanedBulkResolveHandler(featureLoader) + ); return router; } diff --git a/apps/server/src/routes/features/routes/list.ts b/apps/server/src/routes/features/routes/list.ts index 71d8a04ab..46ff3b921 100644 --- a/apps/server/src/routes/features/routes/list.ts +++ b/apps/server/src/routes/features/routes/list.ts @@ -46,7 +46,7 @@ export function createListHandler( // Note: detectOrphanedFeatures handles errors internally and always resolves if (autoModeService) { autoModeService - .detectOrphanedFeatures(projectPath) + .detectOrphanedFeatures(projectPath, features) .then((orphanedFeatures) => { if (orphanedFeatures.length > 0) { logger.info( diff --git a/apps/server/src/routes/features/routes/orphaned.ts b/apps/server/src/routes/features/routes/orphaned.ts new file mode 100644 index 000000000..e44711be1 --- /dev/null +++ b/apps/server/src/routes/features/routes/orphaned.ts @@ -0,0 +1,287 @@ +/** + * POST /orphaned endpoint - Detect orphaned features (features with missing branches) + * POST /orphaned/resolve endpoint - Resolve an orphaned feature (delete, create-worktree, or move-to-branch) + * POST /orphaned/bulk-resolve endpoint - Resolve multiple orphaned features at once + */ + +import crypto from 'crypto'; +import path from 'path'; +import type { Request, Response } from 'express'; +import { FeatureLoader } from '../../../services/feature-loader.js'; +import type { AutoModeServiceCompat } from '../../../services/auto-mode/index.js'; +import { getErrorMessage, logError } from '../common.js'; +import { execGitCommand } from '../../../lib/git.js'; +import { deleteWorktreeMetadata } from '../../../lib/worktree-metadata.js'; +import { createLogger } from '@automaker/utils'; + +const logger = createLogger('OrphanedFeatures'); + +type ResolveAction = 'delete' | 'create-worktree' | 'move-to-branch'; +const VALID_ACTIONS: ResolveAction[] = ['delete', 'create-worktree', 'move-to-branch']; + +export function createOrphanedListHandler( + featureLoader: FeatureLoader, + autoModeService?: AutoModeServiceCompat +) { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath } = req.body as { projectPath: string }; + + if (!projectPath) { + res.status(400).json({ success: false, error: 'projectPath is required' }); + return; + } + + if (!autoModeService) { + res.status(500).json({ success: false, error: 'Auto-mode service not available' }); + return; + } + + const orphanedFeatures = await autoModeService.detectOrphanedFeatures(projectPath); + + res.json({ success: true, orphanedFeatures }); + } catch (error) { + logError(error, 'Detect orphaned features failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} + +export function createOrphanedResolveHandler( + featureLoader: FeatureLoader, + _autoModeService?: AutoModeServiceCompat +) { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath, featureId, action, targetBranch } = req.body as { + projectPath: string; + featureId: string; + action: ResolveAction; + targetBranch?: string | null; + }; + + if (!projectPath || !featureId || !action) { + res.status(400).json({ + success: false, + error: 'projectPath, featureId, and action are required', + }); + return; + } + + if (!VALID_ACTIONS.includes(action)) { + res.status(400).json({ + success: false, + error: `action must be one of: ${VALID_ACTIONS.join(', ')}`, + }); + return; + } + + const result = await resolveOrphanedFeature( + featureLoader, + projectPath, + featureId, + action, + targetBranch + ); + + if (!result.success) { + res.status(result.error === 'Feature not found' ? 404 : 500).json(result); + return; + } + + res.json(result); + } catch (error) { + logError(error, 'Resolve orphaned feature failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} + +interface BulkResolveResult { + featureId: string; + success: boolean; + action?: string; + error?: string; +} + +async function resolveOrphanedFeature( + featureLoader: FeatureLoader, + projectPath: string, + featureId: string, + action: ResolveAction, + targetBranch?: string | null +): Promise { + try { + const feature = await featureLoader.get(projectPath, featureId); + if (!feature) { + return { featureId, success: false, error: 'Feature not found' }; + } + + const missingBranch = feature.branchName; + + switch (action) { + case 'delete': { + if (missingBranch) { + try { + await deleteWorktreeMetadata(projectPath, missingBranch); + } catch { + // Non-fatal + } + } + const success = await featureLoader.delete(projectPath, featureId); + if (!success) { + return { featureId, success: false, error: 'Deletion failed' }; + } + logger.info(`Deleted orphaned feature ${featureId} (branch: ${missingBranch})`); + return { featureId, success: true, action: 'deleted' }; + } + + case 'create-worktree': { + if (!missingBranch) { + return { featureId, success: false, error: 'Feature has no branch name to recreate' }; + } + + const sanitizedName = missingBranch.replace(/[^a-zA-Z0-9_-]/g, '-'); + const hash = crypto.createHash('sha1').update(missingBranch).digest('hex').slice(0, 8); + const worktreesDir = path.join(projectPath, '.worktrees'); + const worktreePath = path.join(worktreesDir, `${sanitizedName}-${hash}`); + + try { + await execGitCommand(['worktree', 'add', '-b', missingBranch, worktreePath], projectPath); + } catch (error) { + const msg = getErrorMessage(error); + if (msg.includes('already exists')) { + try { + await execGitCommand(['worktree', 'add', worktreePath, missingBranch], projectPath); + } catch (innerError) { + return { + featureId, + success: false, + error: `Failed to create worktree: ${getErrorMessage(innerError)}`, + }; + } + } else { + return { featureId, success: false, error: `Failed to create worktree: ${msg}` }; + } + } + + logger.info( + `Created worktree for orphaned feature ${featureId} at ${worktreePath} (branch: ${missingBranch})` + ); + return { featureId, success: true, action: 'worktree-created' }; + } + + case 'move-to-branch': { + // Move the feature to a different branch (or clear branch to use main worktree) + const newBranch = targetBranch || null; + + // Validate that the target branch exists if one is specified + if (newBranch) { + try { + await execGitCommand(['rev-parse', '--verify', newBranch], projectPath); + } catch { + return { + featureId, + success: false, + error: `Target branch "${newBranch}" does not exist`, + }; + } + } + + await featureLoader.update(projectPath, featureId, { + branchName: newBranch, + status: 'pending', + }); + + // Clean up old worktree metadata + if (missingBranch) { + try { + await deleteWorktreeMetadata(projectPath, missingBranch); + } catch { + // Non-fatal + } + } + + const destination = newBranch ?? 'main worktree'; + logger.info( + `Moved orphaned feature ${featureId} to ${destination} (was: ${missingBranch})` + ); + return { featureId, success: true, action: 'moved' }; + } + } + } catch (error) { + return { featureId, success: false, error: getErrorMessage(error) }; + } +} + +export function createOrphanedBulkResolveHandler(featureLoader: FeatureLoader) { + return async (req: Request, res: Response): Promise => { + try { + const { projectPath, featureIds, action, targetBranch } = req.body as { + projectPath: string; + featureIds: string[]; + action: ResolveAction; + targetBranch?: string | null; + }; + + if ( + !projectPath || + !featureIds || + !Array.isArray(featureIds) || + featureIds.length === 0 || + !action + ) { + res.status(400).json({ + success: false, + error: 'projectPath, featureIds (non-empty array), and action are required', + }); + return; + } + + if (!VALID_ACTIONS.includes(action)) { + res.status(400).json({ + success: false, + error: `action must be one of: ${VALID_ACTIONS.join(', ')}`, + }); + return; + } + + // Process sequentially for worktree creation (git operations shouldn't race), + // in parallel for delete/move-to-branch + const results: BulkResolveResult[] = []; + + if (action === 'create-worktree') { + for (const featureId of featureIds) { + const result = await resolveOrphanedFeature( + featureLoader, + projectPath, + featureId, + action, + targetBranch + ); + results.push(result); + } + } else { + const batchResults = await Promise.all( + featureIds.map((featureId) => + resolveOrphanedFeature(featureLoader, projectPath, featureId, action, targetBranch) + ) + ); + results.push(...batchResults); + } + + const successCount = results.filter((r) => r.success).length; + const failedCount = results.length - successCount; + + res.json({ + success: failedCount === 0, + resolvedCount: successCount, + failedCount, + results, + }); + } catch (error) { + logError(error, 'Bulk resolve orphaned features failed'); + res.status(500).json({ success: false, error: getErrorMessage(error) }); + } + }; +} diff --git a/apps/server/src/routes/features/routes/update.ts b/apps/server/src/routes/features/routes/update.ts index 89e2dde09..205838a49 100644 --- a/apps/server/src/routes/features/routes/update.ts +++ b/apps/server/src/routes/features/routes/update.ts @@ -43,7 +43,11 @@ export function createUpdateHandler(featureLoader: FeatureLoader, events?: Event // Get the current feature to detect status changes const currentFeature = await featureLoader.get(projectPath, featureId); - const previousStatus = currentFeature?.status as FeatureStatus | undefined; + if (!currentFeature) { + res.status(404).json({ success: false, error: `Feature ${featureId} not found` }); + return; + } + const previousStatus = currentFeature.status as FeatureStatus; const newStatus = updates.status as FeatureStatus | undefined; const updated = await featureLoader.update( diff --git a/apps/server/src/routes/fs/routes/read.ts b/apps/server/src/routes/fs/routes/read.ts index 27ce45b45..6dfcb9fd7 100644 --- a/apps/server/src/routes/fs/routes/read.ts +++ b/apps/server/src/routes/fs/routes/read.ts @@ -3,16 +3,29 @@ */ import type { Request, Response } from 'express'; +import path from 'path'; import * as secureFs from '../../../lib/secure-fs.js'; import { PathNotAllowedError } from '@automaker/platform'; import { getErrorMessage, logError } from '../common.js'; // Optional files that are expected to not exist in new projects // Don't log ENOENT errors for these to reduce noise -const OPTIONAL_FILES = ['categories.json', 'app_spec.txt']; +const OPTIONAL_FILES = ['categories.json', 'app_spec.txt', 'context-metadata.json']; function isOptionalFile(filePath: string): boolean { - return OPTIONAL_FILES.some((optionalFile) => filePath.endsWith(optionalFile)); + const basename = path.basename(filePath); + if (OPTIONAL_FILES.some((optionalFile) => basename === optionalFile)) { + return true; + } + // Context and memory files may not exist yet during create/delete or test races + if (filePath.includes('.automaker/context/') || filePath.includes('.automaker/memory/')) { + const name = path.basename(filePath); + const lower = name.toLowerCase(); + if (lower.endsWith('.md') || lower.endsWith('.txt') || lower.endsWith('.markdown')) { + return true; + } + } + return false; } function isENOENT(error: unknown): boolean { @@ -39,12 +52,14 @@ export function createReadHandler() { return; } - // Don't log ENOENT errors for optional files (expected to be missing in new projects) - const shouldLog = !(isENOENT(error) && isOptionalFile(req.body?.filePath || '')); - if (shouldLog) { + const filePath = req.body?.filePath || ''; + const optionalMissing = isENOENT(error) && isOptionalFile(filePath); + if (!optionalMissing) { logError(error, 'Read file failed'); } - res.status(500).json({ success: false, error: getErrorMessage(error) }); + // Return 404 for missing optional files so clients can handle "not found" + const status = optionalMissing ? 404 : 500; + res.status(status).json({ success: false, error: getErrorMessage(error) }); } }; } diff --git a/apps/server/src/routes/fs/routes/stat.ts b/apps/server/src/routes/fs/routes/stat.ts index f7df81093..54e0ada15 100644 --- a/apps/server/src/routes/fs/routes/stat.ts +++ b/apps/server/src/routes/fs/routes/stat.ts @@ -35,6 +35,16 @@ export function createStatHandler() { return; } + // File or directory does not exist - return 404 so UI can handle missing paths + const code = + error && typeof error === 'object' && 'code' in error + ? (error as { code: string }).code + : ''; + if (code === 'ENOENT') { + res.status(404).json({ success: false, error: 'File or directory not found' }); + return; + } + logError(error, 'Get file stats failed'); res.status(500).json({ success: false, error: getErrorMessage(error) }); } diff --git a/apps/server/src/routes/github/routes/validate-issue.ts b/apps/server/src/routes/github/routes/validate-issue.ts index 38220f6d0..7707f2b7d 100644 --- a/apps/server/src/routes/github/routes/validate-issue.ts +++ b/apps/server/src/routes/github/routes/validate-issue.ts @@ -38,7 +38,7 @@ import { import { getPromptCustomization, getAutoLoadClaudeMdSetting, - getProviderByModelId, + resolveProviderContext, } from '../../../lib/settings-helpers.js'; import { trySetValidationRunning, @@ -64,6 +64,8 @@ interface ValidateIssueRequestBody { thinkingLevel?: ThinkingLevel; /** Reasoning effort for Codex models (ignored for non-Codex models) */ reasoningEffort?: ReasoningEffort; + /** Optional Claude-compatible provider ID for custom providers (e.g., GLM, MiniMax) */ + providerId?: string; /** Comments to include in validation analysis */ comments?: GitHubComment[]; /** Linked pull requests for this issue */ @@ -87,6 +89,7 @@ async function runValidation( events: EventEmitter, abortController: AbortController, settingsService?: SettingsService, + providerId?: string, comments?: ValidationComment[], linkedPRs?: ValidationLinkedPR[], thinkingLevel?: ThinkingLevel, @@ -176,7 +179,12 @@ ${basePrompt}`; let credentials = await settingsService?.getCredentials(); if (settingsService) { - const providerResult = await getProviderByModelId(model, settingsService, '[ValidateIssue]'); + const providerResult = await resolveProviderContext( + settingsService, + model, + providerId, + '[ValidateIssue]' + ); if (providerResult.provider) { claudeCompatibleProvider = providerResult.provider; providerResolvedModel = providerResult.resolvedModel; @@ -312,10 +320,16 @@ export function createValidateIssueHandler( model = 'opus', thinkingLevel, reasoningEffort, + providerId, comments: rawComments, linkedPRs: rawLinkedPRs, } = req.body as ValidateIssueRequestBody; + const normalizedProviderId = + typeof providerId === 'string' && providerId.trim().length > 0 + ? providerId.trim() + : undefined; + // Transform GitHubComment[] to ValidationComment[] if provided const validationComments: ValidationComment[] | undefined = rawComments?.map((c) => ({ author: c.author?.login || 'ghost', @@ -364,12 +378,14 @@ export function createValidateIssueHandler( isClaudeModel(model) || isCursorModel(model) || isCodexModel(model) || - isOpencodeModel(model); + isOpencodeModel(model) || + !!normalizedProviderId; if (!isValidModel) { res.status(400).json({ success: false, - error: 'Invalid model. Must be a Claude, Cursor, Codex, or OpenCode model ID (or alias).', + error: + 'Invalid model. Must be a Claude, Cursor, Codex, or OpenCode model ID (or alias), or provide a valid providerId for custom Claude-compatible models.', }); return; } @@ -398,6 +414,7 @@ export function createValidateIssueHandler( events, abortController, settingsService, + normalizedProviderId, validationComments, validationLinkedPRs, thinkingLevel, diff --git a/apps/server/src/routes/setup/routes/verify-claude-auth.ts b/apps/server/src/routes/setup/routes/verify-claude-auth.ts index 18a40bf81..c5a354675 100644 --- a/apps/server/src/routes/setup/routes/verify-claude-auth.ts +++ b/apps/server/src/routes/setup/routes/verify-claude-auth.ts @@ -80,6 +80,12 @@ function containsAuthError(text: string): boolean { export function createVerifyClaudeAuthHandler() { return async (req: Request, res: Response): Promise => { try { + // In E2E/CI mock mode, skip real API calls + if (process.env.AUTOMAKER_MOCK_AGENT === 'true') { + res.json({ success: true, authenticated: true }); + return; + } + // Get the auth method and optional API key from the request body const { authMethod, apiKey } = req.body as { authMethod?: 'cli' | 'api_key'; diff --git a/apps/server/src/routes/setup/routes/verify-codex-auth.ts b/apps/server/src/routes/setup/routes/verify-codex-auth.ts index 00edd0f3b..7dcf15598 100644 --- a/apps/server/src/routes/setup/routes/verify-codex-auth.ts +++ b/apps/server/src/routes/setup/routes/verify-codex-auth.ts @@ -82,6 +82,12 @@ function isRateLimitError(text: string): boolean { export function createVerifyCodexAuthHandler() { return async (req: Request, res: Response): Promise => { + // In E2E/CI mock mode, skip real API calls + if (process.env.AUTOMAKER_MOCK_AGENT === 'true') { + res.json({ success: true, authenticated: true }); + return; + } + const { authMethod, apiKey } = req.body as { authMethod?: 'cli' | 'api_key'; apiKey?: string; diff --git a/apps/server/src/routes/worktree/index.ts b/apps/server/src/routes/worktree/index.ts index 2525c831d..d786616b2 100644 --- a/apps/server/src/routes/worktree/index.ts +++ b/apps/server/src/routes/worktree/index.ts @@ -71,10 +71,12 @@ import { createSetTrackingHandler } from './routes/set-tracking.js'; import { createSyncHandler } from './routes/sync.js'; import { createUpdatePRNumberHandler } from './routes/update-pr-number.js'; import type { SettingsService } from '../../services/settings-service.js'; +import type { FeatureLoader } from '../../services/feature-loader.js'; export function createWorktreeRoutes( events: EventEmitter, - settingsService?: SettingsService + settingsService?: SettingsService, + featureLoader?: FeatureLoader ): Router { const router = Router(); @@ -94,7 +96,11 @@ export function createWorktreeRoutes( validatePathParams('projectPath'), createCreateHandler(events, settingsService) ); - router.post('/delete', validatePathParams('projectPath', 'worktreePath'), createDeleteHandler()); + router.post( + '/delete', + validatePathParams('projectPath', 'worktreePath'), + createDeleteHandler(events, featureLoader) + ); router.post('/create-pr', createCreatePRHandler()); router.post('/pr-info', createPRInfoHandler()); router.post( diff --git a/apps/server/src/routes/worktree/routes/delete.ts b/apps/server/src/routes/worktree/routes/delete.ts index fcb42f590..034be28ef 100644 --- a/apps/server/src/routes/worktree/routes/delete.ts +++ b/apps/server/src/routes/worktree/routes/delete.ts @@ -10,11 +10,13 @@ import { isGitRepo } from '@automaker/git-utils'; import { getErrorMessage, logError, isValidBranchName } from '../common.js'; import { execGitCommand } from '../../../lib/git.js'; import { createLogger } from '@automaker/utils'; +import type { FeatureLoader } from '../../../services/feature-loader.js'; +import type { EventEmitter } from '../../../lib/events.js'; const execAsync = promisify(exec); const logger = createLogger('Worktree'); -export function createDeleteHandler() { +export function createDeleteHandler(events: EventEmitter, featureLoader?: FeatureLoader) { return async (req: Request, res: Response): Promise => { try { const { projectPath, worktreePath, deleteBranch } = req.body as { @@ -134,12 +136,65 @@ export function createDeleteHandler() { } } + // Emit worktree:deleted event after successful deletion + events.emit('worktree:deleted', { + worktreePath, + projectPath, + branchName, + branchDeleted, + }); + + // Move features associated with the deleted branch to the main worktree + // This prevents features from being orphaned when a worktree is deleted + let featuresMovedToMain = 0; + if (featureLoader && branchName) { + try { + const allFeatures = await featureLoader.getAll(projectPath); + const affectedFeatures = allFeatures.filter((f) => f.branchName === branchName); + for (const feature of affectedFeatures) { + try { + await featureLoader.update(projectPath, feature.id, { + branchName: null, + }); + featuresMovedToMain++; + // Emit feature:migrated event for each successfully migrated feature + events.emit('feature:migrated', { + featureId: feature.id, + status: 'migrated', + fromBranch: branchName, + toWorktreeId: null, // migrated to main worktree (no specific worktree) + projectPath, + }); + } catch (featureUpdateError) { + // Non-fatal: log per-feature failure but continue migrating others + logger.warn('Failed to move feature to main worktree after deletion', { + error: getErrorMessage(featureUpdateError), + featureId: feature.id, + branchName, + }); + } + } + if (featuresMovedToMain > 0) { + logger.info( + `Moved ${featuresMovedToMain} feature(s) to main worktree after deleting worktree with branch: ${branchName}` + ); + } + } catch (featureError) { + // Non-fatal: log but don't fail the deletion (getAll failed) + logger.warn('Failed to load features for migration to main worktree after deletion', { + error: getErrorMessage(featureError), + branchName, + }); + } + } + res.json({ success: true, deleted: { worktreePath, branch: branchDeleted ? branchName : null, branchDeleted, + featuresMovedToMain, }, }); } catch (error) { diff --git a/apps/server/src/routes/worktree/routes/init-git.ts b/apps/server/src/routes/worktree/routes/init-git.ts index 656a8472f..6d58942f3 100644 --- a/apps/server/src/routes/worktree/routes/init-git.ts +++ b/apps/server/src/routes/worktree/routes/init-git.ts @@ -44,13 +44,79 @@ export function createInitGitHandler() { } // Initialize git with 'main' as the default branch (matching GitHub's standard since 2020) - // and create an initial empty commit - await execAsync( - `git init --initial-branch=main && git commit --allow-empty -m "Initial commit"`, - { - cwd: projectPath, + // Run commands sequentially so failures can be handled and partial state cleaned up. + let gitDirCreated = false; + try { + // Step 1: initialize the repository + try { + await execAsync(`git init --initial-branch=main`, { cwd: projectPath }); + } catch (initError: unknown) { + const stderr = + initError && typeof initError === 'object' && 'stderr' in initError + ? String((initError as { stderr?: string }).stderr) + : ''; + // Idempotent: if .git was created by a concurrent request or a stale lock exists, + // treat as "repo already exists" instead of failing + if ( + /could not lock config file.*File exists|fatal: could not set 'core\.repositoryformatversion'/.test( + stderr + ) + ) { + try { + await secureFs.access(gitDirPath); + res.json({ + success: true, + result: { + initialized: false, + message: 'Git repository already exists', + }, + }); + return; + } catch { + // .git still missing, rethrow original error + } + } + throw initError; + } + gitDirCreated = true; + + // Step 2: ensure user.name and user.email are set so the commit can succeed. + // Check the global/system config first; only set locally if missing. + let userName = ''; + let userEmail = ''; + try { + ({ stdout: userName } = await execAsync(`git config user.name`, { cwd: projectPath })); + } catch { + // not set globally – will configure locally below + } + try { + ({ stdout: userEmail } = await execAsync(`git config user.email`, { + cwd: projectPath, + })); + } catch { + // not set globally – will configure locally below + } + + if (!userName.trim()) { + await execAsync(`git config user.name "Automaker"`, { cwd: projectPath }); + } + if (!userEmail.trim()) { + await execAsync(`git config user.email "automaker@localhost"`, { cwd: projectPath }); } - ); + + // Step 3: create the initial empty commit + await execAsync(`git commit --allow-empty -m "Initial commit"`, { cwd: projectPath }); + } catch (error: unknown) { + // Clean up the partial .git directory so subsequent runs behave deterministically + if (gitDirCreated) { + try { + await secureFs.rm(gitDirPath, { recursive: true, force: true }); + } catch { + // best-effort cleanup; ignore errors + } + } + throw error; + } res.json({ success: true, diff --git a/apps/server/src/routes/worktree/routes/list-branches.ts b/apps/server/src/routes/worktree/routes/list-branches.ts index ca2a33c47..c4ceea216 100644 --- a/apps/server/src/routes/worktree/routes/list-branches.ts +++ b/apps/server/src/routes/worktree/routes/list-branches.ts @@ -6,12 +6,11 @@ */ import type { Request, Response } from 'express'; -import { exec, execFile } from 'child_process'; +import { execFile } from 'child_process'; import { promisify } from 'util'; -import { getErrorMessage, logWorktreeError } from '../common.js'; +import { getErrorMessage, logWorktreeError, execGitCommand } from '../common.js'; import { getRemotesWithBranch } from '../../../services/worktree-service.js'; -const execAsync = promisify(exec); const execFileAsync = promisify(execFile); interface BranchInfo { @@ -36,18 +35,18 @@ export function createListBranchesHandler() { return; } - // Get current branch - const { stdout: currentBranchOutput } = await execAsync('git rev-parse --abbrev-ref HEAD', { - cwd: worktreePath, - }); + // Get current branch (execGitCommand avoids spawning /bin/sh; works in sandboxed CI) + const currentBranchOutput = await execGitCommand( + ['rev-parse', '--abbrev-ref', 'HEAD'], + worktreePath + ); const currentBranch = currentBranchOutput.trim(); // List all local branches - // Use double quotes around the format string for cross-platform compatibility - // Single quotes are preserved literally on Windows; double quotes work on both - const { stdout: branchesOutput } = await execAsync('git branch --format="%(refname:short)"', { - cwd: worktreePath, - }); + const branchesOutput = await execGitCommand( + ['branch', '--format=%(refname:short)'], + worktreePath + ); const branches: BranchInfo[] = branchesOutput .trim() @@ -68,18 +67,15 @@ export function createListBranchesHandler() { try { // Fetch latest remote refs (silently, don't fail if offline) try { - await execAsync('git fetch --all --quiet', { - cwd: worktreePath, - timeout: 10000, // 10 second timeout - }); + await execGitCommand(['fetch', '--all', '--quiet'], worktreePath); } catch { // Ignore fetch errors - we'll use cached remote refs } // List remote branches - const { stdout: remoteBranchesOutput } = await execAsync( - 'git branch -r --format="%(refname:short)"', - { cwd: worktreePath } + const remoteBranchesOutput = await execGitCommand( + ['branch', '-r', '--format=%(refname:short)'], + worktreePath ); const localBranchNames = new Set(branches.map((b) => b.name)); @@ -118,9 +114,7 @@ export function createListBranchesHandler() { // Check if any remotes are configured for this repository let hasAnyRemotes = false; try { - const { stdout: remotesOutput } = await execAsync('git remote', { - cwd: worktreePath, - }); + const remotesOutput = await execGitCommand(['remote'], worktreePath); hasAnyRemotes = remotesOutput.trim().length > 0; } catch { // If git remote fails, assume no remotes diff --git a/apps/server/src/routes/worktree/routes/list.ts b/apps/server/src/routes/worktree/routes/list.ts index 333ba7c21..38712691d 100644 --- a/apps/server/src/routes/worktree/routes/list.ts +++ b/apps/server/src/routes/worktree/routes/list.ts @@ -13,7 +13,14 @@ import { promisify } from 'util'; import path from 'path'; import * as secureFs from '../../../lib/secure-fs.js'; import { isGitRepo } from '@automaker/git-utils'; -import { getErrorMessage, logError, normalizePath, execEnv, isGhCliAvailable } from '../common.js'; +import { + getErrorMessage, + logError, + normalizePath, + execEnv, + isGhCliAvailable, + execGitCommand, +} from '../common.js'; import { readAllWorktreeMetadata, updateWorktreePRInfo, @@ -29,6 +36,22 @@ import { const execAsync = promisify(exec); const logger = createLogger('Worktree'); +/** True when git (or shell) could not be spawned (e.g. ENOENT in sandboxed CI). */ +function isSpawnENOENT(error: unknown): boolean { + if (!error || typeof error !== 'object') return false; + const e = error as { code?: string; errno?: number; syscall?: string }; + // Accept ENOENT with or without syscall so wrapped/reexported errors are handled. + // Node may set syscall to 'spawn' or 'spawn git' (or other command name). + if (e.code === 'ENOENT' || e.errno === -2) { + return ( + e.syscall === 'spawn' || + (typeof e.syscall === 'string' && e.syscall.startsWith('spawn')) || + e.syscall === undefined + ); + } + return false; +} + /** * Cache for GitHub remote status per project path. * This prevents repeated "no git remotes found" warnings when polling @@ -64,6 +87,8 @@ interface WorktreeInfo { conflictType?: 'merge' | 'rebase' | 'cherry-pick'; /** List of files with conflicts */ conflictFiles?: string[]; + /** Source branch involved in merge/rebase/cherry-pick, when resolvable */ + conflictSourceBranch?: string; } /** @@ -75,13 +100,11 @@ async function detectConflictState(worktreePath: string): Promise<{ hasConflicts: boolean; conflictType?: 'merge' | 'rebase' | 'cherry-pick'; conflictFiles?: string[]; + conflictSourceBranch?: string; }> { try { - // Find the canonical .git directory for this worktree - const { stdout: gitDirRaw } = await execAsync('git rev-parse --git-dir', { - cwd: worktreePath, - timeout: 15000, - }); + // Find the canonical .git directory for this worktree (execGitCommand avoids /bin/sh in CI) + const gitDirRaw = await execGitCommand(['rev-parse', '--git-dir'], worktreePath); const gitDir = path.resolve(worktreePath, gitDirRaw.trim()); // Check for merge, rebase, and cherry-pick state files/directories @@ -121,10 +144,10 @@ async function detectConflictState(worktreePath: string): Promise<{ // Get list of conflicted files using machine-readable git status let conflictFiles: string[] = []; try { - const { stdout: statusOutput } = await execAsync('git diff --name-only --diff-filter=U', { - cwd: worktreePath, - timeout: 15000, - }); + const statusOutput = await execGitCommand( + ['diff', '--name-only', '--diff-filter=U'], + worktreePath + ); conflictFiles = statusOutput .trim() .split('\n') @@ -133,10 +156,84 @@ async function detectConflictState(worktreePath: string): Promise<{ // Fall back to empty list if diff fails } + // Detect the source branch involved in the conflict + let conflictSourceBranch: string | undefined; + try { + if (conflictType === 'merge' && mergeHeadExists) { + // For merges, resolve MERGE_HEAD to a branch name + const mergeHead = ( + (await secureFs.readFile(path.join(gitDir, 'MERGE_HEAD'), 'utf-8')) as string + ).trim(); + try { + const branchName = await execGitCommand( + ['name-rev', '--name-only', '--refs=refs/heads/*', mergeHead], + worktreePath + ); + const cleaned = branchName.trim().replace(/~\d+$/, ''); + if (cleaned && cleaned !== 'undefined') { + conflictSourceBranch = cleaned; + } + } catch { + // Could not resolve to branch name + } + } else if (conflictType === 'rebase') { + // For rebases, read the onto branch from rebase-merge/head-name or rebase-apply/head-name + const headNamePath = rebaseMergeExists + ? path.join(gitDir, 'rebase-merge', 'onto-name') + : path.join(gitDir, 'rebase-apply', 'onto-name'); + try { + const ontoName = ((await secureFs.readFile(headNamePath, 'utf-8')) as string).trim(); + if (ontoName) { + conflictSourceBranch = ontoName.replace(/^refs\/heads\//, ''); + } + } catch { + // onto-name may not exist; try to resolve the onto commit + try { + const ontoPath = rebaseMergeExists + ? path.join(gitDir, 'rebase-merge', 'onto') + : path.join(gitDir, 'rebase-apply', 'onto'); + const ontoCommit = ((await secureFs.readFile(ontoPath, 'utf-8')) as string).trim(); + if (ontoCommit) { + const branchName = await execGitCommand( + ['name-rev', '--name-only', '--refs=refs/heads/*', ontoCommit], + worktreePath + ); + const cleaned = branchName.trim().replace(/~\d+$/, ''); + if (cleaned && cleaned !== 'undefined') { + conflictSourceBranch = cleaned; + } + } + } catch { + // Could not resolve onto commit + } + } + } else if (conflictType === 'cherry-pick' && cherryPickHeadExists) { + // For cherry-picks, try to resolve CHERRY_PICK_HEAD to a branch name + const cherryPickHead = ( + (await secureFs.readFile(path.join(gitDir, 'CHERRY_PICK_HEAD'), 'utf-8')) as string + ).trim(); + try { + const branchName = await execGitCommand( + ['name-rev', '--name-only', '--refs=refs/heads/*', cherryPickHead], + worktreePath + ); + const cleaned = branchName.trim().replace(/~\d+$/, ''); + if (cleaned && cleaned !== 'undefined') { + conflictSourceBranch = cleaned; + } + } catch { + // Could not resolve to branch name + } + } + } catch { + // Ignore source branch detection errors + } + return { hasConflicts: conflictFiles.length > 0, conflictType, conflictFiles, + conflictSourceBranch, }; } catch { // If anything fails, assume no conflicts @@ -146,13 +243,69 @@ async function detectConflictState(worktreePath: string): Promise<{ async function getCurrentBranch(cwd: string): Promise { try { - const { stdout } = await execAsync('git branch --show-current', { cwd }); + const stdout = await execGitCommand(['branch', '--show-current'], cwd); return stdout.trim(); } catch { return ''; } } +function normalizeBranchFromHeadRef(headRef: string): string | null { + let normalized = headRef.trim(); + const prefixes = ['refs/heads/', 'refs/remotes/origin/', 'refs/remotes/', 'refs/']; + + for (const prefix of prefixes) { + if (normalized.startsWith(prefix)) { + normalized = normalized.slice(prefix.length); + break; + } + } + + // Return the full branch name, including any slashes (e.g., "feature/my-branch") + return normalized || null; +} + +/** + * Attempt to recover the branch name for a worktree in detached HEAD state. + * This happens during rebase operations where git detaches HEAD from the branch. + * We look at git state files (rebase-merge/head-name, rebase-apply/head-name) + * to determine which branch the operation is targeting. + * + * Note: merge conflicts do NOT detach HEAD, so `git worktree list --porcelain` + * still includes the `branch` line for merge conflicts. This recovery is + * specifically for rebase and cherry-pick operations. + */ +async function recoverBranchForDetachedWorktree(worktreePath: string): Promise { + try { + const gitDirRaw = await execGitCommand(['rev-parse', '--git-dir'], worktreePath); + const gitDir = path.resolve(worktreePath, gitDirRaw.trim()); + + // During a rebase, the original branch is stored in rebase-merge/head-name + try { + const headNamePath = path.join(gitDir, 'rebase-merge', 'head-name'); + const headName = (await secureFs.readFile(headNamePath, 'utf-8')) as string; + const branch = normalizeBranchFromHeadRef(headName); + if (branch) return branch; + } catch { + // Not a rebase-merge + } + + // rebase-apply also stores the original branch in head-name + try { + const headNamePath = path.join(gitDir, 'rebase-apply', 'head-name'); + const headName = (await secureFs.readFile(headNamePath, 'utf-8')) as string; + const branch = normalizeBranchFromHeadRef(headName); + if (branch) return branch; + } catch { + // Not a rebase-apply + } + + return null; + } catch { + return null; + } +} + /** * Scan the .worktrees directory to discover worktrees that may exist on disk * but are not registered with git (e.g., created externally or corrupted state). @@ -204,22 +357,36 @@ async function scanWorktreesDirectory( }); } else { // Try to get branch from HEAD if branch --show-current fails (detached HEAD) + let headBranch: string | null = null; try { - const { stdout: headRef } = await execAsync('git rev-parse --abbrev-ref HEAD', { - cwd: worktreePath, - }); - const headBranch = headRef.trim(); - if (headBranch && headBranch !== 'HEAD') { - logger.info( - `Discovered worktree in .worktrees/ not in git worktree list: ${entry.name} (branch: ${headBranch})` - ); - discovered.push({ - path: normalizedPath, - branch: headBranch, - }); + const headRef = await execGitCommand( + ['rev-parse', '--abbrev-ref', 'HEAD'], + worktreePath + ); + const ref = headRef.trim(); + if (ref && ref !== 'HEAD') { + headBranch = ref; } - } catch { - // Can't determine branch, skip this directory + } catch (error) { + // Can't determine branch from HEAD ref (including timeout) - fall back to detached HEAD recovery + logger.debug( + `Failed to resolve HEAD ref for ${worktreePath}: ${getErrorMessage(error)}` + ); + } + + // If HEAD is detached (rebase/merge in progress), try recovery from git state files + if (!headBranch) { + headBranch = await recoverBranchForDetachedWorktree(worktreePath); + } + + if (headBranch) { + logger.info( + `Discovered worktree in .worktrees/ not in git worktree list: ${entry.name} (branch: ${headBranch})` + ); + discovered.push({ + path: normalizedPath, + branch: headBranch, + }); } } } @@ -378,15 +545,14 @@ export function createListHandler() { // Get current branch in main directory const currentBranch = await getCurrentBranch(projectPath); - // Get actual worktrees from git - const { stdout } = await execAsync('git worktree list --porcelain', { - cwd: projectPath, - }); + // Get actual worktrees from git (execGitCommand avoids /bin/sh in sandboxed CI) + const stdout = await execGitCommand(['worktree', 'list', '--porcelain'], projectPath); const worktrees: WorktreeInfo[] = []; const removedWorktrees: Array<{ path: string; branch: string }> = []; + let hasMissingWorktree = false; const lines = stdout.split('\n'); - let current: { path?: string; branch?: string } = {}; + let current: { path?: string; branch?: string; isDetached?: boolean } = {}; let isFirst = true; // First pass: detect removed worktrees @@ -395,8 +561,11 @@ export function createListHandler() { current.path = normalizePath(line.slice(9)); } else if (line.startsWith('branch ')) { current.branch = line.slice(7).replace('refs/heads/', ''); + } else if (line.startsWith('detached')) { + // Worktree is in detached HEAD state (e.g., during rebase) + current.isDetached = true; } else if (line === '') { - if (current.path && current.branch) { + if (current.path) { const isMainWorktree = isFirst; // Check if the worktree directory actually exists // Skip checking/pruning the main worktree (projectPath itself) @@ -407,19 +576,37 @@ export function createListHandler() { } catch { worktreeExists = false; } + if (!isMainWorktree && !worktreeExists) { + hasMissingWorktree = true; // Worktree directory doesn't exist - it was manually deleted - removedWorktrees.push({ + // Only add to removed list if we know the branch name + if (current.branch) { + removedWorktrees.push({ + path: current.path, + branch: current.branch, + }); + } + } else if (current.branch) { + // Normal case: worktree with a known branch + worktrees.push({ path: current.path, branch: current.branch, + isMain: isMainWorktree, + isCurrent: current.branch === currentBranch, + hasWorktree: true, }); - } else { - // Worktree exists (or is main worktree), add it to the list + isFirst = false; + } else if (current.isDetached && worktreeExists) { + // Detached HEAD (e.g., rebase in progress) - try to recover branch name. + // This is critical: without this, worktrees undergoing rebase/merge + // operations would silently disappear from the UI. + const recoveredBranch = await recoverBranchForDetachedWorktree(current.path); worktrees.push({ path: current.path, - branch: current.branch, + branch: recoveredBranch || `(detached)`, isMain: isMainWorktree, - isCurrent: current.branch === currentBranch, + isCurrent: false, hasWorktree: true, }); isFirst = false; @@ -429,10 +616,10 @@ export function createListHandler() { } } - // Prune removed worktrees from git (only if any were detected) - if (removedWorktrees.length > 0) { + // Prune removed worktrees from git (only if any missing worktrees were detected) + if (hasMissingWorktree) { try { - await execAsync('git worktree prune', { cwd: projectPath }); + await execGitCommand(['worktree', 'prune'], projectPath); } catch { // Prune failed, but we'll still report the removed worktrees } @@ -461,9 +648,7 @@ export function createListHandler() { if (includeDetails) { for (const worktree of worktrees) { try { - const { stdout: statusOutput } = await execAsync('git status --porcelain', { - cwd: worktree.path, - }); + const statusOutput = await execGitCommand(['status', '--porcelain'], worktree.path); const changedFiles = statusOutput .trim() .split('\n') @@ -486,13 +671,14 @@ export function createListHandler() { // hasConflicts is true only when there are actual unresolved files worktree.hasConflicts = conflictState.hasConflicts; worktree.conflictFiles = conflictState.conflictFiles; + worktree.conflictSourceBranch = conflictState.conflictSourceBranch; } catch { // Ignore conflict detection errors } } } - // Assign PR info to each worktree, preferring fresh GitHub data over cached metadata. + // Assign PR info to each worktree. // Only fetch GitHub PRs if includeDetails is requested (performance optimization). // Uses --state all to detect merged/closed PRs, limited to 1000 recent PRs. const githubPRs = includeDetails @@ -510,14 +696,27 @@ export function createListHandler() { const metadata = allMetadata.get(worktree.branch); const githubPR = githubPRs.get(worktree.branch); - if (githubPR) { - // Prefer fresh GitHub data (it has the current state) + const metadataPR = metadata?.pr; + // Preserve explicit user-selected PR tracking from metadata when it differs + // from branch-derived GitHub PR lookup. This allows "Change PR Number" to + // persist instead of being overwritten by gh pr list for the branch. + const hasManualOverride = + !!metadataPR && !!githubPR && metadataPR.number !== githubPR.number; + + if (hasManualOverride) { + worktree.pr = metadataPR; + } else if (githubPR) { + // Use fresh GitHub data when there is no explicit override. worktree.pr = githubPR; - // Sync metadata with GitHub state when: - // 1. No metadata exists for this PR (PR created externally) - // 2. State has changed (e.g., merged/closed on GitHub) - const needsSync = !metadata?.pr || metadata.pr.state !== githubPR.state; + // Sync metadata when missing or stale so fallback data stays current. + const needsSync = + !metadataPR || + metadataPR.number !== githubPR.number || + metadataPR.state !== githubPR.state || + metadataPR.title !== githubPR.title || + metadataPR.url !== githubPR.url || + metadataPR.createdAt !== githubPR.createdAt; if (needsSync) { // Fire and forget - don't block the response updateWorktreePRInfo(projectPath, worktree.branch, githubPR).catch((err) => { @@ -526,9 +725,9 @@ export function createListHandler() { ); }); } - } else if (metadata?.pr && metadata.pr.state === 'OPEN') { + } else if (metadataPR && metadataPR.state === 'OPEN') { // Fall back to stored metadata only if the PR is still OPEN - worktree.pr = metadata.pr; + worktree.pr = metadataPR; } } @@ -538,6 +737,26 @@ export function createListHandler() { removedWorktrees: removedWorktrees.length > 0 ? removedWorktrees : undefined, }); } catch (error) { + // When git is unavailable (e.g. sandboxed E2E, PATH without git), return minimal list so UI still loads + if (isSpawnENOENT(error)) { + const projectPathFromBody = (req.body as { projectPath?: string })?.projectPath; + const mainPath = projectPathFromBody ? normalizePath(projectPathFromBody) : undefined; + if (mainPath) { + res.json({ + success: true, + worktrees: [ + { + path: mainPath, + branch: 'main', + isMain: true, + isCurrent: true, + hasWorktree: true, + }, + ], + }); + return; + } + } logError(error, 'List worktrees failed'); res.status(500).json({ success: false, error: getErrorMessage(error) }); } diff --git a/apps/server/src/routes/worktree/routes/pull.ts b/apps/server/src/routes/worktree/routes/pull.ts index 3c9f06653..ccea76d14 100644 --- a/apps/server/src/routes/worktree/routes/pull.ts +++ b/apps/server/src/routes/worktree/routes/pull.ts @@ -23,9 +23,11 @@ import type { PullResult } from '../../../services/pull-service.js'; export function createPullHandler() { return async (req: Request, res: Response): Promise => { try { - const { worktreePath, remote, stashIfNeeded } = req.body as { + const { worktreePath, remote, remoteBranch, stashIfNeeded } = req.body as { worktreePath: string; remote?: string; + /** Specific remote branch to pull (e.g. 'main'). When provided, pulls this branch from the remote regardless of tracking config. */ + remoteBranch?: string; /** When true, automatically stash local changes before pulling and reapply after */ stashIfNeeded?: boolean; }; @@ -39,7 +41,7 @@ export function createPullHandler() { } // Execute the pull via the service - const result = await performPull(worktreePath, { remote, stashIfNeeded }); + const result = await performPull(worktreePath, { remote, remoteBranch, stashIfNeeded }); // Map service result to HTTP response mapResultToResponse(res, result); diff --git a/apps/server/src/services/agent-executor-types.ts b/apps/server/src/services/agent-executor-types.ts index e84964a11..ef0dcb68c 100644 --- a/apps/server/src/services/agent-executor-types.ts +++ b/apps/server/src/services/agent-executor-types.ts @@ -44,6 +44,8 @@ export interface AgentExecutionOptions { specAlreadyDetected?: boolean; existingApprovedPlanContent?: string; persistedTasks?: ParsedTask[]; + /** Feature status - used to check if pipeline summary extraction is required */ + status?: string; } export interface AgentExecutionResult { diff --git a/apps/server/src/services/agent-executor.ts b/apps/server/src/services/agent-executor.ts index f79307665..15731b928 100644 --- a/apps/server/src/services/agent-executor.ts +++ b/apps/server/src/services/agent-executor.ts @@ -4,6 +4,7 @@ import path from 'path'; import type { ExecuteOptions, ParsedTask } from '@automaker/types'; +import { isPipelineStatus } from '@automaker/types'; import { buildPromptWithImages, createLogger, isAuthenticationError } from '@automaker/utils'; import { getFeatureDir } from '@automaker/platform'; import * as secureFs from '../lib/secure-fs.js'; @@ -91,6 +92,7 @@ export class AgentExecutor { existingApprovedPlanContent, persistedTasks, credentials, + status, // Feature status for pipeline summary check claudeCompatibleProvider, mcpServers, sdkSessionId, @@ -207,6 +209,17 @@ export class AgentExecutor { if (writeTimeout) clearTimeout(writeTimeout); if (rawWriteTimeout) clearTimeout(rawWriteTimeout); await writeToFile(); + + // Extract and save summary from the new content generated in this session + await this.extractAndSaveSessionSummary( + projectPath, + featureId, + result.responseText, + previousContent, + callbacks, + status + ); + return { responseText: result.responseText, specDetected: true, @@ -340,9 +353,78 @@ export class AgentExecutor { } } } + + // Capture summary if it hasn't been captured by handleSpecGenerated or executeTasksLoop + // or if we're in a simple execution mode (planningMode='skip') + await this.extractAndSaveSessionSummary( + projectPath, + featureId, + responseText, + previousContent, + callbacks, + status + ); + return { responseText, specDetected, tasksCompleted, aborted }; } + /** + * Strip the follow-up session scaffold marker from content. + * The scaffold is added when resuming a session with previous content: + * "\n\n---\n\n## Follow-up Session\n\n" + * This ensures fallback summaries don't include the scaffold header. + * + * The regex pattern handles variations in whitespace while matching the + * scaffold structure: dashes followed by "## Follow-up Session" at the + * start of the content. + */ + private static stripFollowUpScaffold(content: string): string { + // Pattern matches: ^\s*---\s*##\s*Follow-up Session\s* + // - ^ = start of content (scaffold is always at the beginning of sessionContent) + // - \s* = any whitespace (handles \n\n before ---, spaces/tabs between markers) + // - --- = literal dashes + // - \s* = whitespace between dashes and heading + // - ## = heading marker + // - \s* = whitespace before "Follow-up" + // - Follow-up Session = literal heading text + // - \s* = trailing whitespace/newlines after heading + const scaffoldPattern = /^\s*---\s*##\s*Follow-up Session\s*/; + return content.replace(scaffoldPattern, ''); + } + + /** + * Extract summary ONLY from the new content generated in this session + * and save it via the provided callback. + */ + private async extractAndSaveSessionSummary( + projectPath: string, + featureId: string, + responseText: string, + previousContent: string | undefined, + callbacks: AgentExecutorCallbacks, + status?: string + ): Promise { + const sessionContent = responseText.substring(previousContent ? previousContent.length : 0); + const summary = extractSummary(sessionContent); + if (summary) { + await callbacks.saveFeatureSummary(projectPath, featureId, summary); + return; + } + + // If we're in a pipeline step, a summary is expected. Use a fallback if extraction fails. + if (isPipelineStatus(status)) { + // Strip any follow-up session scaffold before using as fallback + const cleanSessionContent = AgentExecutor.stripFollowUpScaffold(sessionContent); + const fallback = cleanSessionContent.trim(); + if (fallback) { + await callbacks.saveFeatureSummary(projectPath, featureId, fallback); + } + logger.warn( + `[AgentExecutor] Mandatory summary extraction failed for pipeline feature ${featureId} (status="${status}")` + ); + } + } + private async executeTasksLoop( options: AgentExecutionOptions, tasks: ParsedTask[], @@ -439,14 +521,15 @@ export class AgentExecutor { } } if (!taskCompleteDetected) { - const cid = detectTaskCompleteMarker(taskOutput); - if (cid) { + const completeMarker = detectTaskCompleteMarker(taskOutput); + if (completeMarker) { taskCompleteDetected = true; await this.featureStateManager.updateTaskStatus( projectPath, featureId, - cid, - 'completed' + completeMarker.id, + 'completed', + completeMarker.summary ); } } @@ -524,8 +607,6 @@ export class AgentExecutor { } } } - const summary = extractSummary(responseText); - if (summary) await callbacks.saveFeatureSummary(projectPath, featureId, summary); return { responseText, tasksCompleted, aborted: false }; } @@ -722,8 +803,6 @@ export class AgentExecutor { ); responseText = r.responseText; } - const summary = extractSummary(responseText); - if (summary) await callbacks.saveFeatureSummary(projectPath, featureId, summary); return { responseText, tasksCompleted }; } diff --git a/apps/server/src/services/auto-loop-coordinator.ts b/apps/server/src/services/auto-loop-coordinator.ts index 6d83e6994..ef4a91557 100644 --- a/apps/server/src/services/auto-loop-coordinator.ts +++ b/apps/server/src/services/auto-loop-coordinator.ts @@ -15,6 +15,12 @@ const logger = createLogger('AutoLoopCoordinator'); const CONSECUTIVE_FAILURE_THRESHOLD = 3; const FAILURE_WINDOW_MS = 60000; +// Sleep intervals for the auto-loop (in milliseconds) +const SLEEP_INTERVAL_CAPACITY_MS = 5000; +const SLEEP_INTERVAL_IDLE_MS = 10000; +const SLEEP_INTERVAL_NORMAL_MS = 2000; +const SLEEP_INTERVAL_ERROR_MS = 5000; + export interface AutoModeConfig { maxConcurrency: number; useWorktrees: boolean; @@ -169,20 +175,32 @@ export class AutoLoopCoordinator { // presence is accounted for when deciding whether to dispatch new auto-mode tasks. const runningCount = await this.getRunningCountForWorktree(projectPath, branchName); if (runningCount >= projectState.config.maxConcurrency) { - await this.sleep(5000, projectState.abortController.signal); + await this.sleep(SLEEP_INTERVAL_CAPACITY_MS, projectState.abortController.signal); continue; } const pendingFeatures = await this.loadPendingFeaturesFn(projectPath, branchName); if (pendingFeatures.length === 0) { if (runningCount === 0 && !projectState.hasEmittedIdleEvent) { - this.eventBus.emitAutoModeEvent('auto_mode_idle', { - message: 'No pending features - auto mode idle', + // Double-check that we have no features in 'in_progress' state that might + // have been released from the concurrency manager but not yet updated to + // their final status. This prevents auto_mode_idle from firing prematurely + // when features are transitioning states (e.g., during status update). + const hasInProgressFeatures = await this.hasInProgressFeaturesForWorktree( projectPath, - branchName, - }); - projectState.hasEmittedIdleEvent = true; + branchName + ); + + // Only emit auto_mode_idle if we're truly done with all features + if (!hasInProgressFeatures) { + this.eventBus.emitAutoModeEvent('auto_mode_idle', { + message: 'No pending features - auto mode idle', + projectPath, + branchName, + }); + projectState.hasEmittedIdleEvent = true; + } } - await this.sleep(10000, projectState.abortController.signal); + await this.sleep(SLEEP_INTERVAL_IDLE_MS, projectState.abortController.signal); continue; } @@ -228,10 +246,10 @@ export class AutoLoopCoordinator { } }); } - await this.sleep(2000, projectState.abortController.signal); + await this.sleep(SLEEP_INTERVAL_NORMAL_MS, projectState.abortController.signal); } catch { if (projectState.abortController.signal.aborted) break; - await this.sleep(5000, projectState.abortController.signal); + await this.sleep(SLEEP_INTERVAL_ERROR_MS, projectState.abortController.signal); } } projectState.isRunning = false; @@ -462,4 +480,48 @@ export class AutoLoopCoordinator { signal?.addEventListener('abort', onAbort); }); } + + /** + * Check if a feature belongs to the current worktree based on branch name. + * For main worktree (branchName === null or 'main'): includes features with no branchName or branchName === 'main'. + * For feature worktrees (branchName !== null and !== 'main'): only includes features with matching branchName. + */ + private featureBelongsToWorktree(feature: Feature, branchName: string | null): boolean { + const isMainWorktree = branchName === null || branchName === 'main'; + if (isMainWorktree) { + // Main worktree: include features with no branchName or branchName === 'main' + return !feature.branchName || feature.branchName === 'main'; + } else { + // Feature worktree: only include exact branch match + return feature.branchName === branchName; + } + } + + /** + * Check if there are features in 'in_progress' status for the current worktree. + * This prevents auto_mode_idle from firing prematurely when features are + * transitioning states (e.g., during status update from in_progress to completed). + */ + private async hasInProgressFeaturesForWorktree( + projectPath: string, + branchName: string | null + ): Promise { + if (!this.loadAllFeaturesFn) { + return false; + } + + try { + const allFeatures = await this.loadAllFeaturesFn(projectPath); + return allFeatures.some( + (f) => f.status === 'in_progress' && this.featureBelongsToWorktree(f, branchName) + ); + } catch (error) { + const errorInfo = classifyError(error); + logger.warn( + `Failed to load all features for idle check (projectPath=${projectPath}, branchName=${branchName}): ${errorInfo.message}`, + error + ); + return false; + } + } } diff --git a/apps/server/src/services/auto-mode/compat.ts b/apps/server/src/services/auto-mode/compat.ts index 97fe19e8b..ea911d9b8 100644 --- a/apps/server/src/services/auto-mode/compat.ts +++ b/apps/server/src/services/auto-mode/compat.ts @@ -232,9 +232,10 @@ export class AutoModeServiceCompat { } async detectOrphanedFeatures( - projectPath: string + projectPath: string, + preloadedFeatures?: Feature[] ): Promise> { const facade = this.createFacade(projectPath); - return facade.detectOrphanedFeatures(); + return facade.detectOrphanedFeatures(preloadedFeatures); } } diff --git a/apps/server/src/services/auto-mode/facade.ts b/apps/server/src/services/auto-mode/facade.ts index af660ea57..db4dccdc9 100644 --- a/apps/server/src/services/auto-mode/facade.ts +++ b/apps/server/src/services/auto-mode/facade.ts @@ -15,7 +15,12 @@ import path from 'path'; import { exec } from 'child_process'; import { promisify } from 'util'; import type { Feature, PlanningMode, ThinkingLevel, ReasoningEffort } from '@automaker/types'; -import { DEFAULT_MAX_CONCURRENCY, DEFAULT_MODELS, stripProviderPrefix } from '@automaker/types'; +import { + DEFAULT_MAX_CONCURRENCY, + DEFAULT_MODELS, + stripProviderPrefix, + isPipelineStatus, +} from '@automaker/types'; import { resolveModelString } from '@automaker/model-resolver'; import { createLogger, loadContextFiles, classifyError } from '@automaker/utils'; import { getFeatureDir } from '@automaker/platform'; @@ -23,7 +28,7 @@ import * as secureFs from '../../lib/secure-fs.js'; import { validateWorkingDirectory, createAutoModeOptions } from '../../lib/sdk-options.js'; import { getPromptCustomization, - getProviderByModelId, + resolveProviderContext, getMCPServersFromSettings, getDefaultMaxTurnsSetting, } from '../../lib/settings-helpers.js'; @@ -79,6 +84,37 @@ export class AutoModeServiceFacade { private readonly settingsService: SettingsService | null ) {} + /** + * Determine if a feature is eligible to be picked up by the auto-mode loop. + * + * @param feature - The feature to check + * @param branchName - The current worktree branch name (null for main) + * @param primaryBranch - The resolved primary branch name for the project + * @returns True if the feature is eligible for auto-dispatch + */ + public static isFeatureEligibleForAutoMode( + feature: Feature, + branchName: string | null, + primaryBranch: string | null + ): boolean { + const isEligibleStatus = + feature.status === 'backlog' || + feature.status === 'ready' || + feature.status === 'interrupted' || + isPipelineStatus(feature.status); + + if (!isEligibleStatus) return false; + + // Filter by branch/worktree alignment + if (branchName === null) { + // For main worktree, include features with no branch or matching primary branch + return !feature.branchName || (primaryBranch != null && feature.branchName === primaryBranch); + } else { + // For named worktrees, only include features matching that branch + return feature.branchName === branchName; + } + } + /** * Classify and log an error at the facade boundary. * Emits an error event to the UI so failures are surfaced to the user. @@ -190,8 +226,7 @@ export class AutoModeServiceFacade { /** * Shared agent-run helper used by both PipelineOrchestrator and ExecutionService. * - * Resolves the model string, looks up the custom provider/credentials via - * getProviderByModelId, then delegates to agentExecutor.execute with the + * Resolves provider/model context, then delegates to agentExecutor.execute with the * full payload. The opts parameter uses an index-signature union so it * accepts both the typed ExecutionService opts object and the looser * Record used by PipelineOrchestrator without requiring @@ -217,6 +252,7 @@ export class AutoModeServiceFacade { thinkingLevel?: ThinkingLevel; reasoningEffort?: ReasoningEffort; branchName?: string | null; + status?: string; // Feature status for pipeline summary check [key: string]: unknown; } ): Promise => { @@ -229,16 +265,19 @@ export class AutoModeServiceFacade { | import('@automaker/types').ClaudeCompatibleProvider | undefined; let credentials: import('@automaker/types').Credentials | undefined; + let providerResolvedModel: string | undefined; + if (settingsService) { - const providerResult = await getProviderByModelId( - resolvedModel, + const providerId = opts?.providerId as string | undefined; + const result = await resolveProviderContext( settingsService, + resolvedModel, + providerId, '[AutoModeFacade]' ); - if (providerResult.provider) { - claudeCompatibleProvider = providerResult.provider; - credentials = providerResult.credentials; - } + claudeCompatibleProvider = result.provider; + credentials = result.credentials; + providerResolvedModel = result.resolvedModel; } // Build sdkOptions with proper maxTurns and allowedTools for auto-mode. @@ -264,7 +303,7 @@ export class AutoModeServiceFacade { const sdkOpts = createAutoModeOptions({ cwd: workDir, - model: resolvedModel, + model: providerResolvedModel || resolvedModel, systemPrompt: opts?.systemPrompt, abortController, autoLoadClaudeMd, @@ -276,8 +315,14 @@ export class AutoModeServiceFacade { | undefined, }); + if (!sdkOpts) { + logger.error( + `[createRunAgentFn] sdkOpts is UNDEFINED! createAutoModeOptions type: ${typeof createAutoModeOptions}` + ); + } + logger.info( - `[createRunAgentFn] Feature ${featureId}: model=${resolvedModel}, ` + + `[createRunAgentFn] Feature ${featureId}: model=${resolvedModel} (resolved=${providerResolvedModel || resolvedModel}), ` + `maxTurns=${sdkOpts.maxTurns}, allowedTools=${(sdkOpts.allowedTools as string[])?.length ?? 'default'}, ` + `provider=${provider.getName()}` ); @@ -300,6 +345,7 @@ export class AutoModeServiceFacade { thinkingLevel: opts?.thinkingLevel as ThinkingLevel | undefined, reasoningEffort: opts?.reasoningEffort as ReasoningEffort | undefined, branchName: opts?.branchName as string | null | undefined, + status: opts?.status as string | undefined, provider, effectiveBareModel, credentials, @@ -373,12 +419,8 @@ export class AutoModeServiceFacade { if (branchName === null) { primaryBranch = await worktreeResolver.getCurrentBranch(pPath); } - return features.filter( - (f) => - (f.status === 'backlog' || f.status === 'ready') && - (branchName === null - ? !f.branchName || (primaryBranch && f.branchName === primaryBranch) - : f.branchName === branchName) + return features.filter((f) => + AutoModeServiceFacade.isFeatureEligibleForAutoMode(f, branchName, primaryBranch) ); }, (pPath, branchName, maxConcurrency) => @@ -421,9 +463,25 @@ export class AutoModeServiceFacade { (pPath, featureId, status) => featureStateManager.updateFeatureStatus(pPath, featureId, status), (pPath, featureId) => featureStateManager.loadFeature(pPath, featureId), - async (_feature) => { - // getPlanningPromptPrefixFn - planning prompts handled by AutoModeService - return ''; + async (feature) => { + // getPlanningPromptPrefixFn - select appropriate planning prompt based on feature's planningMode + if (!feature.planningMode || feature.planningMode === 'skip') { + return ''; + } + const prompts = await getPromptCustomization(settingsService, '[PlanningPromptPrefix]'); + const autoModePrompts = prompts.autoMode; + switch (feature.planningMode) { + case 'lite': + return feature.requirePlanApproval + ? autoModePrompts.planningLiteWithApproval + '\n\n' + : autoModePrompts.planningLite + '\n\n'; + case 'spec': + return autoModePrompts.planningSpec + '\n\n'; + case 'full': + return autoModePrompts.planningFull + '\n\n'; + default: + return ''; + } }, (pPath, featureId, summary) => featureStateManager.saveFeatureSummary(pPath, featureId, summary), @@ -1075,12 +1133,13 @@ export class AutoModeServiceFacade { /** * Detect orphaned features (features with missing branches) + * @param preloadedFeatures - Optional pre-loaded features to avoid redundant disk reads */ - async detectOrphanedFeatures(): Promise { + async detectOrphanedFeatures(preloadedFeatures?: Feature[]): Promise { const orphanedFeatures: OrphanedFeatureInfo[] = []; try { - const allFeatures = await this.featureLoader.getAll(this.projectPath); + const allFeatures = preloadedFeatures ?? (await this.featureLoader.getAll(this.projectPath)); const featuresWithBranches = allFeatures.filter( (f) => f.branchName && f.branchName.trim() !== '' ); diff --git a/apps/server/src/services/codex-model-cache-service.ts b/apps/server/src/services/codex-model-cache-service.ts index 7e171428c..0c9054036 100644 --- a/apps/server/src/services/codex-model-cache-service.ts +++ b/apps/server/src/services/codex-model-cache-service.ts @@ -193,7 +193,11 @@ export class CodexModelCacheService { * Infer tier from model ID */ private inferTier(modelId: string): 'premium' | 'standard' | 'basic' { - if (modelId.includes('max') || modelId.includes('gpt-5.2-codex')) { + if ( + modelId.includes('max') || + modelId.includes('gpt-5.2-codex') || + modelId.includes('gpt-5.3-codex') + ) { return 'premium'; } if (modelId.includes('mini')) { diff --git a/apps/server/src/services/dev-server-service.ts b/apps/server/src/services/dev-server-service.ts index c9637c424..6cef17dc8 100644 --- a/apps/server/src/services/dev-server-service.ts +++ b/apps/server/src/services/dev-server-service.ts @@ -13,6 +13,8 @@ import path from 'path'; import net from 'net'; import { createLogger } from '@automaker/utils'; import type { EventEmitter } from '../lib/events.js'; +import fs from 'fs/promises'; +import { constants } from 'fs'; const logger = createLogger('DevServerService'); @@ -110,6 +112,21 @@ export interface DevServerInfo { urlDetected: boolean; // Timer for URL detection timeout fallback urlDetectionTimeout: NodeJS.Timeout | null; + // Custom command used to start the server + customCommand?: string; +} + +/** + * Persistable subset of DevServerInfo for survival across server restarts + */ +interface PersistedDevServerInfo { + worktreePath: string; + allocatedPort: number; + port: number; + url: string; + startedAt: string; + urlDetected: boolean; + customCommand?: string; } // Port allocation starts at 3001 to avoid conflicts with common dev ports @@ -121,8 +138,20 @@ const LIVERELOAD_PORTS = [35729, 35730, 35731] as const; class DevServerService { private runningServers: Map = new Map(); + private startingServers: Set = new Set(); private allocatedPorts: Set = new Set(); private emitter: EventEmitter | null = null; + private dataDir: string | null = null; + private saveQueue: Promise = Promise.resolve(); + + /** + * Initialize the service with data directory for persistence + */ + async initialize(dataDir: string, emitter: EventEmitter): Promise { + this.dataDir = dataDir; + this.emitter = emitter; + await this.loadState(); + } /** * Set the event emitter for streaming log events @@ -132,6 +161,131 @@ class DevServerService { this.emitter = emitter; } + /** + * Save the current state of running servers to disk + */ + private async saveState(): Promise { + if (!this.dataDir) return; + + // Queue the save operation to prevent concurrent writes + this.saveQueue = this.saveQueue + .then(async () => { + if (!this.dataDir) return; + try { + const statePath = path.join(this.dataDir, 'dev-servers.json'); + const persistedInfo: PersistedDevServerInfo[] = Array.from( + this.runningServers.values() + ).map((s) => ({ + worktreePath: s.worktreePath, + allocatedPort: s.allocatedPort, + port: s.port, + url: s.url, + startedAt: s.startedAt.toISOString(), + urlDetected: s.urlDetected, + customCommand: s.customCommand, + })); + + await fs.writeFile(statePath, JSON.stringify(persistedInfo, null, 2)); + logger.debug(`Saved dev server state to ${statePath}`); + } catch (error) { + logger.error('Failed to save dev server state:', error); + } + }) + .catch((error) => { + logger.error('Error in save queue:', error); + }); + + return this.saveQueue; + } + + /** + * Load the state of running servers from disk + */ + private async loadState(): Promise { + if (!this.dataDir) return; + + try { + const statePath = path.join(this.dataDir, 'dev-servers.json'); + try { + await fs.access(statePath, constants.F_OK); + } catch { + // File doesn't exist, which is fine + return; + } + + const content = await fs.readFile(statePath, 'utf-8'); + const rawParsed: unknown = JSON.parse(content); + + if (!Array.isArray(rawParsed)) { + logger.warn('Dev server state file is not an array, skipping load'); + return; + } + + const persistedInfo: PersistedDevServerInfo[] = rawParsed.filter((entry: unknown) => { + if (entry === null || typeof entry !== 'object') { + logger.warn('Dropping invalid dev server entry (not an object):', entry); + return false; + } + const e = entry as Record; + const valid = + typeof e.worktreePath === 'string' && + e.worktreePath.length > 0 && + typeof e.allocatedPort === 'number' && + Number.isInteger(e.allocatedPort) && + e.allocatedPort >= 1 && + e.allocatedPort <= 65535 && + typeof e.port === 'number' && + Number.isInteger(e.port) && + e.port >= 1 && + e.port <= 65535 && + typeof e.url === 'string' && + typeof e.startedAt === 'string' && + typeof e.urlDetected === 'boolean' && + (e.customCommand === undefined || typeof e.customCommand === 'string'); + if (!valid) { + logger.warn('Dropping malformed dev server entry:', e); + } + return valid; + }) as PersistedDevServerInfo[]; + + logger.info(`Loading ${persistedInfo.length} dev servers from state`); + + for (const info of persistedInfo) { + // Check if the process is still running on the port + // Since we can't reliably re-attach to the process for output, + // we'll just check if the port is in use. + const portInUse = !(await this.isPortAvailable(info.port)); + + if (portInUse) { + logger.info(`Re-attached to dev server on port ${info.port} for ${info.worktreePath}`); + const serverInfo: DevServerInfo = { + ...info, + startedAt: new Date(info.startedAt), + process: null, // Process object is lost, but we know it's running + scrollbackBuffer: '', + outputBuffer: '', + flushTimeout: null, + stopping: false, + urlDetectionTimeout: null, + }; + this.runningServers.set(info.worktreePath, serverInfo); + this.allocatedPorts.add(info.allocatedPort); + } else { + logger.info( + `Dev server on port ${info.port} for ${info.worktreePath} is no longer running` + ); + } + } + + // Cleanup stale entries from the file if any + if (this.runningServers.size !== persistedInfo.length) { + await this.saveState(); + } + } catch (error) { + logger.error('Failed to load dev server state:', error); + } + } + /** * Prune a stale server entry whose process has exited without cleanup. * Clears any pending timers, removes the port from allocatedPorts, deletes @@ -148,6 +302,10 @@ class DevServerService { // been mutated by detectUrlFromOutput to reflect the actual detected port. this.allocatedPorts.delete(server.allocatedPort); this.runningServers.delete(worktreePath); + + // Persist state change + this.saveState().catch((err) => logger.error('Failed to save state in pruneStaleServer:', err)); + if (this.emitter) { this.emitter.emit('dev-server:stopped', { worktreePath, @@ -249,7 +407,7 @@ class DevServerService { * - PHP: "Development Server (http://localhost:8000) started" * - Generic: Any localhost URL with a port */ - private detectUrlFromOutput(server: DevServerInfo, content: string): void { + private async detectUrlFromOutput(server: DevServerInfo, content: string): Promise { // Skip if URL already detected if (server.urlDetected) { return; @@ -304,6 +462,11 @@ class DevServerService { logger.info(`Detected server URL via ${description}: ${detectedUrl}`); + // Persist state change + await this.saveState().catch((err) => + logger.error('Failed to save state in detectUrlFromOutput:', err) + ); + // Emit URL update event if (this.emitter) { this.emitter.emit('dev-server:url-detected', { @@ -346,6 +509,11 @@ class DevServerService { logger.info(`Detected server port via ${description}: ${detectedPort} → ${detectedUrl}`); + // Persist state change + await this.saveState().catch((err) => + logger.error('Failed to save state in detectUrlFromOutput Phase 2:', err) + ); + // Emit URL update event if (this.emitter) { this.emitter.emit('dev-server:url-detected', { @@ -365,7 +533,7 @@ class DevServerService { * Handle incoming stdout/stderr data from dev server process * Buffers data for scrollback replay and schedules throttled emission */ - private handleProcessOutput(server: DevServerInfo, data: Buffer): void { + private async handleProcessOutput(server: DevServerInfo, data: Buffer): Promise { // Skip output if server is stopping if (server.stopping) { return; @@ -374,7 +542,7 @@ class DevServerService { const content = data.toString(); // Try to detect actual server URL from output - this.detectUrlFromOutput(server, content); + await this.detectUrlFromOutput(server, content); // Append to scrollback buffer for replay on reconnect this.appendToScrollback(server, content); @@ -594,261 +762,305 @@ class DevServerService { }; error?: string; }> { - // Check if already running - if (this.runningServers.has(worktreePath)) { - const existing = this.runningServers.get(worktreePath)!; - return { - success: true, - result: { - worktreePath: existing.worktreePath, - port: existing.port, - url: existing.url, - message: `Dev server already running on port ${existing.port}`, - }, - }; - } - - // Verify the worktree exists - if (!(await this.fileExists(worktreePath))) { + // Check if already running or starting + if (this.runningServers.has(worktreePath) || this.startingServers.has(worktreePath)) { + const existing = this.runningServers.get(worktreePath); + if (existing) { + return { + success: true, + result: { + worktreePath: existing.worktreePath, + port: existing.port, + url: existing.url, + message: `Dev server already running on port ${existing.port}`, + }, + }; + } return { success: false, - error: `Worktree path does not exist: ${worktreePath}`, + error: 'Dev server is already starting', }; } - // Determine the dev command to use - let devCommand: { cmd: string; args: string[] }; - - // Normalize custom command: trim whitespace and treat empty strings as undefined - const normalizedCustomCommand = customCommand?.trim(); + this.startingServers.add(worktreePath); - if (normalizedCustomCommand) { - // Use the provided custom command - devCommand = this.parseCustomCommand(normalizedCustomCommand); - if (!devCommand.cmd) { + try { + // Verify the worktree exists + if (!(await this.fileExists(worktreePath))) { return { success: false, - error: 'Invalid custom command: command cannot be empty', + error: `Worktree path does not exist: ${worktreePath}`, }; } - logger.debug(`Using custom command: ${normalizedCustomCommand}`); - } else { - // Check for package.json when auto-detecting - const packageJsonPath = path.join(worktreePath, 'package.json'); - if (!(await this.fileExists(packageJsonPath))) { - return { - success: false, - error: `No package.json found in: ${worktreePath}`, - }; + + // Determine the dev command to use + let devCommand: { cmd: string; args: string[] }; + + // Normalize custom command: trim whitespace and treat empty strings as undefined + const normalizedCustomCommand = customCommand?.trim(); + + if (normalizedCustomCommand) { + // Use the provided custom command + devCommand = this.parseCustomCommand(normalizedCustomCommand); + if (!devCommand.cmd) { + return { + success: false, + error: 'Invalid custom command: command cannot be empty', + }; + } + logger.debug(`Using custom command: ${normalizedCustomCommand}`); + } else { + // Check for package.json when auto-detecting + const packageJsonPath = path.join(worktreePath, 'package.json'); + if (!(await this.fileExists(packageJsonPath))) { + return { + success: false, + error: `No package.json found in: ${worktreePath}`, + }; + } + + // Get dev command from package manager detection + const detectedCommand = await this.getDevCommand(worktreePath); + if (!detectedCommand) { + return { + success: false, + error: `Could not determine dev command for: ${worktreePath}`, + }; + } + devCommand = detectedCommand; } - // Get dev command from package manager detection - const detectedCommand = await this.getDevCommand(worktreePath); - if (!detectedCommand) { + // Find available port + let port: number; + try { + port = await this.findAvailablePort(); + } catch (error) { return { success: false, - error: `Could not determine dev command for: ${worktreePath}`, + error: error instanceof Error ? error.message : 'Port allocation failed', }; } - devCommand = detectedCommand; - } - // Find available port - let port: number; - try { - port = await this.findAvailablePort(); - } catch (error) { - return { - success: false, - error: error instanceof Error ? error.message : 'Port allocation failed', - }; - } + // Reserve the port (port was already force-killed in findAvailablePort) + this.allocatedPorts.add(port); - // Reserve the port (port was already force-killed in findAvailablePort) - this.allocatedPorts.add(port); + // Also kill common related ports (livereload, etc.) + // Some dev servers use fixed ports for HMR/livereload regardless of main port + for (const relatedPort of LIVERELOAD_PORTS) { + this.killProcessOnPort(relatedPort); + } - // Also kill common related ports (livereload, etc.) - // Some dev servers use fixed ports for HMR/livereload regardless of main port - for (const relatedPort of LIVERELOAD_PORTS) { - this.killProcessOnPort(relatedPort); - } + // Small delay to ensure related ports are freed + await new Promise((resolve) => setTimeout(resolve, 100)); - // Small delay to ensure related ports are freed - await new Promise((resolve) => setTimeout(resolve, 100)); - - logger.info(`Starting dev server on port ${port}`); - logger.debug(`Working directory (cwd): ${worktreePath}`); - logger.debug(`Command: ${devCommand.cmd} ${devCommand.args.join(' ')} with PORT=${port}`); - - // Spawn the dev process with PORT environment variable - // FORCE_COLOR enables colored output even when not running in a TTY - const env = { - ...process.env, - PORT: String(port), - FORCE_COLOR: '1', - // Some tools use these additional env vars for color detection - COLORTERM: 'truecolor', - TERM: 'xterm-256color', - }; + logger.info(`Starting dev server on port ${port}`); + logger.debug(`Working directory (cwd): ${worktreePath}`); + logger.debug(`Command: ${devCommand.cmd} ${devCommand.args.join(' ')} with PORT=${port}`); - const devProcess = spawn(devCommand.cmd, devCommand.args, { - cwd: worktreePath, - env, - stdio: ['ignore', 'pipe', 'pipe'], - detached: false, - }); + // Emit starting only after preflight checks pass to avoid dangling starting state. + if (this.emitter) { + this.emitter.emit('dev-server:starting', { + worktreePath, + timestamp: new Date().toISOString(), + }); + } - // Track if process failed early using object to work around TypeScript narrowing - const status = { error: null as string | null, exited: false }; - - // Create server info early so we can reference it in handlers - // We'll add it to runningServers after verifying the process started successfully - const hostname = process.env.HOSTNAME || 'localhost'; - const serverInfo: DevServerInfo = { - worktreePath, - allocatedPort: port, // Immutable: records which port we reserved; never changed after this point - port, - url: `http://${hostname}:${port}`, // Initial URL, may be updated by detectUrlFromOutput - process: devProcess, - startedAt: new Date(), - scrollbackBuffer: '', - outputBuffer: '', - flushTimeout: null, - stopping: false, - urlDetected: false, // Will be set to true when actual URL is detected from output - urlDetectionTimeout: null, // Will be set after server starts successfully - }; + // Spawn the dev process with PORT environment variable + // FORCE_COLOR enables colored output even when not running in a TTY + const env = { + ...process.env, + PORT: String(port), + FORCE_COLOR: '1', + // Some tools use these additional env vars for color detection + COLORTERM: 'truecolor', + TERM: 'xterm-256color', + }; - // Capture stdout with buffer management and event emission - if (devProcess.stdout) { - devProcess.stdout.on('data', (data: Buffer) => { - this.handleProcessOutput(serverInfo, data); + const devProcess = spawn(devCommand.cmd, devCommand.args, { + cwd: worktreePath, + env, + stdio: ['ignore', 'pipe', 'pipe'], + detached: false, }); - } - // Capture stderr with buffer management and event emission - if (devProcess.stderr) { - devProcess.stderr.on('data', (data: Buffer) => { - this.handleProcessOutput(serverInfo, data); - }); - } + // Track if process failed early using object to work around TypeScript narrowing + const status = { error: null as string | null, exited: false }; - // Helper to clean up resources and emit stop event - const cleanupAndEmitStop = (exitCode: number | null, errorMessage?: string) => { - if (serverInfo.flushTimeout) { - clearTimeout(serverInfo.flushTimeout); - serverInfo.flushTimeout = null; - } + // Create server info early so we can reference it in handlers + // We'll add it to runningServers after verifying the process started successfully + const fallbackHost = 'localhost'; + const serverInfo: DevServerInfo = { + worktreePath, + allocatedPort: port, // Immutable: records which port we reserved; never changed after this point + port, + url: `http://${fallbackHost}:${port}`, // Initial URL, may be updated by detectUrlFromOutput + process: devProcess, + startedAt: new Date(), + scrollbackBuffer: '', + outputBuffer: '', + flushTimeout: null, + stopping: false, + urlDetected: false, // Will be set to true when actual URL is detected from output + urlDetectionTimeout: null, // Will be set after server starts successfully + customCommand: normalizedCustomCommand, + }; - // Clear URL detection timeout to prevent stale fallback emission - if (serverInfo.urlDetectionTimeout) { - clearTimeout(serverInfo.urlDetectionTimeout); - serverInfo.urlDetectionTimeout = null; + // Capture stdout with buffer management and event emission + if (devProcess.stdout) { + devProcess.stdout.on('data', (data: Buffer) => { + this.handleProcessOutput(serverInfo, data).catch((error: unknown) => { + logger.error('Failed to handle dev server stdout output:', error); + }); + }); } - // Emit stopped event (only if not already stopping - prevents duplicate events) - if (this.emitter && !serverInfo.stopping) { - this.emitter.emit('dev-server:stopped', { - worktreePath, - port: serverInfo.port, // Use the detected port (may differ from allocated port if detectUrlFromOutput updated it) - exitCode, - error: errorMessage, - timestamp: new Date().toISOString(), + // Capture stderr with buffer management and event emission + if (devProcess.stderr) { + devProcess.stderr.on('data', (data: Buffer) => { + this.handleProcessOutput(serverInfo, data).catch((error: unknown) => { + logger.error('Failed to handle dev server stderr output:', error); + }); }); } - this.allocatedPorts.delete(serverInfo.allocatedPort); - this.runningServers.delete(worktreePath); - }; - - devProcess.on('error', (error) => { - logger.error(`Process error:`, error); - status.error = error.message; - cleanupAndEmitStop(null, error.message); - }); + // Helper to clean up resources and emit stop event + const cleanupAndEmitStop = (exitCode: number | null, errorMessage?: string) => { + if (serverInfo.flushTimeout) { + clearTimeout(serverInfo.flushTimeout); + serverInfo.flushTimeout = null; + } - devProcess.on('exit', (code) => { - logger.info(`Process for ${worktreePath} exited with code ${code}`); - status.exited = true; - cleanupAndEmitStop(code); - }); + // Clear URL detection timeout to prevent stale fallback emission + if (serverInfo.urlDetectionTimeout) { + clearTimeout(serverInfo.urlDetectionTimeout); + serverInfo.urlDetectionTimeout = null; + } - // Wait a moment to see if the process fails immediately - await new Promise((resolve) => setTimeout(resolve, 500)); + // Emit stopped event (only if not already stopping - prevents duplicate events) + if (this.emitter && !serverInfo.stopping) { + this.emitter.emit('dev-server:stopped', { + worktreePath, + port: serverInfo.port, // Use the detected port (may differ from allocated port if detectUrlFromOutput updated it) + exitCode, + error: errorMessage, + timestamp: new Date().toISOString(), + }); + } - if (status.error) { - return { - success: false, - error: `Failed to start dev server: ${status.error}`, - }; - } + this.allocatedPorts.delete(serverInfo.allocatedPort); + this.runningServers.delete(worktreePath); - if (status.exited) { - return { - success: false, - error: `Dev server process exited immediately. Check server logs for details.`, + // Persist state change + this.saveState().catch((err) => logger.error('Failed to save state in cleanup:', err)); }; - } - // Server started successfully - add to running servers map - this.runningServers.set(worktreePath, serverInfo); + devProcess.on('error', (error) => { + logger.error(`Process error:`, error); + status.error = error.message; + cleanupAndEmitStop(null, error.message); + }); - // Emit started event for WebSocket subscribers - if (this.emitter) { - this.emitter.emit('dev-server:started', { - worktreePath, - port, - url: serverInfo.url, - timestamp: new Date().toISOString(), + devProcess.on('exit', (code) => { + logger.info(`Process for ${worktreePath} exited with code ${code}`); + status.exited = true; + cleanupAndEmitStop(code); }); - } - // Set up URL detection timeout fallback. - // If URL detection hasn't succeeded after URL_DETECTION_TIMEOUT_MS, check if - // the allocated port is actually in use (server probably started successfully) - // and emit a url-detected event with the allocated port as fallback. - // Also re-scan the scrollback buffer in case the URL was printed before - // our patterns could match (e.g., it was split across multiple data chunks). - serverInfo.urlDetectionTimeout = setTimeout(() => { - serverInfo.urlDetectionTimeout = null; - - // Only run fallback if server is still running and URL wasn't detected - if (serverInfo.stopping || serverInfo.urlDetected || !this.runningServers.has(worktreePath)) { - return; + // Wait a moment to see if the process fails immediately + await new Promise((resolve) => setTimeout(resolve, 500)); + + if (status.error) { + return { + success: false, + error: `Failed to start dev server: ${status.error}`, + }; } - // Re-scan the entire scrollback buffer for URL patterns - // This catches cases where the URL was split across multiple output chunks - logger.info(`URL detection timeout for ${worktreePath}, re-scanning scrollback buffer`); - this.detectUrlFromOutput(serverInfo, serverInfo.scrollbackBuffer); + if (status.exited) { + return { + success: false, + error: `Dev server process exited immediately. Check server logs for details.`, + }; + } - // If still not detected after full rescan, use the allocated port as fallback - if (!serverInfo.urlDetected) { - logger.info(`URL detection fallback: using allocated port ${port} for ${worktreePath}`); - const fallbackUrl = `http://${hostname}:${port}`; - serverInfo.url = fallbackUrl; - serverInfo.urlDetected = true; + // Server started successfully - add to running servers map + this.runningServers.set(worktreePath, serverInfo); - if (this.emitter) { - this.emitter.emit('dev-server:url-detected', { - worktreePath, - url: fallbackUrl, - port, - timestamp: new Date().toISOString(), - }); - } + // Persist state change + await this.saveState().catch((err) => + logger.error('Failed to save state in startDevServer:', err) + ); + + // Emit started event for WebSocket subscribers + if (this.emitter) { + this.emitter.emit('dev-server:started', { + worktreePath, + port, + url: serverInfo.url, + timestamp: new Date().toISOString(), + }); } - }, URL_DETECTION_TIMEOUT_MS); - return { - success: true, - result: { - worktreePath, - port, - url: `http://${hostname}:${port}`, - message: `Dev server started on port ${port}`, - }, - }; + // Set up URL detection timeout fallback. + // If URL detection hasn't succeeded after URL_DETECTION_TIMEOUT_MS, check if + // the allocated port is actually in use (server probably started successfully) + // and emit a url-detected event with the allocated port as fallback. + // Also re-scan the scrollback buffer in case the URL was printed before + // our patterns could match (e.g., it was split across multiple data chunks). + serverInfo.urlDetectionTimeout = setTimeout(async () => { + serverInfo.urlDetectionTimeout = null; + + // Only run fallback if server is still running and URL wasn't detected + if ( + serverInfo.stopping || + serverInfo.urlDetected || + !this.runningServers.has(worktreePath) + ) { + return; + } + + // Re-scan the entire scrollback buffer for URL patterns + // This catches cases where the URL was split across multiple output chunks + logger.info(`URL detection timeout for ${worktreePath}, re-scanning scrollback buffer`); + await this.detectUrlFromOutput(serverInfo, serverInfo.scrollbackBuffer).catch((err) => + logger.error('Failed to re-scan scrollback buffer:', err) + ); + + // If still not detected after full rescan, use the allocated port as fallback + if (!serverInfo.urlDetected) { + logger.info(`URL detection fallback: using allocated port ${port} for ${worktreePath}`); + const fallbackUrl = `http://${fallbackHost}:${port}`; + serverInfo.url = fallbackUrl; + serverInfo.urlDetected = true; + + // Persist state change + await this.saveState().catch((err) => + logger.error('Failed to save state in URL detection fallback:', err) + ); + + if (this.emitter) { + this.emitter.emit('dev-server:url-detected', { + worktreePath: serverInfo.worktreePath, + url: fallbackUrl, + port, + timestamp: new Date().toISOString(), + }); + } + } + }, URL_DETECTION_TIMEOUT_MS); + + return { + success: true, + result: { + worktreePath: serverInfo.worktreePath, + port: serverInfo.port, + url: serverInfo.url, + message: `Dev server started on port ${port}`, + }, + }; + } finally { + this.startingServers.delete(worktreePath); + } } /** @@ -904,9 +1116,11 @@ class DevServerService { }); } - // Kill the process + // Kill the process; persisted/re-attached entries may not have a process handle. if (server.process && !server.process.killed) { server.process.kill('SIGTERM'); + } else { + this.killProcessOnPort(server.port); } // Free the originally-reserved port slot (allocatedPort is immutable and always @@ -915,6 +1129,11 @@ class DevServerService { this.allocatedPorts.delete(server.allocatedPort); this.runningServers.delete(worktreePath); + // Persist state change + await this.saveState().catch((err) => + logger.error('Failed to save state in stopDevServer:', err) + ); + return { success: true, result: { diff --git a/apps/server/src/services/event-hook-service.ts b/apps/server/src/services/event-hook-service.ts index 376da964b..649735656 100644 --- a/apps/server/src/services/event-hook-service.ts +++ b/apps/server/src/services/event-hook-service.ts @@ -27,7 +27,11 @@ import type { EventHookTrigger, EventHookShellAction, EventHookHttpAction, + EventHookNtfyAction, + NtfyEndpointConfig, + EventHookContext, } from '@automaker/types'; +import { ntfyService, type NtfyContext } from './ntfy-service.js'; const execAsync = promisify(exec); const logger = createLogger('EventHooks'); @@ -38,19 +42,8 @@ const DEFAULT_SHELL_TIMEOUT = 30000; /** Default timeout for HTTP requests (10 seconds) */ const DEFAULT_HTTP_TIMEOUT = 10000; -/** - * Context available for variable substitution in hooks - */ -interface HookContext { - featureId?: string; - featureName?: string; - projectPath?: string; - projectName?: string; - error?: string; - errorType?: string; - timestamp: string; - eventType: EventHookTrigger; -} +// Use the shared EventHookContext type (aliased locally as HookContext for clarity) +type HookContext = EventHookContext; /** * Auto-mode event payload structure @@ -451,6 +444,8 @@ export class EventHookService { await this.executeShellHook(hook.action, context, hookName); } else if (hook.action.type === 'http') { await this.executeHttpHook(hook.action, context, hookName); + } else if (hook.action.type === 'ntfy') { + await this.executeNtfyHook(hook.action, context, hookName); } } catch (error) { logger.error(`Hook "${hookName}" failed:`, error); @@ -558,6 +553,89 @@ export class EventHookService { } } + /** + * Execute an ntfy.sh notification hook + */ + private async executeNtfyHook( + action: EventHookNtfyAction, + context: HookContext, + hookName: string + ): Promise { + if (!this.settingsService) { + logger.warn('Settings service not available for ntfy hook'); + return; + } + + // Get the endpoint configuration + const settings = await this.settingsService.getGlobalSettings(); + const endpoints = settings.ntfyEndpoints || []; + const endpoint = endpoints.find((e) => e.id === action.endpointId); + + if (!endpoint) { + logger.error(`Ntfy hook "${hookName}" references unknown endpoint: ${action.endpointId}`); + return; + } + + // Convert HookContext to NtfyContext + const ntfyContext: NtfyContext = { + featureId: context.featureId, + featureName: context.featureName, + projectPath: context.projectPath, + projectName: context.projectName, + error: context.error, + errorType: context.errorType, + timestamp: context.timestamp, + eventType: context.eventType, + }; + + // Build click URL with deep-link if project context is available + let clickUrl = action.clickUrl; + if (!clickUrl && endpoint.defaultClickUrl) { + clickUrl = endpoint.defaultClickUrl; + // If we have a project path and the click URL looks like the server URL, + // append deep-link path + if (context.projectPath && clickUrl) { + try { + const url = new URL(clickUrl); + // Add featureId as query param for deep linking to board with feature output modal + if (context.featureId) { + url.pathname = '/board'; + url.searchParams.set('featureId', context.featureId); + } else if (context.projectPath) { + url.pathname = '/board'; + } + clickUrl = url.toString(); + } catch (error) { + // If URL parsing fails, log warning and use as-is + logger.warn( + `Failed to parse defaultClickUrl "${clickUrl}" for deep linking: ${error instanceof Error ? error.message : String(error)}` + ); + } + } + } + + logger.info(`Executing ntfy hook "${hookName}" to endpoint "${endpoint.name}"`); + + const result = await ntfyService.sendNotification( + endpoint, + { + title: action.title, + body: action.body, + tags: action.tags, + emoji: action.emoji, + clickUrl, + priority: action.priority, + }, + ntfyContext + ); + + if (!result.success) { + logger.warn(`Ntfy hook "${hookName}" failed: ${result.error}`); + } else { + logger.info(`Ntfy hook "${hookName}" completed successfully`); + } + } + /** * Substitute {{variable}} placeholders in a string */ diff --git a/apps/server/src/services/execution-service.ts b/apps/server/src/services/execution-service.ts index 633ac8093..dc0555c5a 100644 --- a/apps/server/src/services/execution-service.ts +++ b/apps/server/src/services/execution-service.ts @@ -108,16 +108,14 @@ export class ExecutionService { return firstLine.length <= 60 ? firstLine : firstLine.substring(0, 57) + '...'; } - buildFeaturePrompt( - feature: Feature, - taskExecutionPrompts: { - implementationInstructions: string; - playwrightVerificationInstructions: string; - } - ): string { + /** + * Build feature description section (without implementation instructions). + * Used when planning mode is active — the planning prompt provides its own instructions. + */ + buildFeatureDescription(feature: Feature): string { const title = this.extractTitleFromDescription(feature.description); - let prompt = `## Feature Implementation Task + let prompt = `## Feature Task **Feature ID:** ${feature.id} **Title:** ${title} @@ -146,6 +144,18 @@ ${feature.spec} prompt += `\n**Context Images Attached:**\n${feature.imagePaths.length} image(s) attached:\n${imagesList}\n`; } + return prompt; + } + + buildFeaturePrompt( + feature: Feature, + taskExecutionPrompts: { + implementationInstructions: string; + playwrightVerificationInstructions: string; + } + ): string { + let prompt = this.buildFeatureDescription(feature); + prompt += feature.skipTests ? `\n${taskExecutionPrompts.implementationInstructions}` : `\n${taskExecutionPrompts.implementationInstructions}\n\n${taskExecutionPrompts.playwrightVerificationInstructions}`; @@ -214,7 +224,12 @@ ${feature.spec} const branchName = feature.branchName; if (!worktreePath && useWorktrees && branchName) { worktreePath = await this.worktreeResolver.findWorktreeForBranch(projectPath, branchName); - if (worktreePath) logger.info(`Using worktree for branch "${branchName}": ${worktreePath}`); + if (!worktreePath) { + throw new Error( + `Worktree enabled but no worktree found for feature branch "${branchName}".` + ); + } + logger.info(`Using worktree for branch "${branchName}": ${worktreePath}`); } const workDir = worktreePath ? path.resolve(worktreePath) : path.resolve(projectPath); validateWorkingDirectory(workDir); @@ -268,9 +283,15 @@ ${feature.spec} if (options?.continuationPrompt) { prompt = options.continuationPrompt; } else { - prompt = - (await this.getPlanningPromptPrefixFn(feature)) + - this.buildFeaturePrompt(feature, prompts.taskExecution); + const planningPrefix = await this.getPlanningPromptPrefixFn(feature); + if (planningPrefix) { + // Planning mode active: use planning instructions + feature description only. + // Do NOT include implementationInstructions — they conflict with the planning + // prompt's "DO NOT proceed with implementation until approval" directive. + prompt = planningPrefix + '\n\n' + this.buildFeatureDescription(feature); + } else { + prompt = this.buildFeaturePrompt(feature, prompts.taskExecution); + } if (feature.planningMode && feature.planningMode !== 'skip') { this.eventBus.emitAutoModeEvent('planning_started', { featureId: feature.id, @@ -304,6 +325,7 @@ ${feature.spec} useClaudeCodeSystemPrompt, thinkingLevel: feature.thinkingLevel, reasoningEffort: feature.reasoningEffort, + providerId: feature.providerId, branchName: feature.branchName ?? null, } ); @@ -370,6 +392,7 @@ Please continue from where you left off and complete all remaining tasks. Use th useClaudeCodeSystemPrompt, thinkingLevel: feature.thinkingLevel, reasoningEffort: feature.reasoningEffort, + providerId: feature.providerId, branchName: feature.branchName ?? null, } ); @@ -461,7 +484,10 @@ Please continue from where you left off and complete all remaining tasks. Use th const hasIncompleteTasks = totalTasks > 0 && completedTasks < totalTasks; try { - if (agentOutput) { + // Only save summary if feature doesn't already have one (e.g., accumulated from pipeline steps) + // This prevents overwriting accumulated summaries with just the last step's output + // The agent-executor already extracts and saves summaries during execution + if (agentOutput && !completedFeature?.summary) { const summary = extractSummary(agentOutput); if (summary) await this.saveFeatureSummaryFn(projectPath, featureId, summary); } diff --git a/apps/server/src/services/execution-types.ts b/apps/server/src/services/execution-types.ts index 8a98b2437..765098ba1 100644 --- a/apps/server/src/services/execution-types.ts +++ b/apps/server/src/services/execution-types.ts @@ -34,6 +34,7 @@ export type RunAgentFn = ( useClaudeCodeSystemPrompt?: boolean; thinkingLevel?: ThinkingLevel; reasoningEffort?: ReasoningEffort; + providerId?: string; branchName?: string | null; } ) => Promise; diff --git a/apps/server/src/services/feature-loader.ts b/apps/server/src/services/feature-loader.ts index 5b21e44b1..31eca4a7a 100644 --- a/apps/server/src/services/feature-loader.ts +++ b/apps/server/src/services/feature-loader.ts @@ -378,6 +378,7 @@ export class FeatureLoader { description: featureData.description || '', ...featureData, id: featureId, + createdAt: featureData.createdAt || new Date().toISOString(), imagePaths: migratedImagePaths, descriptionHistory: initialHistory, }; diff --git a/apps/server/src/services/feature-state-manager.ts b/apps/server/src/services/feature-state-manager.ts index 1f8a49520..450048967 100644 --- a/apps/server/src/services/feature-state-manager.ts +++ b/apps/server/src/services/feature-state-manager.ts @@ -14,7 +14,8 @@ */ import path from 'path'; -import type { Feature, ParsedTask, PlanSpec } from '@automaker/types'; +import type { Feature, FeatureStatusWithPipeline, ParsedTask, PlanSpec } from '@automaker/types'; +import { isPipelineStatus } from '@automaker/types'; import { atomicWriteJson, readJsonWithRecovery, @@ -28,9 +29,40 @@ import type { EventEmitter } from '../lib/events.js'; import type { AutoModeEventType } from './typed-event-bus.js'; import { getNotificationService } from './notification-service.js'; import { FeatureLoader } from './feature-loader.js'; +import { pipelineService } from './pipeline-service.js'; const logger = createLogger('FeatureStateManager'); +// Notification type constants +const NOTIFICATION_TYPE_WAITING_APPROVAL = 'feature_waiting_approval'; +const NOTIFICATION_TYPE_VERIFIED = 'feature_verified'; +const NOTIFICATION_TYPE_FEATURE_ERROR = 'feature_error'; +const NOTIFICATION_TYPE_AUTO_MODE_ERROR = 'auto_mode_error'; + +// Notification title constants +const NOTIFICATION_TITLE_WAITING_APPROVAL = 'Feature Ready for Review'; +const NOTIFICATION_TITLE_VERIFIED = 'Feature Verified'; +const NOTIFICATION_TITLE_FEATURE_ERROR = 'Feature Failed'; +const NOTIFICATION_TITLE_AUTO_MODE_ERROR = 'Auto Mode Error'; + +/** + * Auto-mode event payload structure + * This is the payload that comes with 'auto-mode:event' events + */ +interface AutoModeEventPayload { + type?: string; + featureId?: string; + featureName?: string; + passes?: boolean; + executionMode?: 'auto' | 'manual'; + message?: string; + error?: string; + errorType?: string; + projectPath?: string; + /** Status field present when type === 'feature_status_changed' */ + status?: string; +} + /** * FeatureStateManager handles feature status updates with persistence guarantees. * @@ -43,10 +75,28 @@ const logger = createLogger('FeatureStateManager'); export class FeatureStateManager { private events: EventEmitter; private featureLoader: FeatureLoader; + private unsubscribe: (() => void) | null = null; constructor(events: EventEmitter, featureLoader: FeatureLoader) { this.events = events; this.featureLoader = featureLoader; + + // Subscribe to error events to create notifications + this.unsubscribe = events.subscribe((type, payload) => { + if (type === 'auto-mode:event') { + this.handleAutoModeEventError(payload as AutoModeEventPayload); + } + }); + } + + /** + * Cleanup subscriptions + */ + destroy(): void { + if (this.unsubscribe) { + this.unsubscribe(); + this.unsubscribe = null; + } } /** @@ -104,79 +154,20 @@ export class FeatureStateManager { feature.status = status; feature.updatedAt = new Date().toISOString(); - // Set justFinishedAt timestamp when moving to waiting_approval (agent just completed) - // Badge will show for 2 minutes after this timestamp - if (status === 'waiting_approval') { + // Handle justFinishedAt timestamp based on status + const shouldSetJustFinishedAt = status === 'waiting_approval'; + const shouldClearJustFinishedAt = status !== 'waiting_approval'; + if (shouldSetJustFinishedAt) { feature.justFinishedAt = new Date().toISOString(); - - // Finalize task statuses when feature is done: - // - Mark any in_progress tasks as completed (agent finished but didn't explicitly complete them) - // - Do NOT mark pending tasks as completed (they were never started) - // - Clear currentTaskId since no task is actively running - // This prevents cards in "waiting for review" from appearing to still have running tasks - if (feature.planSpec?.tasks) { - let tasksFinalized = 0; - let tasksPending = 0; - for (const task of feature.planSpec.tasks) { - if (task.status === 'in_progress') { - task.status = 'completed'; - tasksFinalized++; - } else if (task.status === 'pending') { - tasksPending++; - } - } - if (tasksFinalized > 0) { - logger.info( - `[updateFeatureStatus] Finalized ${tasksFinalized} in_progress tasks for feature ${featureId} moving to waiting_approval` - ); - } - if (tasksPending > 0) { - logger.warn( - `[updateFeatureStatus] Feature ${featureId} moving to waiting_approval with ${tasksPending} pending (never started) tasks out of ${feature.planSpec.tasks.length} total` - ); - } - // Update tasksCompleted count to reflect actual completed tasks - feature.planSpec.tasksCompleted = feature.planSpec.tasks.filter( - (t) => t.status === 'completed' - ).length; - feature.planSpec.currentTaskId = undefined; - } - } else if (status === 'verified') { - // Also finalize in_progress tasks when moving directly to verified (skipTests=false) - // Do NOT mark pending tasks as completed - they were never started - if (feature.planSpec?.tasks) { - let tasksFinalized = 0; - let tasksPending = 0; - for (const task of feature.planSpec.tasks) { - if (task.status === 'in_progress') { - task.status = 'completed'; - tasksFinalized++; - } else if (task.status === 'pending') { - tasksPending++; - } - } - if (tasksFinalized > 0) { - logger.info( - `[updateFeatureStatus] Finalized ${tasksFinalized} in_progress tasks for feature ${featureId} moving to verified` - ); - } - if (tasksPending > 0) { - logger.warn( - `[updateFeatureStatus] Feature ${featureId} moving to verified with ${tasksPending} pending (never started) tasks out of ${feature.planSpec.tasks.length} total` - ); - } - feature.planSpec.tasksCompleted = feature.planSpec.tasks.filter( - (t) => t.status === 'completed' - ).length; - feature.planSpec.currentTaskId = undefined; - } - // Clear the timestamp when moving to other statuses - feature.justFinishedAt = undefined; - } else { - // Clear the timestamp when moving to other statuses + } else if (shouldClearJustFinishedAt) { feature.justFinishedAt = undefined; } + // Finalize in-progress tasks when reaching terminal states (waiting_approval or verified) + if (status === 'waiting_approval' || status === 'verified') { + this.finalizeInProgressTasks(feature, featureId, status); + } + // PERSIST BEFORE EMIT (Pitfall 2) await atomicWriteJson(featurePath, feature, { backupCount: DEFAULT_BACKUP_COUNT }); @@ -191,19 +182,21 @@ export class FeatureStateManager { // Wrapped in try-catch so failures don't block syncFeatureToAppSpec below try { const notificationService = getNotificationService(); + const displayName = this.getFeatureDisplayName(feature, featureId); + if (status === 'waiting_approval') { await notificationService.createNotification({ - type: 'feature_waiting_approval', - title: 'Feature Ready for Review', - message: `"${feature.name || featureId}" is ready for your review and approval.`, + type: NOTIFICATION_TYPE_WAITING_APPROVAL, + title: displayName, + message: NOTIFICATION_TITLE_WAITING_APPROVAL, featureId, projectPath, }); } else if (status === 'verified') { await notificationService.createNotification({ - type: 'feature_verified', - title: 'Feature Verified', - message: `"${feature.name || featureId}" has been verified and is complete.`, + type: NOTIFICATION_TYPE_VERIFIED, + title: displayName, + message: NOTIFICATION_TITLE_VERIFIED, featureId, projectPath, }); @@ -252,7 +245,7 @@ export class FeatureStateManager { const currentStatus = feature?.status; // Preserve pipeline_* statuses so resumePipelineFeature can resume from the correct step - if (currentStatus && currentStatus.startsWith('pipeline_')) { + if (isPipelineStatus(currentStatus)) { logger.info( `Feature ${featureId} was in ${currentStatus}; preserving pipeline status for resume` ); @@ -270,7 +263,8 @@ export class FeatureStateManager { /** * Shared helper that scans features in a project directory and resets any stuck - * in transient states (in_progress, interrupted, pipeline_*) back to resting states. + * in transient states (in_progress, interrupted) back to resting states. + * Pipeline_* statuses are preserved so they can be resumed. * * Also resets: * - generating planSpec status back to pending @@ -324,10 +318,7 @@ export class FeatureStateManager { // Reset features in active execution states back to a resting state // After a server restart, no processes are actually running - const isActiveState = - originalStatus === 'in_progress' || - originalStatus === 'interrupted' || - (originalStatus != null && originalStatus.startsWith('pipeline_')); + const isActiveState = originalStatus === 'in_progress' || originalStatus === 'interrupted'; if (isActiveState) { const hasApprovedPlan = feature.planSpec?.status === 'approved'; @@ -338,6 +329,17 @@ export class FeatureStateManager { ); } + // Handle pipeline_* statuses separately: preserve them so they can be resumed + // but still count them as needing attention if they were stuck. + if (isPipelineStatus(originalStatus)) { + // We don't change the status, but we still want to reset planSpec/task states + // if they were stuck in transient generation/execution modes. + // No feature.status change here. + logger.debug( + `[${callerLabel}] Preserving pipeline status for feature ${feature.id}: ${originalStatus}` + ); + } + // Reset generating planSpec status back to pending (spec generation was interrupted) if (feature.planSpec?.status === 'generating') { feature.planSpec.status = 'pending'; @@ -396,10 +398,12 @@ export class FeatureStateManager { * Resets: * - in_progress features back to ready (if has plan) or backlog (if no plan) * - interrupted features back to ready (if has plan) or backlog (if no plan) - * - pipeline_* features back to ready (if has plan) or backlog (if no plan) * - generating planSpec status back to pending * - in_progress tasks back to pending * + * Preserves: + * - pipeline_* statuses (so resumePipelineFeature can resume from correct step) + * * @param projectPath - The project path to reset features for */ async resetStuckFeatures(projectPath: string): Promise { @@ -530,6 +534,10 @@ export class FeatureStateManager { * This is called after agent execution completes to save a summary * extracted from the agent's output using tags. * + * For pipeline features (status starts with pipeline_), summaries are accumulated + * across steps with a header identifying each step. For non-pipeline features, + * the summary is replaced entirely. + * * @param projectPath - The project path * @param featureId - The feature ID * @param summary - The summary text to save @@ -537,6 +545,7 @@ export class FeatureStateManager { async saveFeatureSummary(projectPath: string, featureId: string, summary: string): Promise { const featureDir = getFeatureDir(projectPath, featureId); const featurePath = path.join(featureDir, 'feature.json'); + const normalizedSummary = summary.trim(); try { const result = await readJsonWithRecovery(featurePath, null, { @@ -552,7 +561,63 @@ export class FeatureStateManager { return; } - feature.summary = summary; + if (!normalizedSummary) { + logger.debug( + `[saveFeatureSummary] Skipping empty summary for feature ${featureId} (status="${feature.status}")` + ); + return; + } + + // For pipeline features, accumulate summaries across steps + if (isPipelineStatus(feature.status)) { + // If we already have a non-phase summary (typically the initial implementation + // summary from in_progress), normalize it into a named phase before appending + // pipeline step summaries. This keeps the format consistent for UI phase parsing. + const implementationHeader = '### Implementation'; + if (feature.summary && !feature.summary.trimStart().startsWith('### ')) { + feature.summary = `${implementationHeader}\n\n${feature.summary.trim()}`; + } + + const stepName = await this.getPipelineStepName(projectPath, feature.status); + const stepHeader = `### ${stepName}`; + const stepSection = `${stepHeader}\n\n${normalizedSummary}`; + + if (feature.summary) { + // Check if this step already exists in the summary (e.g., if retried) + // Use section splitting to only match real section boundaries, not text in body content + const separator = '\n\n---\n\n'; + const sections = feature.summary.split(separator); + let replaced = false; + const updatedSections = sections.map((section) => { + if (section.startsWith(`${stepHeader}\n\n`)) { + replaced = true; + return stepSection; + } + return section; + }); + + if (replaced) { + feature.summary = updatedSections.join(separator); + logger.info( + `[saveFeatureSummary] Updated existing pipeline step summary for feature ${featureId}: step="${stepName}"` + ); + } else { + // Append as a new section + feature.summary = `${feature.summary}${separator}${stepSection}`; + logger.info( + `[saveFeatureSummary] Appended new pipeline step summary for feature ${featureId}: step="${stepName}"` + ); + } + } else { + feature.summary = stepSection; + logger.info( + `[saveFeatureSummary] Initialized pipeline summary for feature ${featureId}: step="${stepName}"` + ); + } + } else { + feature.summary = normalizedSummary; + } + feature.updatedAt = new Date().toISOString(); // PERSIST BEFORE EMIT @@ -562,13 +627,42 @@ export class FeatureStateManager { this.emitAutoModeEvent('auto_mode_summary', { featureId, projectPath, - summary, + summary: feature.summary, }); } catch (error) { logger.error(`Failed to save summary for ${featureId}:`, error); } } + /** + * Look up the pipeline step name from the current pipeline status. + * + * @param projectPath - The project path + * @param status - The current pipeline status (e.g., 'pipeline_abc123') + * @returns The step name, or a fallback based on the step ID + */ + private async getPipelineStepName(projectPath: string, status: string): Promise { + try { + const stepId = pipelineService.getStepIdFromStatus(status as FeatureStatusWithPipeline); + if (stepId) { + const step = await pipelineService.getStep(projectPath, stepId); + if (step) return step.name; + } + } catch (error) { + logger.debug( + `[getPipelineStepName] Failed to look up step name for status "${status}", using fallback:`, + error + ); + } + // Fallback: derive a human-readable name from the status suffix + // e.g., 'pipeline_code_review' → 'Code Review' + const suffix = status.replace('pipeline_', ''); + return suffix + .split('_') + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(' '); + } + /** * Update the status of a specific task within planSpec.tasks * @@ -581,7 +675,8 @@ export class FeatureStateManager { projectPath: string, featureId: string, taskId: string, - status: ParsedTask['status'] + status: ParsedTask['status'], + summary?: string ): Promise { const featureDir = getFeatureDir(projectPath, featureId); const featurePath = path.join(featureDir, 'feature.json'); @@ -604,6 +699,9 @@ export class FeatureStateManager { const task = feature.planSpec.tasks.find((t) => t.id === taskId); if (task) { task.status = status; + if (summary) { + task.summary = summary; + } feature.updatedAt = new Date().toISOString(); // PERSIST BEFORE EMIT @@ -615,6 +713,7 @@ export class FeatureStateManager { projectPath, taskId, status, + summary, tasks: feature.planSpec.tasks, }); } else { @@ -628,6 +727,137 @@ export class FeatureStateManager { } } + /** + * Get the display name for a feature, preferring title over feature ID. + * Empty string titles are treated as missing and fallback to featureId. + * + * @param feature - The feature to get the display name for + * @param featureId - The feature ID to use as fallback + * @returns The display name (title or feature ID) + */ + private getFeatureDisplayName(feature: Feature, featureId: string): string { + // Use title if it's a non-empty string, otherwise fallback to featureId + return feature.title && feature.title.trim() ? feature.title : featureId; + } + + /** + * Handle auto-mode events to create error notifications. + * This listens for error events and creates notifications to alert users. + */ + private async handleAutoModeEventError(payload: AutoModeEventPayload): Promise { + if (!payload.type) return; + + // Only handle error events + if (payload.type !== 'auto_mode_error' && payload.type !== 'auto_mode_feature_complete') { + return; + } + + // For auto_mode_feature_complete, only notify on failures (passes === false) + if (payload.type === 'auto_mode_feature_complete' && payload.passes !== false) { + return; + } + + // Get project path - handle different event formats + const projectPath = payload.projectPath; + if (!projectPath) return; + + try { + const notificationService = getNotificationService(); + + // Determine notification type and title based on event type + // Only auto_mode_feature_complete events should create feature_error notifications + const isFeatureError = payload.type === 'auto_mode_feature_complete'; + const notificationType = isFeatureError + ? NOTIFICATION_TYPE_FEATURE_ERROR + : NOTIFICATION_TYPE_AUTO_MODE_ERROR; + const notificationTitle = isFeatureError + ? NOTIFICATION_TITLE_FEATURE_ERROR + : NOTIFICATION_TITLE_AUTO_MODE_ERROR; + + // Build error message + let errorMessage = payload.message || 'An error occurred'; + if (payload.error) { + errorMessage = payload.error; + } + + // Use feature title as notification title when available, fall back to gesture name + let title = notificationTitle; + if (payload.featureId) { + const displayName = await this.getFeatureDisplayNameById(projectPath, payload.featureId); + if (displayName) { + title = displayName; + errorMessage = `${notificationTitle}: ${errorMessage}`; + } + } + + await notificationService.createNotification({ + type: notificationType, + title, + message: errorMessage, + featureId: payload.featureId, + projectPath, + }); + } catch (notificationError) { + logger.warn(`Failed to create error notification:`, notificationError); + } + } + + /** + * Get feature display name by loading the feature directly. + */ + private async getFeatureDisplayNameById( + projectPath: string, + featureId: string + ): Promise { + const feature = await this.loadFeature(projectPath, featureId); + if (!feature) return null; + return this.getFeatureDisplayName(feature, featureId); + } + + /** + * Finalize in-progress tasks when a feature reaches a terminal state. + * Marks in_progress tasks as completed but leaves pending tasks untouched. + * + * @param feature - The feature whose tasks should be finalized + * @param featureId - The feature ID for logging + * @param targetStatus - The status the feature is transitioning to + */ + private finalizeInProgressTasks(feature: Feature, featureId: string, targetStatus: string): void { + if (!feature.planSpec?.tasks) { + return; + } + + let tasksFinalized = 0; + let tasksPending = 0; + + for (const task of feature.planSpec.tasks) { + if (task.status === 'in_progress') { + task.status = 'completed'; + tasksFinalized++; + } else if (task.status === 'pending') { + tasksPending++; + } + } + + // Update tasksCompleted count to reflect actual completed tasks + feature.planSpec.tasksCompleted = feature.planSpec.tasks.filter( + (t) => t.status === 'completed' + ).length; + feature.planSpec.currentTaskId = undefined; + + if (tasksFinalized > 0) { + logger.info( + `[updateFeatureStatus] Finalized ${tasksFinalized} in_progress tasks for feature ${featureId} moving to ${targetStatus}` + ); + } + + if (tasksPending > 0) { + logger.warn( + `[updateFeatureStatus] Feature ${featureId} moving to ${targetStatus} with ${tasksPending} pending (never started) tasks out of ${feature.planSpec.tasks.length} total` + ); + } + } + /** * Emit an auto-mode event via the event emitter * diff --git a/apps/server/src/services/ntfy-service.ts b/apps/server/src/services/ntfy-service.ts new file mode 100644 index 000000000..c63ac6e20 --- /dev/null +++ b/apps/server/src/services/ntfy-service.ts @@ -0,0 +1,282 @@ +/** + * Ntfy Service - Sends push notifications via ntfy.sh + * + * Provides integration with ntfy.sh for push notifications. + * Supports custom servers, authentication, tags, emojis, and click actions. + * + * @see https://docs.ntfy.sh/publish/ + */ + +import { createLogger } from '@automaker/utils'; +import type { NtfyEndpointConfig, EventHookContext } from '@automaker/types'; + +const logger = createLogger('Ntfy'); + +/** Default timeout for ntfy HTTP requests (10 seconds) */ +const DEFAULT_NTFY_TIMEOUT = 10000; + +// Re-export EventHookContext as NtfyContext for backward compatibility +export type NtfyContext = EventHookContext; + +/** + * Ntfy Service + * + * Handles sending notifications to ntfy.sh endpoints. + */ +export class NtfyService { + /** + * Send a notification to a ntfy.sh endpoint + * + * @param endpoint The ntfy.sh endpoint configuration + * @param options Notification options (title, body, tags, etc.) + * @param context Context for variable substitution + */ + async sendNotification( + endpoint: NtfyEndpointConfig, + options: { + title?: string; + body?: string; + tags?: string; + emoji?: string; + clickUrl?: string; + priority?: 1 | 2 | 3 | 4 | 5; + }, + context: NtfyContext + ): Promise<{ success: boolean; error?: string }> { + if (!endpoint.enabled) { + logger.warn(`Ntfy endpoint "${endpoint.name}" is disabled, skipping notification`); + return { success: false, error: 'Endpoint is disabled' }; + } + + // Validate endpoint configuration + const validationError = this.validateEndpoint(endpoint); + if (validationError) { + logger.error(`Invalid ntfy endpoint configuration: ${validationError}`); + return { success: false, error: validationError }; + } + + // Build URL + const serverUrl = endpoint.serverUrl.replace(/\/$/, ''); // Remove trailing slash + const url = `${serverUrl}/${encodeURIComponent(endpoint.topic)}`; + + // Build headers + const headers: Record = { + 'Content-Type': 'text/plain; charset=utf-8', + }; + + // Title (with variable substitution) + const title = this.substituteVariables(options.title || this.getDefaultTitle(context), context); + if (title) { + headers['Title'] = title; + } + + // Priority + const priority = options.priority || 3; + headers['Priority'] = String(priority); + + // Tags and emoji + const tags = this.buildTags( + options.tags || endpoint.defaultTags, + options.emoji || endpoint.defaultEmoji + ); + if (tags) { + headers['Tags'] = tags; + } + + // Click action URL + const clickUrl = this.substituteVariables( + options.clickUrl || endpoint.defaultClickUrl || '', + context + ); + if (clickUrl) { + headers['Click'] = clickUrl; + } + + // Authentication + this.addAuthHeaders(headers, endpoint); + + // Message body (with variable substitution) + const body = this.substituteVariables(options.body || this.getDefaultBody(context), context); + + logger.info(`Sending ntfy notification to ${endpoint.name}: ${title}`); + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), DEFAULT_NTFY_TIMEOUT); + + try { + const response = await fetch(url, { + method: 'POST', + headers, + body, + signal: controller.signal, + }); + + if (!response.ok) { + const errorText = await response.text().catch(() => 'Unknown error'); + logger.error(`Ntfy notification failed with status ${response.status}: ${errorText}`); + return { + success: false, + error: `HTTP ${response.status}: ${errorText}`, + }; + } + + logger.info(`Ntfy notification sent successfully to ${endpoint.name}`); + return { success: true }; + } catch (error) { + if ((error as Error).name === 'AbortError') { + logger.error(`Ntfy notification timed out after ${DEFAULT_NTFY_TIMEOUT}ms`); + return { success: false, error: 'Request timed out' }; + } + + const errorMessage = error instanceof Error ? error.message : String(error); + logger.error(`Ntfy notification failed: ${errorMessage}`); + return { success: false, error: errorMessage }; + } finally { + clearTimeout(timeoutId); + } + } + + /** + * Validate an ntfy endpoint configuration + */ + validateEndpoint(endpoint: NtfyEndpointConfig): string | null { + // Validate server URL + if (!endpoint.serverUrl) { + return 'Server URL is required'; + } + + try { + new URL(endpoint.serverUrl); + } catch { + return 'Invalid server URL format'; + } + + // Validate topic + if (!endpoint.topic) { + return 'Topic is required'; + } + + if (endpoint.topic.includes(' ') || endpoint.topic.includes('\t')) { + return 'Topic cannot contain spaces'; + } + + // Validate authentication + if (endpoint.authType === 'basic') { + if (!endpoint.username || !endpoint.password) { + return 'Username and password are required for basic authentication'; + } + } else if (endpoint.authType === 'token') { + if (!endpoint.token) { + return 'Access token is required for token authentication'; + } + } + + return null; + } + + /** + * Build tags string from tags and emoji + */ + private buildTags(tags?: string, emoji?: string): string { + const tagList: string[] = []; + + if (tags) { + // Split by comma and trim whitespace + const parsedTags = tags + .split(',') + .map((t) => t.trim()) + .filter((t) => t.length > 0); + tagList.push(...parsedTags); + } + + if (emoji) { + // Add emoji as first tag if it looks like a shortcode + if (emoji.startsWith(':') && emoji.endsWith(':')) { + tagList.unshift(emoji.slice(1, -1)); + } else if (!emoji.includes(' ')) { + // If it's a single emoji or shortcode without colons, add as-is + tagList.unshift(emoji); + } + } + + return tagList.join(','); + } + + /** + * Add authentication headers based on auth type + */ + private addAuthHeaders(headers: Record, endpoint: NtfyEndpointConfig): void { + if (endpoint.authType === 'basic' && endpoint.username && endpoint.password) { + const credentials = Buffer.from(`${endpoint.username}:${endpoint.password}`).toString( + 'base64' + ); + headers['Authorization'] = `Basic ${credentials}`; + } else if (endpoint.authType === 'token' && endpoint.token) { + headers['Authorization'] = `Bearer ${endpoint.token}`; + } + } + + /** + * Get default title based on event context + */ + private getDefaultTitle(context: NtfyContext): string { + const eventName = this.formatEventName(context.eventType); + if (context.featureName) { + return `${eventName}: ${context.featureName}`; + } + return eventName; + } + + /** + * Get default body based on event context + */ + private getDefaultBody(context: NtfyContext): string { + const lines: string[] = []; + + if (context.featureName) { + lines.push(`Feature: ${context.featureName}`); + } + if (context.featureId) { + lines.push(`ID: ${context.featureId}`); + } + if (context.projectName) { + lines.push(`Project: ${context.projectName}`); + } + if (context.error) { + lines.push(`Error: ${context.error}`); + } + lines.push(`Time: ${context.timestamp}`); + + return lines.join('\n'); + } + + /** + * Format event type to human-readable name + */ + private formatEventName(eventType: string): string { + const eventNames: Record = { + feature_created: 'Feature Created', + feature_success: 'Feature Completed', + feature_error: 'Feature Failed', + auto_mode_complete: 'Auto Mode Complete', + auto_mode_error: 'Auto Mode Error', + }; + return eventNames[eventType] || eventType; + } + + /** + * Substitute {{variable}} placeholders in a string + */ + private substituteVariables(template: string, context: NtfyContext): string { + return template.replace(/\{\{(\w+)\}\}/g, (match, variable) => { + const value = context[variable as keyof NtfyContext]; + if (value === undefined || value === null) { + return ''; + } + return String(value); + }); + } +} + +// Singleton instance +export const ntfyService = new NtfyService(); diff --git a/apps/server/src/services/pipeline-orchestrator.ts b/apps/server/src/services/pipeline-orchestrator.ts index c8564b180..de9800013 100644 --- a/apps/server/src/services/pipeline-orchestrator.ts +++ b/apps/server/src/services/pipeline-orchestrator.ts @@ -115,6 +115,7 @@ export class PipelineOrchestrator { projectPath, }); const model = resolveModelString(feature.model, DEFAULT_MODELS.claude); + const currentStatus = `pipeline_${step.id}`; await this.runAgentFn( workDir, featureId, @@ -133,6 +134,8 @@ export class PipelineOrchestrator { useClaudeCodeSystemPrompt, thinkingLevel: feature.thinkingLevel, reasoningEffort: feature.reasoningEffort, + status: currentStatus, + providerId: feature.providerId, } ); try { @@ -165,7 +168,18 @@ export class PipelineOrchestrator { if (previousContext) prompt += `### Previous Work\n${previousContext}\n\n`; return ( prompt + - `### Pipeline Step Instructions\n${step.instructions}\n\n### Task\nComplete the pipeline step instructions above.` + `### Pipeline Step Instructions\n${step.instructions}\n\n### Task\nComplete the pipeline step instructions above.\n\n` + + `**CRITICAL: After completing the instructions, you MUST output a summary using this EXACT format:**\n\n` + + `\n` + + `## Summary: ${step.name}\n\n` + + `### Changes Implemented\n` + + `- [List all changes made in this step]\n\n` + + `### Files Modified\n` + + `- [List all files modified in this step]\n\n` + + `### Outcome\n` + + `- [Describe the result of this step]\n` + + `\n\n` + + `The and tags MUST be on their own lines. This is REQUIRED.` ); } @@ -490,7 +504,10 @@ export class PipelineOrchestrator { requirePlanApproval: false, useClaudeCodeSystemPrompt: context.useClaudeCodeSystemPrompt, autoLoadClaudeMd: context.autoLoadClaudeMd, + thinkingLevel: context.feature.thinkingLevel, reasoningEffort: context.feature.reasoningEffort, + status: context.feature.status, + providerId: context.feature.providerId, } ); } diff --git a/apps/server/src/services/pull-service.ts b/apps/server/src/services/pull-service.ts index 82531423f..d6f8c36a8 100644 --- a/apps/server/src/services/pull-service.ts +++ b/apps/server/src/services/pull-service.ts @@ -28,6 +28,8 @@ const logger = createLogger('PullService'); export interface PullOptions { /** Remote name to pull from (defaults to 'origin') */ remote?: string; + /** Specific remote branch to pull (e.g. 'main'). When provided, overrides the tracking branch and fetches this branch from the remote. */ + remoteBranch?: string; /** When true, automatically stash local changes before pulling and reapply after */ stashIfNeeded?: boolean; } @@ -243,6 +245,7 @@ export async function performPull( ): Promise { const targetRemote = options?.remote || 'origin'; const stashIfNeeded = options?.stashIfNeeded ?? false; + const targetRemoteBranch = options?.remoteBranch; // 1. Get current branch name let branchName: string; @@ -313,24 +316,34 @@ export async function performPull( } // 7. Verify upstream tracking or remote branch exists - const upstreamStatus = await hasUpstreamOrRemoteBranch(worktreePath, branchName, targetRemote); - if (upstreamStatus === 'none') { - let stashRecoveryFailed = false; - if (didStash) { - const stashPopped = await tryPopStash(worktreePath); - stashRecoveryFailed = !stashPopped; + // Skip this check when a specific remote branch is provided - we always use + // explicit 'git pull ' args in that case. + let upstreamStatus: UpstreamStatus = 'tracking'; + if (!targetRemoteBranch) { + upstreamStatus = await hasUpstreamOrRemoteBranch(worktreePath, branchName, targetRemote); + if (upstreamStatus === 'none') { + let stashRecoveryFailed = false; + if (didStash) { + const stashPopped = await tryPopStash(worktreePath); + stashRecoveryFailed = !stashPopped; + } + return { + success: false, + error: `Branch '${branchName}' has no upstream branch on remote '${targetRemote}'. Push it first or set upstream with: git branch --set-upstream-to=${targetRemote}/${branchName}${stashRecoveryFailed ? ' Local changes remain stashed and need manual recovery (run: git stash pop).' : ''}`, + stashRecoveryFailed: stashRecoveryFailed ? stashRecoveryFailed : undefined, + }; } - return { - success: false, - error: `Branch '${branchName}' has no upstream branch on remote '${targetRemote}'. Push it first or set upstream with: git branch --set-upstream-to=${targetRemote}/${branchName}${stashRecoveryFailed ? ' Local changes remain stashed and need manual recovery (run: git stash pop).' : ''}`, - stashRecoveryFailed: stashRecoveryFailed ? stashRecoveryFailed : undefined, - }; } // 8. Pull latest changes + // When a specific remote branch is requested, always use explicit remote + branch args. // When the branch has a configured upstream tracking ref, let Git use it automatically. // When only the remote branch exists (no tracking ref), explicitly specify remote and branch. - const pullArgs = upstreamStatus === 'tracking' ? ['pull'] : ['pull', targetRemote, branchName]; + const pullArgs = targetRemoteBranch + ? ['pull', targetRemote, targetRemoteBranch] + : upstreamStatus === 'tracking' + ? ['pull'] + : ['pull', targetRemote, branchName]; let pullConflict = false; let pullConflictFiles: string[] = []; diff --git a/apps/server/src/services/settings-service.ts b/apps/server/src/services/settings-service.ts index 7b3ffa707..5986b877a 100644 --- a/apps/server/src/services/settings-service.ts +++ b/apps/server/src/services/settings-service.ts @@ -618,6 +618,36 @@ export class SettingsService { ignoreEmptyArrayOverwrite('eventHooks'); } + // Guard ntfyEndpoints against accidental wipe + // (similar to eventHooks, these are user-configured and shouldn't be lost) + // Check for explicit permission to clear ntfyEndpoints (escape hatch for intentional clearing) + const allowEmptyNtfyEndpoints = + (sanitizedUpdates as Record).__allowEmptyNtfyEndpoints === true; + // Remove the flag so it doesn't get persisted + delete (sanitizedUpdates as Record).__allowEmptyNtfyEndpoints; + + if (!allowEmptyNtfyEndpoints) { + const currentNtfyLen = Array.isArray(current.ntfyEndpoints) + ? current.ntfyEndpoints.length + : 0; + const newNtfyLen = Array.isArray(sanitizedUpdates.ntfyEndpoints) + ? sanitizedUpdates.ntfyEndpoints.length + : currentNtfyLen; + + if (Array.isArray(sanitizedUpdates.ntfyEndpoints) && newNtfyLen === 0 && currentNtfyLen > 0) { + logger.warn( + '[WIPE_PROTECTION] Attempted to set ntfyEndpoints to empty array! Ignoring update.', + { + currentNtfyLen, + newNtfyLen, + } + ); + delete sanitizedUpdates.ntfyEndpoints; + } + } else { + logger.info('[INTENTIONAL_CLEAR] Clearing ntfyEndpoints via escape hatch'); + } + // Empty object overwrite guard const ignoreEmptyObjectOverwrite = (key: K): void => { const nextVal = sanitizedUpdates[key] as unknown; @@ -1023,6 +1053,8 @@ export class SettingsService { keyboardShortcuts: (appState.keyboardShortcuts as KeyboardShortcuts) || DEFAULT_GLOBAL_SETTINGS.keyboardShortcuts, + eventHooks: (appState.eventHooks as GlobalSettings['eventHooks']) || [], + ntfyEndpoints: (appState.ntfyEndpoints as GlobalSettings['ntfyEndpoints']) || [], projects: (appState.projects as ProjectRef[]) || [], trashedProjects: (appState.trashedProjects as TrashedProjectRef[]) || [], projectHistory: (appState.projectHistory as string[]) || [], diff --git a/apps/server/src/services/spec-parser.ts b/apps/server/src/services/spec-parser.ts index cd1c80508..1c9f527ed 100644 --- a/apps/server/src/services/spec-parser.ts +++ b/apps/server/src/services/spec-parser.ts @@ -101,12 +101,32 @@ export function detectTaskStartMarker(text: string): string | null { } /** - * Detect [TASK_COMPLETE] marker in text and extract task ID + * Detect [TASK_COMPLETE] marker in text and extract task ID and summary * Format: [TASK_COMPLETE] T###: Brief summary */ -export function detectTaskCompleteMarker(text: string): string | null { - const match = text.match(/\[TASK_COMPLETE\]\s*(T\d{3})/); - return match ? match[1] : null; +export function detectTaskCompleteMarker(text: string): { id: string; summary?: string } | null { + // Use a regex that captures the summary until newline or next task marker + // Allow brackets in summary content (e.g., "supports array[index] access") + // Pattern breakdown: + // - \[TASK_COMPLETE\]\s* - Match the marker + // - (T\d{3}) - Capture task ID + // - (?::\s*([^\n\[]+))? - Optionally capture summary (stops at newline or bracket) + // - But we want to allow brackets in summary, so we use a different approach: + // - Match summary until newline, then trim any trailing markers in post-processing + const match = text.match(/\[TASK_COMPLETE\]\s*(T\d{3})(?::\s*(.+?))?(?=\n|$)/i); + if (!match) return null; + + // Post-process: remove trailing task markers from summary if present + let summary = match[2]?.trim(); + if (summary) { + // Remove trailing content that looks like another marker + summary = summary.replace(/\s*\[TASK_[A-Z_]+\].*$/i, '').trim(); + } + + return { + id: match[1], + summary: summary || undefined, + }; } /** diff --git a/apps/server/src/services/worktree-resolver.ts b/apps/server/src/services/worktree-resolver.ts index 48ae405d4..4048d1e80 100644 --- a/apps/server/src/services/worktree-resolver.ts +++ b/apps/server/src/services/worktree-resolver.ts @@ -39,6 +39,18 @@ export interface WorktreeInfo { * 3. Listing all worktrees with normalized paths */ export class WorktreeResolver { + private normalizeBranchName(branchName: string | null | undefined): string | null { + if (!branchName) return null; + let normalized = branchName.trim(); + if (!normalized) return null; + + normalized = normalized.replace(/^refs\/heads\//, ''); + normalized = normalized.replace(/^refs\/remotes\/[^/]+\//, ''); + normalized = normalized.replace(/^(origin|upstream)\//, ''); + + return normalized || null; + } + /** * Get the current branch name for a git repository * @@ -64,6 +76,9 @@ export class WorktreeResolver { */ async findWorktreeForBranch(projectPath: string, branchName: string): Promise { try { + const normalizedTargetBranch = this.normalizeBranchName(branchName); + if (!normalizedTargetBranch) return null; + const { stdout } = await execAsync('git worktree list --porcelain', { cwd: projectPath, }); @@ -76,10 +91,10 @@ export class WorktreeResolver { if (line.startsWith('worktree ')) { currentPath = line.slice(9); } else if (line.startsWith('branch ')) { - currentBranch = line.slice(7).replace('refs/heads/', ''); + currentBranch = this.normalizeBranchName(line.slice(7)); } else if (line === '' && currentPath && currentBranch) { // End of a worktree entry - if (currentBranch === branchName) { + if (currentBranch === normalizedTargetBranch) { // Resolve to absolute path - git may return relative paths // On Windows, this is critical for cwd to work correctly // On all platforms, absolute paths ensure consistent behavior @@ -91,7 +106,7 @@ export class WorktreeResolver { } // Check the last entry (if file doesn't end with newline) - if (currentPath && currentBranch && currentBranch === branchName) { + if (currentPath && currentBranch && currentBranch === normalizedTargetBranch) { return this.resolvePath(projectPath, currentPath); } @@ -123,7 +138,7 @@ export class WorktreeResolver { if (line.startsWith('worktree ')) { currentPath = line.slice(9); } else if (line.startsWith('branch ')) { - currentBranch = line.slice(7).replace('refs/heads/', ''); + currentBranch = this.normalizeBranchName(line.slice(7)); } else if (line.startsWith('detached')) { // Detached HEAD - branch is null currentBranch = null; diff --git a/apps/server/tests/unit/lib/file-editor-store-logic.test.ts b/apps/server/tests/unit/lib/file-editor-store-logic.test.ts new file mode 100644 index 000000000..7f6eabbd3 --- /dev/null +++ b/apps/server/tests/unit/lib/file-editor-store-logic.test.ts @@ -0,0 +1,333 @@ +import { describe, it, expect } from 'vitest'; +import { + computeIsDirty, + updateTabWithContent as updateTabContent, + markTabAsSaved as markTabSaved, +} from '../../../../ui/src/components/views/file-editor-view/file-editor-dirty-utils.ts'; + +/** + * Unit tests for the file editor store logic, focusing on the unsaved indicator fix. + * + * The bug was: File unsaved indicators weren't working reliably - editing a file + * and saving it would sometimes leave the dirty indicator (dot) visible. + * + * Root causes: + * 1. Stale closure in handleSave - captured activeTab could have old content + * 2. Editor buffer not synced - CodeMirror might have buffered changes not yet in store + * + * Fix: + * - handleSave now gets fresh state from store using getState() + * - handleSave gets current content from editor via getValue() + * - Content is synced to store before saving if it differs + * + * Since we can't easily test the React/zustand store in node environment, + * we test the pure logic that the store uses for dirty state tracking. + */ + +describe('File editor dirty state logic', () => { + describe('updateTabContent', () => { + it('should set isDirty to true when content differs from originalContent', () => { + const tab = { + content: 'original content', + originalContent: 'original content', + isDirty: false, + }; + + const updated = updateTabContent(tab, 'modified content'); + + expect(updated.isDirty).toBe(true); + expect(updated.content).toBe('modified content'); + expect(updated.originalContent).toBe('original content'); + }); + + it('should set isDirty to false when content matches originalContent', () => { + const tab = { + content: 'original content', + originalContent: 'original content', + isDirty: false, + }; + + // First modify it + let updated = updateTabContent(tab, 'modified content'); + expect(updated.isDirty).toBe(true); + + // Now update back to original + updated = updateTabContent(updated, 'original content'); + expect(updated.isDirty).toBe(false); + }); + + it('should handle empty content correctly', () => { + const tab = { + content: '', + originalContent: '', + isDirty: false, + }; + + const updated = updateTabContent(tab, 'new content'); + + expect(updated.isDirty).toBe(true); + }); + }); + + describe('markTabSaved', () => { + it('should set isDirty to false and update both content and originalContent', () => { + const tab = { + content: 'original content', + originalContent: 'original content', + isDirty: false, + }; + + // First modify + let updated = updateTabContent(tab, 'modified content'); + expect(updated.isDirty).toBe(true); + + // Then save + updated = markTabSaved(updated, 'modified content'); + + expect(updated.isDirty).toBe(false); + expect(updated.content).toBe('modified content'); + expect(updated.originalContent).toBe('modified content'); + }); + + it('should correctly clear dirty state when save is triggered after edit', () => { + // This test simulates the bug scenario: + // 1. User edits file -> isDirty = true + // 2. User saves -> markTabSaved should set isDirty = false + let tab = { + content: 'initial', + originalContent: 'initial', + isDirty: false, + }; + + // Simulate user editing + tab = updateTabContent(tab, 'initial\nnew line'); + + // Should be dirty + expect(tab.isDirty).toBe(true); + + // Simulate save (with the content that was saved) + tab = markTabSaved(tab, 'initial\nnew line'); + + // Should NOT be dirty anymore + expect(tab.isDirty).toBe(false); + }); + }); + + describe('race condition handling', () => { + it('should correctly handle updateTabContent after markTabSaved with same content', () => { + // This tests the scenario where: + // 1. CodeMirror has a pending onChange with content "B" + // 2. User presses save when editor shows "B" + // 3. markTabSaved is called with "B" + // 4. CodeMirror's pending onChange fires with "B" (same content) + // Result: isDirty should remain false + let tab = { + content: 'A', + originalContent: 'A', + isDirty: false, + }; + + // User edits to "B" + tab = updateTabContent(tab, 'B'); + + // Save with "B" + tab = markTabSaved(tab, 'B'); + + // Late onChange with same content "B" + tab = updateTabContent(tab, 'B'); + + expect(tab.isDirty).toBe(false); + expect(tab.content).toBe('B'); + }); + + it('should correctly handle updateTabContent after markTabSaved with different content', () => { + // This tests the scenario where: + // 1. CodeMirror has a pending onChange with content "C" + // 2. User presses save when store has "B" + // 3. markTabSaved is called with "B" + // 4. CodeMirror's pending onChange fires with "C" (different content) + // Result: isDirty should be true (file changed after save) + let tab = { + content: 'A', + originalContent: 'A', + isDirty: false, + }; + + // User edits to "B" + tab = updateTabContent(tab, 'B'); + + // Save with "B" + tab = markTabSaved(tab, 'B'); + + // Late onChange with different content "C" + tab = updateTabContent(tab, 'C'); + + // File changed after save, so it should be dirty + expect(tab.isDirty).toBe(true); + expect(tab.content).toBe('C'); + expect(tab.originalContent).toBe('B'); + }); + + it('should handle rapid edit-save-edit cycle correctly', () => { + // Simulate rapid user actions + let tab = { + content: 'v1', + originalContent: 'v1', + isDirty: false, + }; + + // Edit 1 + tab = updateTabContent(tab, 'v2'); + expect(tab.isDirty).toBe(true); + + // Save 1 + tab = markTabSaved(tab, 'v2'); + expect(tab.isDirty).toBe(false); + + // Edit 2 + tab = updateTabContent(tab, 'v3'); + expect(tab.isDirty).toBe(true); + + // Save 2 + tab = markTabSaved(tab, 'v3'); + expect(tab.isDirty).toBe(false); + + // Edit 3 (back to v2) + tab = updateTabContent(tab, 'v2'); + expect(tab.isDirty).toBe(true); + + // Save 3 + tab = markTabSaved(tab, 'v2'); + expect(tab.isDirty).toBe(false); + }); + }); + + describe('handleSave stale closure fix simulation', () => { + it('demonstrates the fix: using fresh content instead of closure content', () => { + // This test demonstrates why the fix was necessary. + // The old handleSave captured activeTab in closure, which could be stale. + // The fix gets fresh state from getState() and uses editor.getValue(). + + // Simulate store state + let storeState = { + tabs: [ + { + id: 'tab-1', + content: 'A', + originalContent: 'A', + isDirty: false, + }, + ], + activeTabId: 'tab-1', + }; + + // Simulate a "stale closure" capturing the tab state + const staleClosureTab = storeState.tabs[0]; + + // User edits - store state updates + storeState = { + ...storeState, + tabs: [ + { + id: 'tab-1', + content: 'B', + originalContent: 'A', + isDirty: true, + }, + ], + }; + + // OLD BUG: Using stale closure tab would save "A" (old content) + const oldBugSavedContent = staleClosureTab!.content; + expect(oldBugSavedContent).toBe('A'); // Wrong! Should be "B" + + // FIX: Using fresh state from getState() gets correct content + const freshTab = storeState.tabs[0]; + const fixedSavedContent = freshTab!.content; + expect(fixedSavedContent).toBe('B'); // Correct! + }); + + it('demonstrates syncing editor content before save', () => { + // This test demonstrates why we need to get content from editor directly. + // The store might have stale content if onChange hasn't fired yet. + + // Simulate store state (has old content because onChange hasn't fired) + let storeContent = 'A'; + + // Editor has newer content (not yet synced to store) + const editorContent = 'B'; + + // FIX: Use editor content if available, fall back to store content + const contentToSave = editorContent ?? storeContent; + + expect(contentToSave).toBe('B'); // Correctly saves editor content + + // Simulate syncing to store before save + if (editorContent !== null && editorContent !== storeContent) { + storeContent = editorContent; + } + + // Now store is synced + expect(storeContent).toBe('B'); + + // After save, markTabSaved would set originalContent = savedContent + // and isDirty = false (if no more changes come in) + }); + }); + + describe('edge cases', () => { + it('should handle whitespace-only changes as dirty', () => { + let tab = { + content: 'hello', + originalContent: 'hello', + isDirty: false, + }; + + tab = updateTabContent(tab, 'hello '); + expect(tab.isDirty).toBe(true); + }); + + it('should treat CRLF and LF line endings as equivalent (not dirty)', () => { + let tab = { + content: 'line1\nline2', + originalContent: 'line1\nline2', + isDirty: false, + }; + + // CodeMirror normalizes \r\n to \n internally, so content that only + // differs by line endings should NOT be considered dirty. + tab = updateTabContent(tab, 'line1\r\nline2'); + expect(tab.isDirty).toBe(false); + }); + + it('should handle unicode content correctly', () => { + let tab = { + content: '你好世界', + originalContent: '你好世界', + isDirty: false, + }; + + tab = updateTabContent(tab, '你好宇宙'); + expect(tab.isDirty).toBe(true); + + tab = markTabSaved(tab, '你好宇宙'); + expect(tab.isDirty).toBe(false); + }); + + it('should handle very large content efficiently', () => { + // Generate a large string (1MB) + const largeOriginal = 'x'.repeat(1024 * 1024); + const largeModified = largeOriginal + 'y'; + + let tab = { + content: largeOriginal, + originalContent: largeOriginal, + isDirty: false, + }; + + tab = updateTabContent(tab, largeModified); + + expect(tab.isDirty).toBe(true); + }); + }); +}); diff --git a/apps/server/tests/unit/lib/settings-helpers.test.ts b/apps/server/tests/unit/lib/settings-helpers.test.ts index a7096c55d..edaa74d03 100644 --- a/apps/server/tests/unit/lib/settings-helpers.test.ts +++ b/apps/server/tests/unit/lib/settings-helpers.test.ts @@ -1,5 +1,11 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { getMCPServersFromSettings } from '@/lib/settings-helpers.js'; +import { + getMCPServersFromSettings, + getProviderById, + getProviderByModelId, + resolveProviderContext, + getAllProviderModels, +} from '@/lib/settings-helpers.js'; import type { SettingsService } from '@/services/settings-service.js'; // Mock the logger @@ -286,4 +292,691 @@ describe('settings-helpers.ts', () => { }); }); }); + + describe('getProviderById', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should return provider when found by ID', async () => { + const mockProvider = { id: 'zai-1', name: 'Zai', enabled: true }; + const mockSettingsService = { + getGlobalSettings: vi.fn().mockResolvedValue({ + claudeCompatibleProviders: [mockProvider], + }), + getCredentials: vi.fn().mockResolvedValue({}), + } as unknown as SettingsService; + + const result = await getProviderById('zai-1', mockSettingsService); + expect(result.provider).toEqual(mockProvider); + }); + + it('should return undefined when provider not found', async () => { + const mockSettingsService = { + getGlobalSettings: vi.fn().mockResolvedValue({ + claudeCompatibleProviders: [], + }), + getCredentials: vi.fn().mockResolvedValue({}), + } as unknown as SettingsService; + + const result = await getProviderById('unknown', mockSettingsService); + expect(result.provider).toBeUndefined(); + }); + + it('should return provider even if disabled (caller handles enabled state)', async () => { + const mockProvider = { id: 'disabled-1', name: 'Disabled', enabled: false }; + const mockSettingsService = { + getGlobalSettings: vi.fn().mockResolvedValue({ + claudeCompatibleProviders: [mockProvider], + }), + getCredentials: vi.fn().mockResolvedValue({}), + } as unknown as SettingsService; + + const result = await getProviderById('disabled-1', mockSettingsService); + expect(result.provider).toEqual(mockProvider); + }); + }); + + describe('getProviderByModelId', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should return provider and modelConfig when found by model ID', async () => { + const mockModel = { id: 'custom-model-1', name: 'Custom Model' }; + const mockProvider = { + id: 'provider-1', + name: 'Provider 1', + enabled: true, + models: [mockModel], + }; + const mockSettingsService = { + getGlobalSettings: vi.fn().mockResolvedValue({ + claudeCompatibleProviders: [mockProvider], + }), + getCredentials: vi.fn().mockResolvedValue({}), + } as unknown as SettingsService; + + const result = await getProviderByModelId('custom-model-1', mockSettingsService); + expect(result.provider).toEqual(mockProvider); + expect(result.modelConfig).toEqual(mockModel); + }); + + it('should resolve mapped Claude model when mapsToClaudeModel is present', async () => { + const mockModel = { + id: 'custom-model-1', + name: 'Custom Model', + mapsToClaudeModel: 'sonnet-3-5', + }; + const mockProvider = { + id: 'provider-1', + name: 'Provider 1', + enabled: true, + models: [mockModel], + }; + const mockSettingsService = { + getGlobalSettings: vi.fn().mockResolvedValue({ + claudeCompatibleProviders: [mockProvider], + }), + getCredentials: vi.fn().mockResolvedValue({}), + } as unknown as SettingsService; + + const result = await getProviderByModelId('custom-model-1', mockSettingsService); + expect(result.resolvedModel).toBeDefined(); + // resolveModelString('sonnet-3-5') usually returns 'claude-3-5-sonnet-20240620' or similar + }); + + it('should ignore disabled providers', async () => { + const mockModel = { id: 'custom-model-1', name: 'Custom Model' }; + const mockProvider = { + id: 'disabled-1', + name: 'Disabled Provider', + enabled: false, + models: [mockModel], + }; + const mockSettingsService = { + getGlobalSettings: vi.fn().mockResolvedValue({ + claudeCompatibleProviders: [mockProvider], + }), + getCredentials: vi.fn().mockResolvedValue({}), + } as unknown as SettingsService; + + const result = await getProviderByModelId('custom-model-1', mockSettingsService); + expect(result.provider).toBeUndefined(); + }); + }); + + describe('resolveProviderContext', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should resolve provider by explicit providerId', async () => { + const mockProvider = { + id: 'provider-1', + name: 'Provider 1', + enabled: true, + models: [{ id: 'custom-model-1', name: 'Custom Model' }], + }; + const mockSettingsService = { + getGlobalSettings: vi.fn().mockResolvedValue({ + claudeCompatibleProviders: [mockProvider], + }), + getCredentials: vi.fn().mockResolvedValue({ anthropicApiKey: 'test-key' }), + } as unknown as SettingsService; + + const result = await resolveProviderContext( + mockSettingsService, + 'custom-model-1', + 'provider-1' + ); + + expect(result.provider).toEqual(mockProvider); + expect(result.credentials).toEqual({ anthropicApiKey: 'test-key' }); + }); + + it('should return undefined provider when explicit providerId not found', async () => { + const mockSettingsService = { + getGlobalSettings: vi.fn().mockResolvedValue({ + claudeCompatibleProviders: [], + }), + getCredentials: vi.fn().mockResolvedValue({}), + } as unknown as SettingsService; + + const result = await resolveProviderContext( + mockSettingsService, + 'some-model', + 'unknown-provider' + ); + + expect(result.provider).toBeUndefined(); + }); + + it('should fallback to model-based lookup when providerId not provided', async () => { + const mockProvider = { + id: 'provider-1', + name: 'Provider 1', + enabled: true, + models: [{ id: 'custom-model-1', name: 'Custom Model' }], + }; + const mockSettingsService = { + getGlobalSettings: vi.fn().mockResolvedValue({ + claudeCompatibleProviders: [mockProvider], + }), + getCredentials: vi.fn().mockResolvedValue({}), + } as unknown as SettingsService; + + const result = await resolveProviderContext(mockSettingsService, 'custom-model-1'); + + expect(result.provider).toEqual(mockProvider); + expect(result.modelConfig?.id).toBe('custom-model-1'); + }); + + it('should resolve mapsToClaudeModel to actual Claude model', async () => { + const mockProvider = { + id: 'provider-1', + name: 'Provider 1', + enabled: true, + models: [ + { + id: 'custom-model-1', + name: 'Custom Model', + mapsToClaudeModel: 'sonnet', + }, + ], + }; + const mockSettingsService = { + getGlobalSettings: vi.fn().mockResolvedValue({ + claudeCompatibleProviders: [mockProvider], + }), + getCredentials: vi.fn().mockResolvedValue({}), + } as unknown as SettingsService; + + const result = await resolveProviderContext(mockSettingsService, 'custom-model-1'); + + // resolveModelString('sonnet') should return a valid Claude model ID + expect(result.resolvedModel).toBeDefined(); + expect(result.resolvedModel).toContain('claude'); + }); + + it('should handle empty providers list', async () => { + const mockSettingsService = { + getGlobalSettings: vi.fn().mockResolvedValue({ + claudeCompatibleProviders: [], + }), + getCredentials: vi.fn().mockResolvedValue({}), + } as unknown as SettingsService; + + const result = await resolveProviderContext(mockSettingsService, 'some-model'); + + expect(result.provider).toBeUndefined(); + expect(result.resolvedModel).toBeUndefined(); + expect(result.modelConfig).toBeUndefined(); + }); + + it('should handle missing claudeCompatibleProviders field', async () => { + const mockSettingsService = { + getGlobalSettings: vi.fn().mockResolvedValue({}), + getCredentials: vi.fn().mockResolvedValue({}), + } as unknown as SettingsService; + + const result = await resolveProviderContext(mockSettingsService, 'some-model'); + + expect(result.provider).toBeUndefined(); + }); + + it('should skip disabled providers during fallback lookup', async () => { + const disabledProvider = { + id: 'disabled-1', + name: 'Disabled Provider', + enabled: false, + models: [{ id: 'model-in-disabled', name: 'Model' }], + }; + const enabledProvider = { + id: 'enabled-1', + name: 'Enabled Provider', + enabled: true, + models: [{ id: 'model-in-enabled', name: 'Model' }], + }; + const mockSettingsService = { + getGlobalSettings: vi.fn().mockResolvedValue({ + claudeCompatibleProviders: [disabledProvider, enabledProvider], + }), + getCredentials: vi.fn().mockResolvedValue({}), + } as unknown as SettingsService; + + // Should skip the disabled provider and find the model in the enabled one + const result = await resolveProviderContext(mockSettingsService, 'model-in-enabled'); + expect(result.provider?.id).toBe('enabled-1'); + + // Should not find model that only exists in disabled provider + const result2 = await resolveProviderContext(mockSettingsService, 'model-in-disabled'); + expect(result2.provider).toBeUndefined(); + }); + + it('should perform case-insensitive model ID matching', async () => { + const mockProvider = { + id: 'provider-1', + name: 'Provider 1', + enabled: true, + models: [{ id: 'Custom-Model-1', name: 'Custom Model' }], + }; + const mockSettingsService = { + getGlobalSettings: vi.fn().mockResolvedValue({ + claudeCompatibleProviders: [mockProvider], + }), + getCredentials: vi.fn().mockResolvedValue({}), + } as unknown as SettingsService; + + const result = await resolveProviderContext(mockSettingsService, 'custom-model-1'); + + expect(result.provider).toEqual(mockProvider); + expect(result.modelConfig?.id).toBe('Custom-Model-1'); + }); + + it('should return error result on exception', async () => { + const mockSettingsService = { + getGlobalSettings: vi.fn().mockRejectedValue(new Error('Settings error')), + getCredentials: vi.fn().mockResolvedValue({}), + } as unknown as SettingsService; + + const result = await resolveProviderContext(mockSettingsService, 'some-model'); + + expect(result.provider).toBeUndefined(); + expect(result.credentials).toBeUndefined(); + expect(result.resolvedModel).toBeUndefined(); + expect(result.modelConfig).toBeUndefined(); + }); + + it('should persist and load provider config from server settings', async () => { + // This test verifies the main bug fix: providers are loaded from server settings + const savedProvider = { + id: 'saved-provider-1', + name: 'Saved Provider', + enabled: true, + apiKeySource: 'credentials' as const, + models: [ + { + id: 'saved-model-1', + name: 'Saved Model', + mapsToClaudeModel: 'sonnet', + }, + ], + }; + + const mockSettingsService = { + getGlobalSettings: vi.fn().mockResolvedValue({ + claudeCompatibleProviders: [savedProvider], + }), + getCredentials: vi.fn().mockResolvedValue({ + anthropicApiKey: 'saved-api-key', + }), + } as unknown as SettingsService; + + // Simulate loading saved provider config + const result = await resolveProviderContext( + mockSettingsService, + 'saved-model-1', + 'saved-provider-1' + ); + + // Verify the provider is loaded from server settings + expect(result.provider).toEqual(savedProvider); + expect(result.provider?.id).toBe('saved-provider-1'); + expect(result.provider?.models).toHaveLength(1); + expect(result.credentials?.anthropicApiKey).toBe('saved-api-key'); + // Verify model mapping is resolved + expect(result.resolvedModel).toContain('claude'); + }); + + it('should accept custom logPrefix parameter', async () => { + // Verify that the logPrefix parameter is accepted (used by facade.ts) + const mockProvider = { + id: 'provider-1', + name: 'Provider 1', + enabled: true, + models: [{ id: 'model-1', name: 'Model' }], + }; + const mockSettingsService = { + getGlobalSettings: vi.fn().mockResolvedValue({ + claudeCompatibleProviders: [mockProvider], + }), + getCredentials: vi.fn().mockResolvedValue({}), + } as unknown as SettingsService; + + // Call with custom logPrefix (as facade.ts does) + const result = await resolveProviderContext( + mockSettingsService, + 'model-1', + undefined, + '[CustomPrefix]' + ); + + // Function should work the same with custom prefix + expect(result.provider).toEqual(mockProvider); + }); + + // Session restore scenarios - provider.enabled: undefined should be treated as enabled + describe('session restore scenarios (enabled: undefined)', () => { + it('should treat provider with enabled: undefined as enabled', async () => { + // This is the main bug fix: when providers are loaded from settings on session restore, + // enabled might be undefined (not explicitly set) and should be treated as enabled + const mockProvider = { + id: 'provider-1', + name: 'Provider 1', + enabled: undefined, // Not explicitly set - should be treated as enabled + models: [{ id: 'model-1', name: 'Model' }], + }; + const mockSettingsService = { + getGlobalSettings: vi.fn().mockResolvedValue({ + claudeCompatibleProviders: [mockProvider], + }), + getCredentials: vi.fn().mockResolvedValue({}), + } as unknown as SettingsService; + + const result = await resolveProviderContext(mockSettingsService, 'model-1'); + + // Provider should be found and used even though enabled is undefined + expect(result.provider).toEqual(mockProvider); + expect(result.modelConfig?.id).toBe('model-1'); + }); + + it('should use provider by ID when enabled is undefined', async () => { + // This tests the explicit providerId lookup with undefined enabled + const mockProvider = { + id: 'provider-1', + name: 'Provider 1', + enabled: undefined, // Not explicitly set - should be treated as enabled + models: [{ id: 'custom-model', name: 'Custom Model', mapsToClaudeModel: 'sonnet' }], + }; + const mockSettingsService = { + getGlobalSettings: vi.fn().mockResolvedValue({ + claudeCompatibleProviders: [mockProvider], + }), + getCredentials: vi.fn().mockResolvedValue({ anthropicApiKey: 'test-key' }), + } as unknown as SettingsService; + + const result = await resolveProviderContext( + mockSettingsService, + 'custom-model', + 'provider-1' + ); + + // Provider should be found and used even though enabled is undefined + expect(result.provider).toEqual(mockProvider); + expect(result.credentials?.anthropicApiKey).toBe('test-key'); + expect(result.resolvedModel).toContain('claude'); + }); + + it('should find model via fallback in provider with enabled: undefined', async () => { + // Test fallback model lookup when provider has undefined enabled + const providerWithUndefinedEnabled = { + id: 'provider-1', + name: 'Provider 1', + // enabled is not set (undefined) + models: [{ id: 'model-1', name: 'Model' }], + }; + const mockSettingsService = { + getGlobalSettings: vi.fn().mockResolvedValue({ + claudeCompatibleProviders: [providerWithUndefinedEnabled], + }), + getCredentials: vi.fn().mockResolvedValue({}), + } as unknown as SettingsService; + + const result = await resolveProviderContext(mockSettingsService, 'model-1'); + + expect(result.provider).toEqual(providerWithUndefinedEnabled); + expect(result.modelConfig?.id).toBe('model-1'); + }); + + it('should still use provider for connection when model not found in its models array', async () => { + // This tests the fix: when providerId is explicitly set and provider is found, + // but the model isn't in that provider's models array, we still use that provider + // for connection settings (baseUrl, credentials) + const mockProvider = { + id: 'provider-1', + name: 'Provider 1', + enabled: true, + baseUrl: 'https://custom-api.example.com', + models: [{ id: 'other-model', name: 'Other Model' }], + }; + const mockSettingsService = { + getGlobalSettings: vi.fn().mockResolvedValue({ + claudeCompatibleProviders: [mockProvider], + }), + getCredentials: vi.fn().mockResolvedValue({ anthropicApiKey: 'test-key' }), + } as unknown as SettingsService; + + const result = await resolveProviderContext( + mockSettingsService, + 'unknown-model', // Model not in provider's models array + 'provider-1' + ); + + // Provider should still be returned for connection settings + expect(result.provider).toEqual(mockProvider); + // modelConfig should be undefined since the model wasn't found + expect(result.modelConfig).toBeUndefined(); + // resolvedModel should be undefined since no mapping was found + expect(result.resolvedModel).toBeUndefined(); + }); + + it('should fallback to find modelConfig in other providers when not in explicit providerId provider', async () => { + // When providerId is set and provider is found, but model isn't there, + // we should still search for modelConfig in other providers + const provider1 = { + id: 'provider-1', + name: 'Provider 1', + enabled: true, + baseUrl: 'https://provider1.example.com', + models: [{ id: 'provider1-model', name: 'Provider 1 Model' }], + }; + const provider2 = { + id: 'provider-2', + name: 'Provider 2', + enabled: true, + baseUrl: 'https://provider2.example.com', + models: [ + { + id: 'shared-model', + name: 'Shared Model', + mapsToClaudeModel: 'sonnet', + }, + ], + }; + const mockSettingsService = { + getGlobalSettings: vi.fn().mockResolvedValue({ + claudeCompatibleProviders: [provider1, provider2], + }), + getCredentials: vi.fn().mockResolvedValue({ anthropicApiKey: 'test-key' }), + } as unknown as SettingsService; + + const result = await resolveProviderContext( + mockSettingsService, + 'shared-model', // This model is in provider-2, not provider-1 + 'provider-1' // But we explicitly want to use provider-1 + ); + + // Provider should still be provider-1 (for connection settings) + expect(result.provider).toEqual(provider1); + // But modelConfig should be found from provider-2 + expect(result.modelConfig?.id).toBe('shared-model'); + // And the model mapping should be resolved + expect(result.resolvedModel).toContain('claude'); + }); + + it('should handle multiple providers with mixed enabled states', async () => { + // Test the full session restore scenario with multiple providers + const providers = [ + { + id: 'provider-1', + name: 'First Provider', + enabled: undefined, // Undefined after restore + models: [{ id: 'model-a', name: 'Model A' }], + }, + { + id: 'provider-2', + name: 'Second Provider', + // enabled field missing entirely + models: [{ id: 'model-b', name: 'Model B', mapsToClaudeModel: 'opus' }], + }, + { + id: 'provider-3', + name: 'Disabled Provider', + enabled: false, // Explicitly disabled + models: [{ id: 'model-c', name: 'Model C' }], + }, + ]; + + const mockSettingsService = { + getGlobalSettings: vi.fn().mockResolvedValue({ + claudeCompatibleProviders: providers, + }), + getCredentials: vi.fn().mockResolvedValue({ anthropicApiKey: 'test-key' }), + } as unknown as SettingsService; + + // Provider 1 should work (enabled: undefined) + const result1 = await resolveProviderContext(mockSettingsService, 'model-a', 'provider-1'); + expect(result1.provider?.id).toBe('provider-1'); + expect(result1.modelConfig?.id).toBe('model-a'); + + // Provider 2 should work (enabled field missing) + const result2 = await resolveProviderContext(mockSettingsService, 'model-b', 'provider-2'); + expect(result2.provider?.id).toBe('provider-2'); + expect(result2.modelConfig?.id).toBe('model-b'); + expect(result2.resolvedModel).toContain('claude'); + + // Provider 3 with explicit providerId IS returned even if disabled + // (caller handles enabled state check) + const result3 = await resolveProviderContext(mockSettingsService, 'model-c', 'provider-3'); + // Provider is found but modelConfig won't be found since disabled providers + // skip model lookup in their models array + expect(result3.provider).toEqual(providers[2]); + expect(result3.modelConfig).toBeUndefined(); + }); + }); + }); + + describe('getAllProviderModels', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should return all models from enabled providers', async () => { + const mockProviders = [ + { + id: 'provider-1', + name: 'Provider 1', + enabled: true, + models: [ + { id: 'model-1', name: 'Model 1' }, + { id: 'model-2', name: 'Model 2' }, + ], + }, + { + id: 'provider-2', + name: 'Provider 2', + enabled: true, + models: [{ id: 'model-3', name: 'Model 3' }], + }, + ]; + const mockSettingsService = { + getGlobalSettings: vi.fn().mockResolvedValue({ + claudeCompatibleProviders: mockProviders, + }), + } as unknown as SettingsService; + + const result = await getAllProviderModels(mockSettingsService); + + expect(result).toHaveLength(3); + expect(result[0].providerId).toBe('provider-1'); + expect(result[0].model.id).toBe('model-1'); + expect(result[2].providerId).toBe('provider-2'); + }); + + it('should filter out disabled providers', async () => { + const mockProviders = [ + { + id: 'enabled-1', + name: 'Enabled Provider', + enabled: true, + models: [{ id: 'model-1', name: 'Model 1' }], + }, + { + id: 'disabled-1', + name: 'Disabled Provider', + enabled: false, + models: [{ id: 'model-2', name: 'Model 2' }], + }, + ]; + const mockSettingsService = { + getGlobalSettings: vi.fn().mockResolvedValue({ + claudeCompatibleProviders: mockProviders, + }), + } as unknown as SettingsService; + + const result = await getAllProviderModels(mockSettingsService); + + expect(result).toHaveLength(1); + expect(result[0].providerId).toBe('enabled-1'); + }); + + it('should return empty array when no providers configured', async () => { + const mockSettingsService = { + getGlobalSettings: vi.fn().mockResolvedValue({ + claudeCompatibleProviders: [], + }), + } as unknown as SettingsService; + + const result = await getAllProviderModels(mockSettingsService); + + expect(result).toEqual([]); + }); + + it('should handle missing claudeCompatibleProviders field', async () => { + const mockSettingsService = { + getGlobalSettings: vi.fn().mockResolvedValue({}), + } as unknown as SettingsService; + + const result = await getAllProviderModels(mockSettingsService); + + expect(result).toEqual([]); + }); + + it('should handle provider with no models', async () => { + const mockProviders = [ + { + id: 'provider-1', + name: 'Provider 1', + enabled: true, + models: [], + }, + { + id: 'provider-2', + name: 'Provider 2', + enabled: true, + // no models field + }, + ]; + const mockSettingsService = { + getGlobalSettings: vi.fn().mockResolvedValue({ + claudeCompatibleProviders: mockProviders, + }), + } as unknown as SettingsService; + + const result = await getAllProviderModels(mockSettingsService); + + expect(result).toEqual([]); + }); + + it('should return empty array on exception', async () => { + const mockSettingsService = { + getGlobalSettings: vi.fn().mockRejectedValue(new Error('Settings error')), + } as unknown as SettingsService; + + const result = await getAllProviderModels(mockSettingsService); + + expect(result).toEqual([]); + }); + }); }); diff --git a/apps/server/tests/unit/providers/codex-provider.test.ts b/apps/server/tests/unit/providers/codex-provider.test.ts index 1e150ee16..a0448a705 100644 --- a/apps/server/tests/unit/providers/codex-provider.test.ts +++ b/apps/server/tests/unit/providers/codex-provider.test.ts @@ -15,6 +15,7 @@ import { calculateReasoningTimeout, REASONING_TIMEOUT_MULTIPLIERS, DEFAULT_TIMEOUT_MS, + validateBareModelId, } from '@automaker/types'; const OPENAI_API_KEY_ENV = 'OPENAI_API_KEY'; @@ -455,4 +456,19 @@ describe('codex-provider.ts', () => { expect(calculateReasoningTimeout('xhigh')).toBe(120000); }); }); + + describe('validateBareModelId integration', () => { + it('should allow codex- prefixed models for Codex provider with expectedProvider="codex"', () => { + expect(() => validateBareModelId('codex-gpt-4', 'CodexProvider', 'codex')).not.toThrow(); + expect(() => + validateBareModelId('codex-gpt-5.1-codex-max', 'CodexProvider', 'codex') + ).not.toThrow(); + }); + + it('should reject other provider prefixes for Codex provider', () => { + expect(() => validateBareModelId('cursor-gpt-4', 'CodexProvider', 'codex')).toThrow(); + expect(() => validateBareModelId('gemini-2.5-flash', 'CodexProvider', 'codex')).toThrow(); + expect(() => validateBareModelId('copilot-gpt-4', 'CodexProvider', 'codex')).toThrow(); + }); + }); }); diff --git a/apps/server/tests/unit/providers/copilot-provider.test.ts b/apps/server/tests/unit/providers/copilot-provider.test.ts index 55db34dfa..54cdb49c6 100644 --- a/apps/server/tests/unit/providers/copilot-provider.test.ts +++ b/apps/server/tests/unit/providers/copilot-provider.test.ts @@ -331,13 +331,15 @@ describe('copilot-provider.ts', () => { }); }); - it('should normalize tool.execution_end event', () => { + it('should normalize tool.execution_complete event', () => { const event = { - type: 'tool.execution_end', + type: 'tool.execution_complete', data: { - toolName: 'read_file', toolCallId: 'call-123', - result: 'file content', + success: true, + result: { + content: 'file content', + }, }, }; @@ -357,23 +359,85 @@ describe('copilot-provider.ts', () => { }); }); - it('should handle tool.execution_end with error', () => { + it('should handle tool.execution_complete with error', () => { const event = { - type: 'tool.execution_end', + type: 'tool.execution_complete', data: { - toolName: 'bash', toolCallId: 'call-456', - error: 'Command failed', + success: false, + error: { + message: 'Command failed', + }, }, }; const result = provider.normalizeEvent(event); expect(result?.message?.content?.[0]).toMatchObject({ type: 'tool_result', + tool_use_id: 'call-456', content: '[ERROR] Command failed', }); }); + it('should handle tool.execution_complete with empty result', () => { + const event = { + type: 'tool.execution_complete', + data: { + toolCallId: 'call-789', + success: true, + result: { + content: '', + }, + }, + }; + + const result = provider.normalizeEvent(event); + expect(result?.message?.content?.[0]).toMatchObject({ + type: 'tool_result', + tool_use_id: 'call-789', + content: '', + }); + }); + + it('should handle tool.execution_complete with missing result', () => { + const event = { + type: 'tool.execution_complete', + data: { + toolCallId: 'call-999', + success: true, + // No result field + }, + }; + + const result = provider.normalizeEvent(event); + expect(result?.message?.content?.[0]).toMatchObject({ + type: 'tool_result', + tool_use_id: 'call-999', + content: '', + }); + }); + + it('should handle tool.execution_complete with error code', () => { + const event = { + type: 'tool.execution_complete', + data: { + toolCallId: 'call-567', + success: false, + error: { + message: 'Permission denied', + code: 'EACCES', + }, + }, + }; + + const result = provider.normalizeEvent(event); + expect(result?.message?.content?.[0]).toMatchObject({ + type: 'tool_result', + tool_use_id: 'call-567', + content: '[ERROR] Permission denied (EACCES)', + }); + }); + it('should normalize session.idle to success result', () => { const event = { type: 'session.idle' }; diff --git a/apps/server/tests/unit/providers/cursor-provider.test.ts b/apps/server/tests/unit/providers/cursor-provider.test.ts index 0e41d9630..846ac69be 100644 --- a/apps/server/tests/unit/providers/cursor-provider.test.ts +++ b/apps/server/tests/unit/providers/cursor-provider.test.ts @@ -1,5 +1,6 @@ -import { describe, it, expect, beforeEach } from 'vitest'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; import { CursorProvider } from '@/providers/cursor-provider.js'; +import { validateBareModelId } from '@automaker/types'; describe('cursor-provider.ts', () => { describe('buildCliArgs', () => { @@ -154,4 +155,81 @@ describe('cursor-provider.ts', () => { expect(msg!.subtype).toBe('success'); }); }); + + describe('Cursor Gemini models support', () => { + let provider: CursorProvider; + + beforeEach(() => { + provider = Object.create(CursorProvider.prototype) as CursorProvider & { + cliPath?: string; + }; + provider.cliPath = '/usr/local/bin/cursor-agent'; + }); + + describe('buildCliArgs with Cursor Gemini models', () => { + it('should handle cursor-gemini-3-pro model', () => { + const args = provider.buildCliArgs({ + prompt: 'Write a function', + model: 'gemini-3-pro', // Bare model ID after stripping cursor- prefix + cwd: '/tmp/project', + }); + + const modelIndex = args.indexOf('--model'); + expect(modelIndex).toBeGreaterThan(-1); + expect(args[modelIndex + 1]).toBe('gemini-3-pro'); + }); + + it('should handle cursor-gemini-3-flash model', () => { + const args = provider.buildCliArgs({ + prompt: 'Quick task', + model: 'gemini-3-flash', // Bare model ID after stripping cursor- prefix + cwd: '/tmp/project', + }); + + const modelIndex = args.indexOf('--model'); + expect(modelIndex).toBeGreaterThan(-1); + expect(args[modelIndex + 1]).toBe('gemini-3-flash'); + }); + + it('should include --resume with Cursor Gemini models when sdkSessionId is provided', () => { + const args = provider.buildCliArgs({ + prompt: 'Continue task', + model: 'gemini-3-pro', + cwd: '/tmp/project', + sdkSessionId: 'cursor-gemini-session-123', + }); + + const resumeIndex = args.indexOf('--resume'); + expect(resumeIndex).toBeGreaterThan(-1); + expect(args[resumeIndex + 1]).toBe('cursor-gemini-session-123'); + }); + }); + + describe('validateBareModelId with Cursor Gemini models', () => { + it('should allow gemini- prefixed models for Cursor provider with expectedProvider="cursor"', () => { + // This is the key fix - Cursor Gemini models have bare IDs like "gemini-3-pro" + expect(() => validateBareModelId('gemini-3-pro', 'CursorProvider', 'cursor')).not.toThrow(); + expect(() => + validateBareModelId('gemini-3-flash', 'CursorProvider', 'cursor') + ).not.toThrow(); + }); + + it('should still reject other provider prefixes for Cursor provider', () => { + expect(() => validateBareModelId('codex-gpt-4', 'CursorProvider', 'cursor')).toThrow(); + expect(() => validateBareModelId('copilot-gpt-4', 'CursorProvider', 'cursor')).toThrow(); + expect(() => validateBareModelId('opencode-gpt-4', 'CursorProvider', 'cursor')).toThrow(); + }); + + it('should accept cursor- prefixed models when expectedProvider is "cursor" (for double-prefix validation)', () => { + // Note: When expectedProvider="cursor", we skip the cursor- prefix check + // This is intentional because the validation happens AFTER prefix stripping + // So if cursor-gemini-3-pro reaches validateBareModelId with expectedProvider="cursor", + // it means the prefix was NOT properly stripped, but we skip it anyway + // since we're checking if the Cursor provider itself can receive cursor- prefixed models + expect(() => + validateBareModelId('cursor-gemini-3-pro', 'CursorProvider', 'cursor') + ).not.toThrow(); + }); + }); + }); }); diff --git a/apps/server/tests/unit/providers/gemini-provider.test.ts b/apps/server/tests/unit/providers/gemini-provider.test.ts index 9a29c765e..5dffc5688 100644 --- a/apps/server/tests/unit/providers/gemini-provider.test.ts +++ b/apps/server/tests/unit/providers/gemini-provider.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, beforeEach } from 'vitest'; import { GeminiProvider } from '@/providers/gemini-provider.js'; import type { ProviderMessage } from '@automaker/types'; +import { validateBareModelId } from '@automaker/types'; describe('gemini-provider.ts', () => { let provider: GeminiProvider; @@ -253,4 +254,19 @@ describe('gemini-provider.ts', () => { expect(msg.subtype).toBe('success'); }); }); + + describe('validateBareModelId integration', () => { + it('should allow gemini- prefixed models for Gemini provider with expectedProvider="gemini"', () => { + expect(() => + validateBareModelId('gemini-2.5-flash', 'GeminiProvider', 'gemini') + ).not.toThrow(); + expect(() => validateBareModelId('gemini-2.5-pro', 'GeminiProvider', 'gemini')).not.toThrow(); + }); + + it('should reject other provider prefixes for Gemini provider', () => { + expect(() => validateBareModelId('cursor-gpt-4', 'GeminiProvider', 'gemini')).toThrow(); + expect(() => validateBareModelId('codex-gpt-4', 'GeminiProvider', 'gemini')).toThrow(); + expect(() => validateBareModelId('copilot-gpt-4', 'GeminiProvider', 'gemini')).toThrow(); + }); + }); }); diff --git a/apps/server/tests/unit/routes/app-spec/parse-and-create-features-defaults.test.ts b/apps/server/tests/unit/routes/app-spec/parse-and-create-features-defaults.test.ts new file mode 100644 index 000000000..8f1080ac7 --- /dev/null +++ b/apps/server/tests/unit/routes/app-spec/parse-and-create-features-defaults.test.ts @@ -0,0 +1,270 @@ +/** + * Tests for default fields applied to features created by parseAndCreateFeatures + * + * Verifies that auto-created features include planningMode: 'skip', + * requirePlanApproval: false, and dependencies: []. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import path from 'path'; + +// Use vi.hoisted to create mock functions that can be referenced in vi.mock factories +const { mockMkdir, mockAtomicWriteJson, mockExtractJsonWithArray, mockCreateNotification } = + vi.hoisted(() => ({ + mockMkdir: vi.fn().mockResolvedValue(undefined), + mockAtomicWriteJson: vi.fn().mockResolvedValue(undefined), + mockExtractJsonWithArray: vi.fn(), + mockCreateNotification: vi.fn().mockResolvedValue(undefined), + })); + +vi.mock('@/lib/secure-fs.js', () => ({ + mkdir: mockMkdir, +})); + +vi.mock('@automaker/utils', () => ({ + createLogger: vi.fn().mockReturnValue({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }), + atomicWriteJson: mockAtomicWriteJson, + DEFAULT_BACKUP_COUNT: 3, +})); + +vi.mock('@automaker/platform', () => ({ + getFeaturesDir: vi.fn((projectPath: string) => path.join(projectPath, '.automaker', 'features')), +})); + +vi.mock('@/lib/json-extractor.js', () => ({ + extractJsonWithArray: mockExtractJsonWithArray, +})); + +vi.mock('@/services/notification-service.js', () => ({ + getNotificationService: vi.fn(() => ({ + createNotification: mockCreateNotification, + })), +})); + +// Import after mocks are set up +import { parseAndCreateFeatures } from '../../../../src/routes/app-spec/parse-and-create-features.js'; + +describe('parseAndCreateFeatures - default fields', () => { + const mockEvents = { + emit: vi.fn(), + } as any; + + const projectPath = '/test/project'; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should set planningMode to "skip" on created features', async () => { + mockExtractJsonWithArray.mockReturnValue({ + features: [ + { + id: 'feature-1', + title: 'Test Feature', + description: 'A test feature', + priority: 1, + complexity: 'simple', + }, + ], + }); + + await parseAndCreateFeatures(projectPath, 'content', mockEvents); + + expect(mockAtomicWriteJson).toHaveBeenCalledTimes(1); + const writtenData = mockAtomicWriteJson.mock.calls[0][1]; + expect(writtenData.planningMode).toBe('skip'); + }); + + it('should set requirePlanApproval to false on created features', async () => { + mockExtractJsonWithArray.mockReturnValue({ + features: [ + { + id: 'feature-1', + title: 'Test Feature', + description: 'A test feature', + }, + ], + }); + + await parseAndCreateFeatures(projectPath, 'content', mockEvents); + + const writtenData = mockAtomicWriteJson.mock.calls[0][1]; + expect(writtenData.requirePlanApproval).toBe(false); + }); + + it('should set dependencies to empty array when not provided', async () => { + mockExtractJsonWithArray.mockReturnValue({ + features: [ + { + id: 'feature-1', + title: 'Test Feature', + description: 'A test feature', + }, + ], + }); + + await parseAndCreateFeatures(projectPath, 'content', mockEvents); + + const writtenData = mockAtomicWriteJson.mock.calls[0][1]; + expect(writtenData.dependencies).toEqual([]); + }); + + it('should preserve dependencies when provided by the parser', async () => { + mockExtractJsonWithArray.mockReturnValue({ + features: [ + { + id: 'feature-1', + title: 'Test Feature', + description: 'A test feature', + dependencies: ['feature-0'], + }, + ], + }); + + await parseAndCreateFeatures(projectPath, 'content', mockEvents); + + const writtenData = mockAtomicWriteJson.mock.calls[0][1]; + expect(writtenData.dependencies).toEqual(['feature-0']); + }); + + it('should apply all default fields consistently across multiple features', async () => { + mockExtractJsonWithArray.mockReturnValue({ + features: [ + { + id: 'feature-1', + title: 'Feature 1', + description: 'First feature', + }, + { + id: 'feature-2', + title: 'Feature 2', + description: 'Second feature', + dependencies: ['feature-1'], + }, + { + id: 'feature-3', + title: 'Feature 3', + description: 'Third feature', + }, + ], + }); + + await parseAndCreateFeatures(projectPath, 'content', mockEvents); + + expect(mockAtomicWriteJson).toHaveBeenCalledTimes(3); + + for (let i = 0; i < 3; i++) { + const writtenData = mockAtomicWriteJson.mock.calls[i][1]; + expect(writtenData.planningMode, `feature ${i + 1} planningMode`).toBe('skip'); + expect(writtenData.requirePlanApproval, `feature ${i + 1} requirePlanApproval`).toBe(false); + expect(Array.isArray(writtenData.dependencies), `feature ${i + 1} dependencies`).toBe(true); + } + + // Feature 2 should have its explicit dependency preserved + expect(mockAtomicWriteJson.mock.calls[1][1].dependencies).toEqual(['feature-1']); + // Features 1 and 3 should have empty arrays + expect(mockAtomicWriteJson.mock.calls[0][1].dependencies).toEqual([]); + expect(mockAtomicWriteJson.mock.calls[2][1].dependencies).toEqual([]); + }); + + it('should set status to "backlog" on all created features', async () => { + mockExtractJsonWithArray.mockReturnValue({ + features: [ + { + id: 'feature-1', + title: 'Test Feature', + description: 'A test feature', + }, + ], + }); + + await parseAndCreateFeatures(projectPath, 'content', mockEvents); + + const writtenData = mockAtomicWriteJson.mock.calls[0][1]; + expect(writtenData.status).toBe('backlog'); + }); + + it('should include createdAt and updatedAt timestamps', async () => { + mockExtractJsonWithArray.mockReturnValue({ + features: [ + { + id: 'feature-1', + title: 'Test Feature', + description: 'A test feature', + }, + ], + }); + + await parseAndCreateFeatures(projectPath, 'content', mockEvents); + + const writtenData = mockAtomicWriteJson.mock.calls[0][1]; + expect(writtenData.createdAt).toBeDefined(); + expect(writtenData.updatedAt).toBeDefined(); + // Should be valid ISO date strings + expect(new Date(writtenData.createdAt).toISOString()).toBe(writtenData.createdAt); + expect(new Date(writtenData.updatedAt).toISOString()).toBe(writtenData.updatedAt); + }); + + it('should use default values for optional fields not provided', async () => { + mockExtractJsonWithArray.mockReturnValue({ + features: [ + { + id: 'feature-minimal', + title: 'Minimal Feature', + description: 'Only required fields', + }, + ], + }); + + await parseAndCreateFeatures(projectPath, 'content', mockEvents); + + const writtenData = mockAtomicWriteJson.mock.calls[0][1]; + expect(writtenData.category).toBe('Uncategorized'); + expect(writtenData.priority).toBe(2); + expect(writtenData.complexity).toBe('moderate'); + expect(writtenData.dependencies).toEqual([]); + expect(writtenData.planningMode).toBe('skip'); + expect(writtenData.requirePlanApproval).toBe(false); + }); + + it('should emit success event after creating features', async () => { + mockExtractJsonWithArray.mockReturnValue({ + features: [ + { + id: 'feature-1', + title: 'Feature 1', + description: 'First', + }, + ], + }); + + await parseAndCreateFeatures(projectPath, 'content', mockEvents); + + expect(mockEvents.emit).toHaveBeenCalledWith( + 'spec-regeneration:event', + expect.objectContaining({ + type: 'spec_regeneration_complete', + projectPath, + }) + ); + }); + + it('should emit error event when no valid JSON is found', async () => { + mockExtractJsonWithArray.mockReturnValue(null); + + await parseAndCreateFeatures(projectPath, 'invalid content', mockEvents); + + expect(mockEvents.emit).toHaveBeenCalledWith( + 'spec-regeneration:event', + expect.objectContaining({ + type: 'spec_regeneration_error', + projectPath, + }) + ); + }); +}); diff --git a/apps/server/tests/unit/routes/backlog-plan/apply.test.ts b/apps/server/tests/unit/routes/backlog-plan/apply.test.ts new file mode 100644 index 000000000..44b5f2709 --- /dev/null +++ b/apps/server/tests/unit/routes/backlog-plan/apply.test.ts @@ -0,0 +1,149 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { mockGetAll, mockCreate, mockUpdate, mockDelete, mockClearBacklogPlan } = vi.hoisted(() => ({ + mockGetAll: vi.fn(), + mockCreate: vi.fn(), + mockUpdate: vi.fn(), + mockDelete: vi.fn(), + mockClearBacklogPlan: vi.fn(), +})); + +vi.mock('@/services/feature-loader.js', () => ({ + FeatureLoader: class { + getAll = mockGetAll; + create = mockCreate; + update = mockUpdate; + delete = mockDelete; + }, +})); + +vi.mock('@/routes/backlog-plan/common.js', () => ({ + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, + clearBacklogPlan: mockClearBacklogPlan, + getErrorMessage: (error: unknown) => (error instanceof Error ? error.message : String(error)), + logError: vi.fn(), +})); + +import { createApplyHandler } from '@/routes/backlog-plan/routes/apply.js'; + +function createMockRes() { + const res: { + status: ReturnType; + json: ReturnType; + } = { + status: vi.fn(), + json: vi.fn(), + }; + res.status.mockReturnValue(res); + return res; +} + +describe('createApplyHandler', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockGetAll.mockResolvedValue([]); + mockCreate.mockResolvedValue({ id: 'feature-created' }); + mockUpdate.mockResolvedValue({}); + mockDelete.mockResolvedValue(true); + mockClearBacklogPlan.mockResolvedValue(undefined); + }); + + it('applies default feature model and planning settings when backlog plan additions omit them', async () => { + const settingsService = { + getGlobalSettings: vi.fn().mockResolvedValue({ + defaultFeatureModel: { model: 'codex-gpt-5.2-codex', reasoningEffort: 'high' }, + defaultPlanningMode: 'spec', + defaultRequirePlanApproval: true, + }), + getProjectSettings: vi.fn().mockResolvedValue({}), + } as any; + + const req = { + body: { + projectPath: '/tmp/project', + plan: { + changes: [ + { + type: 'add', + feature: { + id: 'feature-from-plan', + title: 'Created from plan', + description: 'desc', + }, + }, + ], + }, + }, + } as any; + const res = createMockRes(); + + await createApplyHandler(settingsService)(req, res as any); + + expect(mockCreate).toHaveBeenCalledWith( + '/tmp/project', + expect.objectContaining({ + model: 'codex-gpt-5.2-codex', + reasoningEffort: 'high', + planningMode: 'spec', + requirePlanApproval: true, + }) + ); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + success: true, + }) + ); + }); + + it('uses project default feature model override and enforces no approval for skip mode', async () => { + const settingsService = { + getGlobalSettings: vi.fn().mockResolvedValue({ + defaultFeatureModel: { model: 'claude-opus' }, + defaultPlanningMode: 'skip', + defaultRequirePlanApproval: true, + }), + getProjectSettings: vi.fn().mockResolvedValue({ + defaultFeatureModel: { + model: 'GLM-4.7', + providerId: 'provider-glm', + thinkingLevel: 'adaptive', + }, + }), + } as any; + + const req = { + body: { + projectPath: '/tmp/project', + plan: { + changes: [ + { + type: 'add', + feature: { + id: 'feature-from-plan', + title: 'Created from plan', + }, + }, + ], + }, + }, + } as any; + const res = createMockRes(); + + await createApplyHandler(settingsService)(req, res as any); + + expect(mockCreate).toHaveBeenCalledWith( + '/tmp/project', + expect.objectContaining({ + model: 'GLM-4.7', + providerId: 'provider-glm', + thinkingLevel: 'adaptive', + planningMode: 'skip', + requirePlanApproval: false, + }) + ); + }); +}); diff --git a/apps/server/tests/unit/routes/running-agents.test.ts b/apps/server/tests/unit/routes/running-agents.test.ts index 592796684..dfd2e2ab6 100644 --- a/apps/server/tests/unit/routes/running-agents.test.ts +++ b/apps/server/tests/unit/routes/running-agents.test.ts @@ -47,6 +47,8 @@ describe('running-agents routes', () => { projectPath: '/home/user/project', projectName: 'project', isAutoMode: true, + model: 'claude-sonnet-4-20250514', + provider: 'claude', title: 'Implement login feature', description: 'Add user authentication with OAuth', }, @@ -55,6 +57,8 @@ describe('running-agents routes', () => { projectPath: '/home/user/other-project', projectName: 'other-project', isAutoMode: false, + model: 'codex-gpt-5.1', + provider: 'codex', title: 'Fix navigation bug', description: undefined, }, @@ -82,6 +86,8 @@ describe('running-agents routes', () => { projectPath: '/project', projectName: 'project', isAutoMode: true, + model: undefined, + provider: undefined, title: undefined, description: undefined, }, @@ -141,6 +147,8 @@ describe('running-agents routes', () => { projectPath: `/project-${i}`, projectName: `project-${i}`, isAutoMode: i % 2 === 0, + model: i % 3 === 0 ? 'claude-sonnet-4-20250514' : 'claude-haiku-4-5', + provider: 'claude', title: `Feature ${i}`, description: `Description ${i}`, })); @@ -167,6 +175,8 @@ describe('running-agents routes', () => { projectPath: '/workspace/project-alpha', projectName: 'project-alpha', isAutoMode: true, + model: 'claude-sonnet-4-20250514', + provider: 'claude', title: 'Feature A', description: 'In project alpha', }, @@ -175,6 +185,8 @@ describe('running-agents routes', () => { projectPath: '/workspace/project-beta', projectName: 'project-beta', isAutoMode: false, + model: 'codex-gpt-5.1', + provider: 'codex', title: 'Feature B', description: 'In project beta', }, @@ -191,5 +203,56 @@ describe('running-agents routes', () => { expect(response.runningAgents[0].projectPath).toBe('/workspace/project-alpha'); expect(response.runningAgents[1].projectPath).toBe('/workspace/project-beta'); }); + + it('should include model and provider information for running agents', async () => { + // Arrange + const runningAgents = [ + { + featureId: 'feature-claude', + projectPath: '/project', + projectName: 'project', + isAutoMode: true, + model: 'claude-sonnet-4-20250514', + provider: 'claude', + title: 'Claude Feature', + description: 'Using Claude model', + }, + { + featureId: 'feature-codex', + projectPath: '/project', + projectName: 'project', + isAutoMode: false, + model: 'codex-gpt-5.1', + provider: 'codex', + title: 'Codex Feature', + description: 'Using Codex model', + }, + { + featureId: 'feature-cursor', + projectPath: '/project', + projectName: 'project', + isAutoMode: false, + model: 'cursor-auto', + provider: 'cursor', + title: 'Cursor Feature', + description: 'Using Cursor model', + }, + ]; + + vi.mocked(mockAutoModeService.getRunningAgents!).mockResolvedValue(runningAgents); + + // Act + const handler = createIndexHandler(mockAutoModeService as AutoModeService); + await handler(req, res); + + // Assert + const response = vi.mocked(res.json).mock.calls[0][0]; + expect(response.runningAgents[0].model).toBe('claude-sonnet-4-20250514'); + expect(response.runningAgents[0].provider).toBe('claude'); + expect(response.runningAgents[1].model).toBe('codex-gpt-5.1'); + expect(response.runningAgents[1].provider).toBe('codex'); + expect(response.runningAgents[2].model).toBe('cursor-auto'); + expect(response.runningAgents[2].provider).toBe('cursor'); + }); }); }); diff --git a/apps/server/tests/unit/routes/worktree/list-detached-head.test.ts b/apps/server/tests/unit/routes/worktree/list-detached-head.test.ts new file mode 100644 index 000000000..7163603bd --- /dev/null +++ b/apps/server/tests/unit/routes/worktree/list-detached-head.test.ts @@ -0,0 +1,930 @@ +/** + * Tests for worktree list endpoint handling of detached HEAD state. + * + * When a worktree is in detached HEAD state (e.g., during a rebase), + * `git worktree list --porcelain` outputs "detached" instead of + * "branch refs/heads/...". Previously, these worktrees were silently + * dropped from the response because the parser required both path AND branch. + */ + +import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'; +import type { Request, Response } from 'express'; +import { exec } from 'child_process'; +import { createMockExpressContext } from '../../../utils/mocks.js'; + +// Mock all external dependencies before importing the module under test +vi.mock('child_process', () => ({ + exec: vi.fn(), +})); + +vi.mock('@/lib/git.js', () => ({ + execGitCommand: vi.fn(), +})); + +vi.mock('@automaker/git-utils', () => ({ + isGitRepo: vi.fn(async () => true), +})); + +vi.mock('@automaker/utils', () => ({ + createLogger: () => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }), +})); + +vi.mock('@automaker/types', () => ({ + validatePRState: vi.fn((state: string) => state), +})); + +vi.mock('@/lib/secure-fs.js', () => ({ + access: vi.fn().mockResolvedValue(undefined), + readFile: vi.fn(), + readdir: vi.fn().mockResolvedValue([]), + stat: vi.fn(), +})); + +vi.mock('@/lib/worktree-metadata.js', () => ({ + readAllWorktreeMetadata: vi.fn(async () => new Map()), + updateWorktreePRInfo: vi.fn(async () => undefined), +})); + +vi.mock('@/routes/worktree/common.js', async (importOriginal) => { + const actual = (await importOriginal()) as Record; + return { + ...actual, + getErrorMessage: vi.fn((e: Error) => e?.message || 'Unknown error'), + logError: vi.fn(), + normalizePath: vi.fn((p: string) => p), + execEnv: {}, + isGhCliAvailable: vi.fn().mockResolvedValue(false), + }; +}); + +vi.mock('@/routes/github/routes/check-github-remote.js', () => ({ + checkGitHubRemote: vi.fn().mockResolvedValue({ hasGitHubRemote: false }), +})); + +import { createListHandler } from '@/routes/worktree/routes/list.js'; +import * as secureFs from '@/lib/secure-fs.js'; +import { execGitCommand } from '@/lib/git.js'; +import { readAllWorktreeMetadata, updateWorktreePRInfo } from '@/lib/worktree-metadata.js'; +import { isGitRepo } from '@automaker/git-utils'; +import { isGhCliAvailable, normalizePath, getErrorMessage } from '@/routes/worktree/common.js'; +import { checkGitHubRemote } from '@/routes/github/routes/check-github-remote.js'; + +/** + * Set up execGitCommand mock (list handler uses this via lib/git.js, not child_process.exec). + */ +function setupExecGitCommandMock(options: { + porcelainOutput: string; + projectBranch?: string; + gitDirs?: Record; + worktreeBranches?: Record; +}) { + const { porcelainOutput, projectBranch = 'main', gitDirs = {}, worktreeBranches = {} } = options; + + vi.mocked(execGitCommand).mockImplementation(async (args: string[], cwd: string) => { + if (args[0] === 'worktree' && args[1] === 'list' && args[2] === '--porcelain') { + return porcelainOutput; + } + if (args[0] === 'branch' && args[1] === '--show-current') { + if (worktreeBranches[cwd] !== undefined) { + return worktreeBranches[cwd] + '\n'; + } + return projectBranch + '\n'; + } + if (args[0] === 'rev-parse' && args[1] === '--git-dir') { + if (cwd && gitDirs[cwd]) { + return gitDirs[cwd] + '\n'; + } + throw new Error('not a git directory'); + } + if (args[0] === 'rev-parse' && args[1] === '--abbrev-ref' && args[2] === 'HEAD') { + return 'HEAD\n'; + } + if (args[0] === 'worktree' && args[1] === 'prune') { + return ''; + } + if (args[0] === 'status' && args[1] === '--porcelain') { + return ''; + } + if (args[0] === 'diff' && args[1] === '--name-only' && args[2] === '--diff-filter=U') { + return ''; + } + return ''; + }); +} + +describe('worktree list - detached HEAD handling', () => { + let req: Request; + let res: Response; + + beforeEach(() => { + vi.clearAllMocks(); + const context = createMockExpressContext(); + req = context.req; + res = context.res; + + // Re-establish mock implementations cleared by mockReset/clearAllMocks + vi.mocked(isGitRepo).mockResolvedValue(true); + vi.mocked(readAllWorktreeMetadata).mockResolvedValue(new Map()); + vi.mocked(isGhCliAvailable).mockResolvedValue(false); + vi.mocked(checkGitHubRemote).mockResolvedValue({ hasGitHubRemote: false }); + vi.mocked(normalizePath).mockImplementation((p: string) => p); + vi.mocked(getErrorMessage).mockImplementation( + (e: unknown) => (e as Error)?.message || 'Unknown error' + ); + + // Default: all paths exist + vi.mocked(secureFs.access).mockResolvedValue(undefined); + // Default: .worktrees directory doesn't exist (no scan via readdir) + vi.mocked(secureFs.readdir).mockRejectedValue(new Error('ENOENT')); + // Default: readFile fails + vi.mocked(secureFs.readFile).mockRejectedValue(new Error('ENOENT')); + + // Default execGitCommand so list handler gets valid porcelain/branch output (vitest clearMocks resets implementations) + setupExecGitCommandMock({ + porcelainOutput: 'worktree /project\nbranch refs/heads/main\n\n', + projectBranch: 'main', + }); + }); + + /** + * Helper: set up execGitCommand mock for the list handler. + * Worktree-specific behavior can be customized via the options parameter. + */ + function setupStandardExec(options: { + porcelainOutput: string; + projectBranch?: string; + /** Map of worktree path -> git-dir path */ + gitDirs?: Record; + /** Map of worktree cwd -> branch for `git branch --show-current` */ + worktreeBranches?: Record; + }) { + setupExecGitCommandMock(options); + } + + /** Suppress .worktrees dir scan by making access throw for the .worktrees dir. */ + function disableWorktreesScan() { + vi.mocked(secureFs.access).mockImplementation(async (p) => { + const pathStr = String(p); + // Block only the .worktrees dir access check in scanWorktreesDirectory + if (pathStr.endsWith('.worktrees') || pathStr.endsWith('.worktrees/')) { + throw new Error('ENOENT'); + } + // All other paths exist + return undefined; + }); + } + + describe('porcelain parser', () => { + it('should include normal worktrees with branch lines', async () => { + req.body = { projectPath: '/project' }; + + setupStandardExec({ + porcelainOutput: [ + 'worktree /project', + 'branch refs/heads/main', + '', + 'worktree /project/.worktrees/feature-a', + 'branch refs/heads/feature-a', + '', + ].join('\n'), + }); + disableWorktreesScan(); + + const handler = createListHandler(); + await handler(req, res); + + const response = vi.mocked(res.json).mock.calls[0][0] as { + success: boolean; + worktrees: Array<{ branch: string; path: string; isMain: boolean; hasWorktree: boolean }>; + }; + + expect(response.success).toBe(true); + expect(response.worktrees).toHaveLength(2); + expect(response.worktrees[0]).toEqual( + expect.objectContaining({ + path: '/project', + branch: 'main', + isMain: true, + hasWorktree: true, + }) + ); + expect(response.worktrees[1]).toEqual( + expect.objectContaining({ + path: '/project/.worktrees/feature-a', + branch: 'feature-a', + isMain: false, + hasWorktree: true, + }) + ); + }); + + it('should include worktrees with detached HEAD and recover branch from rebase-merge state', async () => { + req.body = { projectPath: '/project' }; + + setupStandardExec({ + porcelainOutput: [ + 'worktree /project', + 'branch refs/heads/main', + '', + 'worktree /project/.worktrees/rebasing-wt', + 'detached', + '', + ].join('\n'), + gitDirs: { + '/project/.worktrees/rebasing-wt': '/project/.worktrees/rebasing-wt/.git', + }, + }); + disableWorktreesScan(); + + // rebase-merge/head-name returns the branch being rebased + vi.mocked(secureFs.readFile).mockImplementation(async (filePath) => { + const pathStr = String(filePath); + if (pathStr.includes('rebase-merge/head-name')) { + return 'refs/heads/feature/my-rebasing-branch\n' as any; + } + throw new Error('ENOENT'); + }); + + const handler = createListHandler(); + await handler(req, res); + + const response = vi.mocked(res.json).mock.calls[0][0] as { + worktrees: Array<{ branch: string; path: string; isCurrent: boolean }>; + }; + expect(response.worktrees).toHaveLength(2); + expect(response.worktrees[1]).toEqual( + expect.objectContaining({ + path: '/project/.worktrees/rebasing-wt', + branch: 'feature/my-rebasing-branch', + isMain: false, + isCurrent: false, + hasWorktree: true, + }) + ); + }); + + it('should include worktrees with detached HEAD and recover branch from rebase-apply state', async () => { + req.body = { projectPath: '/project' }; + + setupStandardExec({ + porcelainOutput: [ + 'worktree /project', + 'branch refs/heads/main', + '', + 'worktree /project/.worktrees/apply-wt', + 'detached', + '', + ].join('\n'), + gitDirs: { + '/project/.worktrees/apply-wt': '/project/.worktrees/apply-wt/.git', + }, + }); + disableWorktreesScan(); + + // rebase-merge doesn't exist, but rebase-apply does + vi.mocked(secureFs.readFile).mockImplementation(async (filePath) => { + const pathStr = String(filePath); + if (pathStr.includes('rebase-apply/head-name')) { + return 'refs/heads/feature/apply-branch\n' as any; + } + throw new Error('ENOENT'); + }); + + const handler = createListHandler(); + await handler(req, res); + + const response = vi.mocked(res.json).mock.calls[0][0] as { + worktrees: Array<{ branch: string; path: string }>; + }; + const detachedWt = response.worktrees.find((w) => w.path === '/project/.worktrees/apply-wt'); + expect(detachedWt).toBeDefined(); + expect(detachedWt!.branch).toBe('feature/apply-branch'); + }); + + it('should show merge conflict worktrees normally since merge does not detach HEAD', async () => { + // During a merge conflict, HEAD stays on the branch, so `git worktree list --porcelain` + // still outputs `branch refs/heads/...`. This test verifies merge conflicts don't + // trigger the detached HEAD recovery path. + req.body = { projectPath: '/project' }; + + setupStandardExec({ + porcelainOutput: [ + 'worktree /project', + 'branch refs/heads/main', + '', + 'worktree /project/.worktrees/merge-wt', + 'branch refs/heads/feature/merge-branch', + '', + ].join('\n'), + }); + disableWorktreesScan(); + + const handler = createListHandler(); + await handler(req, res); + + const response = vi.mocked(res.json).mock.calls[0][0] as { + worktrees: Array<{ branch: string; path: string }>; + }; + const mergeWt = response.worktrees.find((w) => w.path === '/project/.worktrees/merge-wt'); + expect(mergeWt).toBeDefined(); + expect(mergeWt!.branch).toBe('feature/merge-branch'); + }); + + it('should fall back to (detached) when all branch recovery methods fail', async () => { + req.body = { projectPath: '/project' }; + + setupStandardExec({ + porcelainOutput: [ + 'worktree /project', + 'branch refs/heads/main', + '', + 'worktree /project/.worktrees/unknown-wt', + 'detached', + '', + ].join('\n'), + worktreeBranches: { + '/project/.worktrees/unknown-wt': '', // empty = no branch + }, + }); + disableWorktreesScan(); + + // All readFile calls fail (no gitDirs so rev-parse --git-dir will throw) + vi.mocked(secureFs.readFile).mockRejectedValue(new Error('ENOENT')); + + const handler = createListHandler(); + await handler(req, res); + + const response = vi.mocked(res.json).mock.calls[0][0] as { + worktrees: Array<{ branch: string; path: string }>; + }; + const detachedWt = response.worktrees.find( + (w) => w.path === '/project/.worktrees/unknown-wt' + ); + expect(detachedWt).toBeDefined(); + expect(detachedWt!.branch).toBe('(detached)'); + }); + + it('should not include detached worktree when directory does not exist on disk', async () => { + req.body = { projectPath: '/project' }; + + setupStandardExec({ + porcelainOutput: [ + 'worktree /project', + 'branch refs/heads/main', + '', + 'worktree /project/.worktrees/deleted-wt', + 'detached', + '', + ].join('\n'), + }); + + // The deleted worktree doesn't exist on disk + vi.mocked(secureFs.access).mockImplementation(async (p) => { + const pathStr = String(p); + if (pathStr.includes('deleted-wt')) { + throw new Error('ENOENT'); + } + if (pathStr.endsWith('.worktrees') || pathStr.endsWith('.worktrees/')) { + throw new Error('ENOENT'); + } + return undefined; + }); + + const handler = createListHandler(); + await handler(req, res); + + const response = vi.mocked(res.json).mock.calls[0][0] as { + worktrees: Array<{ branch: string; path: string }>; + }; + // Only the main worktree should be present + expect(response.worktrees).toHaveLength(1); + expect(response.worktrees[0].path).toBe('/project'); + }); + + it('should set isCurrent to false for detached worktrees even if recovered branch matches current branch', async () => { + req.body = { projectPath: '/project' }; + + setupStandardExec({ + porcelainOutput: [ + 'worktree /project', + 'branch refs/heads/main', + '', + 'worktree /project/.worktrees/rebasing-wt', + 'detached', + '', + ].join('\n'), + // currentBranch for project is 'feature/my-branch' + projectBranch: 'feature/my-branch', + gitDirs: { + '/project/.worktrees/rebasing-wt': '/project/.worktrees/rebasing-wt/.git', + }, + }); + disableWorktreesScan(); + + // Recovery returns the same branch as currentBranch + vi.mocked(secureFs.readFile).mockImplementation(async (filePath) => { + const pathStr = String(filePath); + if (pathStr.includes('rebase-merge/head-name')) { + return 'refs/heads/feature/my-branch\n' as any; + } + throw new Error('ENOENT'); + }); + + const handler = createListHandler(); + await handler(req, res); + + const response = vi.mocked(res.json).mock.calls[0][0] as { + worktrees: Array<{ branch: string; isCurrent: boolean; path: string }>; + }; + const detachedWt = response.worktrees.find( + (w) => w.path === '/project/.worktrees/rebasing-wt' + ); + expect(detachedWt).toBeDefined(); + // Detached worktrees should always have isCurrent=false + expect(detachedWt!.isCurrent).toBe(false); + }); + + it('should handle mixed normal and detached worktrees', async () => { + req.body = { projectPath: '/project' }; + + setupStandardExec({ + porcelainOutput: [ + 'worktree /project', + 'branch refs/heads/main', + '', + 'worktree /project/.worktrees/normal-wt', + 'branch refs/heads/feature-normal', + '', + 'worktree /project/.worktrees/rebasing-wt', + 'detached', + '', + 'worktree /project/.worktrees/another-normal', + 'branch refs/heads/feature-other', + '', + ].join('\n'), + gitDirs: { + '/project/.worktrees/rebasing-wt': '/project/.worktrees/rebasing-wt/.git', + }, + }); + disableWorktreesScan(); + + vi.mocked(secureFs.readFile).mockImplementation(async (filePath) => { + const pathStr = String(filePath); + if (pathStr.includes('rebase-merge/head-name')) { + return 'refs/heads/feature/rebasing\n' as any; + } + throw new Error('ENOENT'); + }); + + const handler = createListHandler(); + await handler(req, res); + + const response = vi.mocked(res.json).mock.calls[0][0] as { + worktrees: Array<{ branch: string; path: string; isMain: boolean }>; + }; + expect(response.worktrees).toHaveLength(4); + expect(response.worktrees[0]).toEqual( + expect.objectContaining({ path: '/project', branch: 'main', isMain: true }) + ); + expect(response.worktrees[1]).toEqual( + expect.objectContaining({ + path: '/project/.worktrees/normal-wt', + branch: 'feature-normal', + isMain: false, + }) + ); + expect(response.worktrees[2]).toEqual( + expect.objectContaining({ + path: '/project/.worktrees/rebasing-wt', + branch: 'feature/rebasing', + isMain: false, + }) + ); + expect(response.worktrees[3]).toEqual( + expect.objectContaining({ + path: '/project/.worktrees/another-normal', + branch: 'feature-other', + isMain: false, + }) + ); + }); + + it('should correctly advance isFirst flag past detached worktrees', async () => { + req.body = { projectPath: '/project' }; + + setupStandardExec({ + porcelainOutput: [ + 'worktree /project', + 'branch refs/heads/main', + '', + 'worktree /project/.worktrees/detached-wt', + 'detached', + '', + 'worktree /project/.worktrees/normal-wt', + 'branch refs/heads/feature-x', + '', + ].join('\n'), + }); + disableWorktreesScan(); + vi.mocked(secureFs.readFile).mockRejectedValue(new Error('ENOENT')); + + const handler = createListHandler(); + await handler(req, res); + + const response = vi.mocked(res.json).mock.calls[0][0] as { + worktrees: Array<{ branch: string; isMain: boolean }>; + }; + expect(response.worktrees).toHaveLength(3); + expect(response.worktrees[0].isMain).toBe(true); // main + expect(response.worktrees[1].isMain).toBe(false); // detached + expect(response.worktrees[2].isMain).toBe(false); // normal + }); + + it('should not add removed detached worktrees to removedWorktrees list', async () => { + req.body = { projectPath: '/project' }; + + setupStandardExec({ + porcelainOutput: [ + 'worktree /project', + 'branch refs/heads/main', + '', + 'worktree /project/.worktrees/gone-wt', + 'detached', + '', + ].join('\n'), + }); + + // The detached worktree doesn't exist on disk + vi.mocked(secureFs.access).mockImplementation(async (p) => { + const pathStr = String(p); + if (pathStr.includes('gone-wt')) { + throw new Error('ENOENT'); + } + if (pathStr.endsWith('.worktrees') || pathStr.endsWith('.worktrees/')) { + throw new Error('ENOENT'); + } + return undefined; + }); + + const handler = createListHandler(); + await handler(req, res); + + const response = vi.mocked(res.json).mock.calls[0][0] as { + worktrees: Array<{ branch: string }>; + removedWorktrees?: Array<{ path: string; branch: string }>; + }; + // Should not be in removed list since we don't know the branch + expect(response.removedWorktrees).toBeUndefined(); + }); + + it('should strip refs/heads/ prefix from recovered branch name', async () => { + req.body = { projectPath: '/project' }; + + setupStandardExec({ + porcelainOutput: [ + 'worktree /project', + 'branch refs/heads/main', + '', + 'worktree /project/.worktrees/wt1', + 'detached', + '', + ].join('\n'), + gitDirs: { + '/project/.worktrees/wt1': '/project/.worktrees/wt1/.git', + }, + }); + disableWorktreesScan(); + + vi.mocked(secureFs.readFile).mockImplementation(async (filePath) => { + const pathStr = String(filePath); + if (pathStr.includes('rebase-merge/head-name')) { + return 'refs/heads/my-branch\n' as any; + } + throw new Error('ENOENT'); + }); + + const handler = createListHandler(); + await handler(req, res); + + const response = vi.mocked(res.json).mock.calls[0][0] as { + worktrees: Array<{ branch: string; path: string }>; + }; + const wt = response.worktrees.find((w) => w.path === '/project/.worktrees/wt1'); + expect(wt).toBeDefined(); + // Should be 'my-branch', not 'refs/heads/my-branch' + expect(wt!.branch).toBe('my-branch'); + }); + }); + + describe('scanWorktreesDirectory with detached HEAD recovery', () => { + it('should recover branch for discovered worktrees with detached HEAD', async () => { + req.body = { projectPath: '/project' }; + + vi.mocked(execGitCommand).mockImplementation(async (args: string[], cwd: string) => { + if (args[0] === 'worktree' && args[1] === 'list') { + return 'worktree /project\nbranch refs/heads/main\n\n'; + } + if (args[0] === 'branch' && args[1] === '--show-current') { + return cwd === '/project' ? 'main\n' : '\n'; + } + if (args[0] === 'rev-parse' && args[1] === '--abbrev-ref') { + return 'HEAD\n'; + } + if (args[0] === 'rev-parse' && args[1] === '--git-dir') { + return '/project/.worktrees/orphan-wt/.git\n'; + } + return ''; + }); + + // .worktrees directory exists and has an orphan worktree + vi.mocked(secureFs.access).mockResolvedValue(undefined); + vi.mocked(secureFs.readdir).mockResolvedValue([ + { name: 'orphan-wt', isDirectory: () => true, isFile: () => false } as any, + ]); + vi.mocked(secureFs.stat).mockResolvedValue({ + isFile: () => true, + isDirectory: () => false, + } as any); + + // readFile returns branch from rebase-merge/head-name + vi.mocked(secureFs.readFile).mockImplementation(async (filePath) => { + const pathStr = String(filePath); + if (pathStr.includes('rebase-merge/head-name')) { + return 'refs/heads/feature/orphan-branch\n' as any; + } + throw new Error('ENOENT'); + }); + + const handler = createListHandler(); + await handler(req, res); + + const response = vi.mocked(res.json).mock.calls[0][0] as { + worktrees: Array<{ branch: string; path: string }>; + }; + + const orphanWt = response.worktrees.find((w) => w.path === '/project/.worktrees/orphan-wt'); + expect(orphanWt).toBeDefined(); + expect(orphanWt!.branch).toBe('feature/orphan-branch'); + }); + + it('should skip discovered worktrees when all branch detection fails', async () => { + req.body = { projectPath: '/project' }; + + vi.mocked(execGitCommand).mockImplementation(async (args: string[], cwd: string) => { + if (args[0] === 'worktree' && args[1] === 'list') { + return 'worktree /project\nbranch refs/heads/main\n\n'; + } + if (args[0] === 'branch' && args[1] === '--show-current') { + return cwd === '/project' ? 'main\n' : '\n'; + } + if (args[0] === 'rev-parse' && args[1] === '--abbrev-ref') { + return 'HEAD\n'; + } + if (args[0] === 'rev-parse' && args[1] === '--git-dir') { + throw new Error('not a git dir'); + } + return ''; + }); + + vi.mocked(secureFs.access).mockResolvedValue(undefined); + vi.mocked(secureFs.readdir).mockResolvedValue([ + { name: 'broken-wt', isDirectory: () => true, isFile: () => false } as any, + ]); + vi.mocked(secureFs.stat).mockResolvedValue({ + isFile: () => true, + isDirectory: () => false, + } as any); + vi.mocked(secureFs.readFile).mockRejectedValue(new Error('ENOENT')); + + const handler = createListHandler(); + await handler(req, res); + + const response = vi.mocked(res.json).mock.calls[0][0] as { + worktrees: Array<{ branch: string; path: string }>; + }; + + // Only main worktree should be present + expect(response.worktrees).toHaveLength(1); + expect(response.worktrees[0].branch).toBe('main'); + }); + }); + + describe('PR tracking precedence', () => { + it('should keep manually tracked PR from metadata when branch PR differs', async () => { + req.body = { projectPath: '/project', includeDetails: true }; + + vi.mocked(readAllWorktreeMetadata).mockResolvedValue( + new Map([ + [ + 'feature-a', + { + branch: 'feature-a', + createdAt: '2026-01-01T00:00:00.000Z', + pr: { + number: 99, + url: 'https://github.com/org/repo/pull/99', + title: 'Manual override PR', + state: 'OPEN', + createdAt: '2026-01-01T00:00:00.000Z', + }, + }, + ], + ]) + ); + vi.mocked(isGhCliAvailable).mockResolvedValue(true); + vi.mocked(checkGitHubRemote).mockResolvedValue({ + hasGitHubRemote: true, + owner: 'org', + repo: 'repo', + }); + vi.mocked(secureFs.access).mockImplementation(async (p) => { + const pathStr = String(p); + if ( + pathStr.includes('MERGE_HEAD') || + pathStr.includes('rebase-merge') || + pathStr.includes('rebase-apply') || + pathStr.includes('CHERRY_PICK_HEAD') + ) { + throw new Error('ENOENT'); + } + return undefined; + }); + + vi.mocked(execGitCommand).mockImplementation(async (args: string[], cwd: string) => { + if (args[0] === 'rev-parse' && args[1] === '--git-dir') { + throw new Error('no git dir'); + } + if (args[0] === 'worktree' && args[1] === 'list') { + return [ + 'worktree /project', + 'branch refs/heads/main', + '', + 'worktree /project/.worktrees/feature-a', + 'branch refs/heads/feature-a', + '', + ].join('\n'); + } + if (args[0] === 'branch' && args[1] === '--show-current') { + return cwd === '/project' ? 'main\n' : 'feature-a\n'; + } + if (args[0] === 'status' && args[1] === '--porcelain') { + return ''; + } + return ''; + }); + (exec as unknown as Mock).mockImplementation( + ( + cmd: string, + _opts: unknown, + callback?: (err: Error | null, out: { stdout: string; stderr: string }) => void + ) => { + const cb = typeof _opts === 'function' ? _opts : callback!; + if (cmd.includes('gh pr list')) { + cb(null, { + stdout: JSON.stringify([ + { + number: 42, + title: 'Branch PR', + url: 'https://github.com/org/repo/pull/42', + state: 'OPEN', + headRefName: 'feature-a', + createdAt: '2026-01-02T00:00:00.000Z', + }, + ]), + stderr: '', + }); + } else { + cb(null, { stdout: '', stderr: '' }); + } + } + ); + disableWorktreesScan(); + + const handler = createListHandler(); + await handler(req, res); + + const response = vi.mocked(res.json).mock.calls[0][0] as { + worktrees: Array<{ branch: string; pr?: { number: number; title: string } }>; + }; + const featureWorktree = response.worktrees.find((w) => w.branch === 'feature-a'); + expect(featureWorktree?.pr?.number).toBe(99); + expect(featureWorktree?.pr?.title).toBe('Manual override PR'); + }); + + it('should prefer GitHub PR when it matches metadata number and sync updated fields', async () => { + req.body = { projectPath: '/project-2', includeDetails: true }; + + vi.mocked(readAllWorktreeMetadata).mockResolvedValue( + new Map([ + [ + 'feature-a', + { + branch: 'feature-a', + createdAt: '2026-01-01T00:00:00.000Z', + pr: { + number: 42, + url: 'https://github.com/org/repo/pull/42', + title: 'Old title', + state: 'OPEN', + createdAt: '2026-01-01T00:00:00.000Z', + }, + }, + ], + ]) + ); + vi.mocked(isGhCliAvailable).mockResolvedValue(true); + vi.mocked(checkGitHubRemote).mockResolvedValue({ + hasGitHubRemote: true, + owner: 'org', + repo: 'repo', + }); + vi.mocked(secureFs.access).mockImplementation(async (p) => { + const pathStr = String(p); + if ( + pathStr.includes('MERGE_HEAD') || + pathStr.includes('rebase-merge') || + pathStr.includes('rebase-apply') || + pathStr.includes('CHERRY_PICK_HEAD') + ) { + throw new Error('ENOENT'); + } + return undefined; + }); + + vi.mocked(execGitCommand).mockImplementation(async (args: string[], cwd: string) => { + if (args[0] === 'rev-parse' && args[1] === '--git-dir') { + throw new Error('no git dir'); + } + if (args[0] === 'worktree' && args[1] === 'list') { + return [ + 'worktree /project-2', + 'branch refs/heads/main', + '', + 'worktree /project-2/.worktrees/feature-a', + 'branch refs/heads/feature-a', + '', + ].join('\n'); + } + if (args[0] === 'branch' && args[1] === '--show-current') { + return cwd === '/project-2' ? 'main\n' : 'feature-a\n'; + } + if (args[0] === 'status' && args[1] === '--porcelain') { + return ''; + } + return ''; + }); + (exec as unknown as Mock).mockImplementation( + ( + cmd: string, + _opts: unknown, + callback?: (err: Error | null, out: { stdout: string; stderr: string }) => void + ) => { + const cb = typeof _opts === 'function' ? _opts : callback!; + if (cmd.includes('gh pr list')) { + cb(null, { + stdout: JSON.stringify([ + { + number: 42, + title: 'New title from GitHub', + url: 'https://github.com/org/repo/pull/42', + state: 'MERGED', + headRefName: 'feature-a', + createdAt: '2026-01-02T00:00:00.000Z', + }, + ]), + stderr: '', + }); + } else { + cb(null, { stdout: '', stderr: '' }); + } + } + ); + disableWorktreesScan(); + + const handler = createListHandler(); + await handler(req, res); + + const response = vi.mocked(res.json).mock.calls[0][0] as { + worktrees: Array<{ branch: string; pr?: { number: number; title: string; state: string } }>; + }; + const featureWorktree = response.worktrees.find((w) => w.branch === 'feature-a'); + expect(featureWorktree?.pr?.number).toBe(42); + expect(featureWorktree?.pr?.title).toBe('New title from GitHub'); + expect(featureWorktree?.pr?.state).toBe('MERGED'); + expect(vi.mocked(updateWorktreePRInfo)).toHaveBeenCalledWith( + '/project-2', + 'feature-a', + expect.objectContaining({ + number: 42, + title: 'New title from GitHub', + state: 'MERGED', + }) + ); + }); + }); +}); diff --git a/apps/server/tests/unit/services/agent-executor-summary.test.ts b/apps/server/tests/unit/services/agent-executor-summary.test.ts new file mode 100644 index 000000000..3bb3cc06d --- /dev/null +++ b/apps/server/tests/unit/services/agent-executor-summary.test.ts @@ -0,0 +1,446 @@ +import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'; +import { AgentExecutor } from '../../../src/services/agent-executor.js'; +import type { TypedEventBus } from '../../../src/services/typed-event-bus.js'; +import type { FeatureStateManager } from '../../../src/services/feature-state-manager.js'; +import type { PlanApprovalService } from '../../../src/services/plan-approval-service.js'; +import type { BaseProvider } from '../../../src/providers/base-provider.js'; +import * as secureFs from '../../../src/lib/secure-fs.js'; +import { getFeatureDir } from '@automaker/platform'; +import { buildPromptWithImages } from '@automaker/utils'; + +vi.mock('../../../src/lib/secure-fs.js', () => ({ + mkdir: vi.fn().mockResolvedValue(undefined), + writeFile: vi.fn().mockResolvedValue(undefined), + appendFile: vi.fn().mockResolvedValue(undefined), + readFile: vi.fn().mockResolvedValue(''), +})); + +vi.mock('@automaker/platform', () => ({ + getFeatureDir: vi.fn(), +})); + +vi.mock('@automaker/utils', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + buildPromptWithImages: vi.fn(), + createLogger: vi.fn().mockReturnValue({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }), + }; +}); + +describe('AgentExecutor Summary Extraction', () => { + let mockEventBus: TypedEventBus; + let mockFeatureStateManager: FeatureStateManager; + let mockPlanApprovalService: PlanApprovalService; + + beforeEach(() => { + vi.clearAllMocks(); + + mockEventBus = { + emitAutoModeEvent: vi.fn(), + } as unknown as TypedEventBus; + + mockFeatureStateManager = { + updateTaskStatus: vi.fn().mockResolvedValue(undefined), + updateFeaturePlanSpec: vi.fn().mockResolvedValue(undefined), + saveFeatureSummary: vi.fn().mockResolvedValue(undefined), + } as unknown as FeatureStateManager; + + mockPlanApprovalService = { + waitForApproval: vi.fn(), + } as unknown as PlanApprovalService; + + (getFeatureDir as Mock).mockReturnValue('/mock/feature/dir'); + (buildPromptWithImages as Mock).mockResolvedValue({ content: 'mocked prompt' }); + }); + + it('should extract summary from new session content only', async () => { + const executor = new AgentExecutor( + mockEventBus, + mockFeatureStateManager, + mockPlanApprovalService, + null + ); + + const previousContent = `Some previous work. +Old summary`; + const newWork = `New implementation work. +New summary`; + + const mockProvider = { + getName: () => 'mock', + executeQuery: vi.fn().mockImplementation(function* () { + yield { + type: 'assistant', + message: { + content: [{ type: 'text', text: newWork }], + }, + }; + yield { type: 'result', subtype: 'success' }; + }), + } as unknown as BaseProvider; + + const options = { + workDir: '/test', + featureId: 'test-feature', + prompt: 'Test prompt', + projectPath: '/project', + abortController: new AbortController(), + provider: mockProvider, + effectiveBareModel: 'claude-sonnet', + planningMode: 'skip' as const, + previousContent, + }; + + const callbacks = { + waitForApproval: vi.fn(), + saveFeatureSummary: vi.fn(), + updateFeatureSummary: vi.fn(), + buildTaskPrompt: vi.fn(), + }; + + await executor.execute(options, callbacks); + + // Verify it called saveFeatureSummary with the NEW summary + expect(callbacks.saveFeatureSummary).toHaveBeenCalledWith( + '/project', + 'test-feature', + 'New summary' + ); + + // Ensure it didn't call it with Old summary + expect(callbacks.saveFeatureSummary).not.toHaveBeenCalledWith( + '/project', + 'test-feature', + 'Old summary' + ); + }); + + it('should not save summary if no summary in NEW session content', async () => { + const executor = new AgentExecutor( + mockEventBus, + mockFeatureStateManager, + mockPlanApprovalService, + null + ); + + const previousContent = `Some previous work. +Old summary`; + const newWork = `New implementation work without a summary tag.`; + + const mockProvider = { + getName: () => 'mock', + executeQuery: vi.fn().mockImplementation(function* () { + yield { + type: 'assistant', + message: { + content: [{ type: 'text', text: newWork }], + }, + }; + yield { type: 'result', subtype: 'success' }; + }), + } as unknown as BaseProvider; + + const options = { + workDir: '/test', + featureId: 'test-feature', + prompt: 'Test prompt', + projectPath: '/project', + abortController: new AbortController(), + provider: mockProvider, + effectiveBareModel: 'claude-sonnet', + planningMode: 'skip' as const, + previousContent, + }; + + const callbacks = { + waitForApproval: vi.fn(), + saveFeatureSummary: vi.fn(), + updateFeatureSummary: vi.fn(), + buildTaskPrompt: vi.fn(), + }; + + await executor.execute(options, callbacks); + + // Verify it NEVER called saveFeatureSummary because there was no NEW summary + expect(callbacks.saveFeatureSummary).not.toHaveBeenCalled(); + }); + + it('should extract task summary and update task status during streaming', async () => { + const executor = new AgentExecutor( + mockEventBus, + mockFeatureStateManager, + mockPlanApprovalService, + null + ); + + const mockProvider = { + getName: () => 'mock', + executeQuery: vi.fn().mockImplementation(function* () { + yield { + type: 'assistant', + message: { + content: [{ type: 'text', text: 'Working... ' }], + }, + }; + yield { + type: 'assistant', + message: { + content: [{ type: 'text', text: '[TASK_COMPLETE] T001: Task finished successfully' }], + }, + }; + yield { type: 'result', subtype: 'success' }; + }), + } as unknown as BaseProvider; + + // We trigger executeTasksLoop by providing persistedTasks + const options = { + workDir: '/test', + featureId: 'test-feature', + prompt: 'Test prompt', + projectPath: '/project', + abortController: new AbortController(), + provider: mockProvider, + effectiveBareModel: 'claude-sonnet', + planningMode: 'skip' as const, + existingApprovedPlanContent: 'Some plan', + persistedTasks: [{ id: 'T001', description: 'Task 1', status: 'pending' as const }], + }; + + const callbacks = { + waitForApproval: vi.fn(), + saveFeatureSummary: vi.fn(), + updateFeatureSummary: vi.fn(), + buildTaskPrompt: vi.fn().mockReturnValue('task prompt'), + }; + + await executor.execute(options, callbacks); + + // Verify it updated task status with summary + expect(mockFeatureStateManager.updateTaskStatus).toHaveBeenCalledWith( + '/project', + 'test-feature', + 'T001', + 'completed', + 'Task finished successfully' + ); + }); + + describe('Pipeline step summary fallback', () => { + it('should save fallback summary when extraction fails for pipeline step', async () => { + const executor = new AgentExecutor( + mockEventBus, + mockFeatureStateManager, + mockPlanApprovalService, + null + ); + + // Content without a summary tag (extraction will fail) + const newWork = 'Implementation completed without summary tag.'; + + const mockProvider = { + getName: () => 'mock', + executeQuery: vi.fn().mockImplementation(function* () { + yield { + type: 'assistant', + message: { + content: [{ type: 'text', text: newWork }], + }, + }; + yield { type: 'result', subtype: 'success' }; + }), + } as unknown as BaseProvider; + + const options = { + workDir: '/test', + featureId: 'test-feature', + prompt: 'Test prompt', + projectPath: '/project', + abortController: new AbortController(), + provider: mockProvider, + effectiveBareModel: 'claude-sonnet', + planningMode: 'skip' as const, + status: 'pipeline_step1' as const, // Pipeline status triggers fallback + }; + + const callbacks = { + waitForApproval: vi.fn(), + saveFeatureSummary: vi.fn(), + updateFeatureSummary: vi.fn(), + buildTaskPrompt: vi.fn(), + }; + + await executor.execute(options, callbacks); + + // Verify fallback summary was saved with trimmed content + expect(callbacks.saveFeatureSummary).toHaveBeenCalledWith( + '/project', + 'test-feature', + 'Implementation completed without summary tag.' + ); + }); + + it('should not save fallback for non-pipeline status when extraction fails', async () => { + const executor = new AgentExecutor( + mockEventBus, + mockFeatureStateManager, + mockPlanApprovalService, + null + ); + + // Content without a summary tag + const newWork = 'Implementation completed without summary tag.'; + + const mockProvider = { + getName: () => 'mock', + executeQuery: vi.fn().mockImplementation(function* () { + yield { + type: 'assistant', + message: { + content: [{ type: 'text', text: newWork }], + }, + }; + yield { type: 'result', subtype: 'success' }; + }), + } as unknown as BaseProvider; + + const options = { + workDir: '/test', + featureId: 'test-feature', + prompt: 'Test prompt', + projectPath: '/project', + abortController: new AbortController(), + provider: mockProvider, + effectiveBareModel: 'claude-sonnet', + planningMode: 'skip' as const, + status: 'in_progress' as const, // Non-pipeline status + }; + + const callbacks = { + waitForApproval: vi.fn(), + saveFeatureSummary: vi.fn(), + updateFeatureSummary: vi.fn(), + buildTaskPrompt: vi.fn(), + }; + + await executor.execute(options, callbacks); + + // Verify no fallback was saved for non-pipeline status + expect(callbacks.saveFeatureSummary).not.toHaveBeenCalled(); + }); + + it('should not save empty fallback for pipeline step', async () => { + const executor = new AgentExecutor( + mockEventBus, + mockFeatureStateManager, + mockPlanApprovalService, + null + ); + + // Empty/whitespace-only content + const newWork = ' \n\t '; + + const mockProvider = { + getName: () => 'mock', + executeQuery: vi.fn().mockImplementation(function* () { + yield { + type: 'assistant', + message: { + content: [{ type: 'text', text: newWork }], + }, + }; + yield { type: 'result', subtype: 'success' }; + }), + } as unknown as BaseProvider; + + const options = { + workDir: '/test', + featureId: 'test-feature', + prompt: 'Test prompt', + projectPath: '/project', + abortController: new AbortController(), + provider: mockProvider, + effectiveBareModel: 'claude-sonnet', + planningMode: 'skip' as const, + status: 'pipeline_step1' as const, + }; + + const callbacks = { + waitForApproval: vi.fn(), + saveFeatureSummary: vi.fn(), + updateFeatureSummary: vi.fn(), + buildTaskPrompt: vi.fn(), + }; + + await executor.execute(options, callbacks); + + // Verify no fallback was saved since content was empty/whitespace + expect(callbacks.saveFeatureSummary).not.toHaveBeenCalled(); + }); + + it('should prefer extracted summary over fallback for pipeline step', async () => { + const executor = new AgentExecutor( + mockEventBus, + mockFeatureStateManager, + mockPlanApprovalService, + null + ); + + // Content WITH a summary tag + const newWork = `Implementation details here. +Proper summary from extraction`; + + const mockProvider = { + getName: () => 'mock', + executeQuery: vi.fn().mockImplementation(function* () { + yield { + type: 'assistant', + message: { + content: [{ type: 'text', text: newWork }], + }, + }; + yield { type: 'result', subtype: 'success' }; + }), + } as unknown as BaseProvider; + + const options = { + workDir: '/test', + featureId: 'test-feature', + prompt: 'Test prompt', + projectPath: '/project', + abortController: new AbortController(), + provider: mockProvider, + effectiveBareModel: 'claude-sonnet', + planningMode: 'skip' as const, + status: 'pipeline_step1' as const, + }; + + const callbacks = { + waitForApproval: vi.fn(), + saveFeatureSummary: vi.fn(), + updateFeatureSummary: vi.fn(), + buildTaskPrompt: vi.fn(), + }; + + await executor.execute(options, callbacks); + + // Verify extracted summary was saved, not the full content + expect(callbacks.saveFeatureSummary).toHaveBeenCalledWith( + '/project', + 'test-feature', + 'Proper summary from extraction' + ); + // Ensure it didn't save the full content as fallback + expect(callbacks.saveFeatureSummary).not.toHaveBeenCalledWith( + '/project', + 'test-feature', + expect.stringContaining('Implementation details here') + ); + }); + }); +}); diff --git a/apps/server/tests/unit/services/agent-executor.test.ts b/apps/server/tests/unit/services/agent-executor.test.ts index e47b1a013..868878bda 100644 --- a/apps/server/tests/unit/services/agent-executor.test.ts +++ b/apps/server/tests/unit/services/agent-executor.test.ts @@ -1181,6 +1181,50 @@ describe('AgentExecutor', () => { ); }); + it('should pass claudeCompatibleProvider to executeQuery options', async () => { + const executor = new AgentExecutor( + mockEventBus, + mockFeatureStateManager, + mockPlanApprovalService, + mockSettingsService + ); + + const mockProvider = { + getName: () => 'mock', + executeQuery: vi.fn().mockImplementation(function* () { + yield { type: 'result', subtype: 'success' }; + }), + } as unknown as BaseProvider; + + const mockClaudeProvider = { id: 'zai-1', name: 'Zai' } as any; + + const options: AgentExecutionOptions = { + workDir: '/test', + featureId: 'test-feature', + prompt: 'Test prompt', + projectPath: '/project', + abortController: new AbortController(), + provider: mockProvider, + effectiveBareModel: 'claude-sonnet-4-6', + claudeCompatibleProvider: mockClaudeProvider, + }; + + const callbacks = { + waitForApproval: vi.fn().mockResolvedValue({ approved: true }), + saveFeatureSummary: vi.fn(), + updateFeatureSummary: vi.fn(), + buildTaskPrompt: vi.fn().mockReturnValue('task prompt'), + }; + + await executor.execute(options, callbacks); + + expect(mockProvider.executeQuery).toHaveBeenCalledWith( + expect.objectContaining({ + claudeCompatibleProvider: mockClaudeProvider, + }) + ); + }); + it('should return correct result structure', async () => { const executor = new AgentExecutor( mockEventBus, @@ -1235,4 +1279,471 @@ describe('AgentExecutor', () => { expect(typeof result.aborted).toBe('boolean'); }); }); + + describe('pipeline summary fallback with scaffold stripping', () => { + it('should strip follow-up scaffold from fallback summary when extraction fails', async () => { + const executor = new AgentExecutor( + mockEventBus, + mockFeatureStateManager, + mockPlanApprovalService, + mockSettingsService + ); + + const mockProvider = { + getName: () => 'mock', + executeQuery: vi.fn().mockImplementation(function* () { + yield { + type: 'assistant', + message: { + content: [{ type: 'text', text: 'Some agent output without summary markers' }], + }, + }; + yield { type: 'result', subtype: 'success' }; + }), + } as unknown as BaseProvider; + + const saveFeatureSummary = vi.fn().mockResolvedValue(undefined); + + const options: AgentExecutionOptions = { + workDir: '/test', + featureId: 'test-feature', + prompt: 'Test prompt', + projectPath: '/project', + abortController: new AbortController(), + provider: mockProvider, + effectiveBareModel: 'claude-sonnet-4-6', + planningMode: 'skip', + previousContent: 'Previous session content', + status: 'pipeline_step1', // Pipeline status to trigger fallback + }; + + const callbacks = { + waitForApproval: vi.fn().mockResolvedValue({ approved: true }), + saveFeatureSummary, + updateFeatureSummary: vi.fn(), + buildTaskPrompt: vi.fn().mockReturnValue('task prompt'), + }; + + await executor.execute(options, callbacks); + + // The fallback summary should be called without the scaffold header + expect(saveFeatureSummary).toHaveBeenCalled(); + const savedSummary = saveFeatureSummary.mock.calls[0][2]; + // Should not contain the scaffold header + expect(savedSummary).not.toContain('---'); + expect(savedSummary).not.toContain('Follow-up Session'); + // Should contain the actual content + expect(savedSummary).toContain('Some agent output without summary markers'); + }); + + it('should not save fallback when scaffold is the only content after stripping', async () => { + const executor = new AgentExecutor( + mockEventBus, + mockFeatureStateManager, + mockPlanApprovalService, + mockSettingsService + ); + + // Provider yields no content - only scaffold will be present + const mockProvider = { + getName: () => 'mock', + executeQuery: vi.fn().mockImplementation(function* () { + // Empty stream - no actual content + }), + } as unknown as BaseProvider; + + const saveFeatureSummary = vi.fn().mockResolvedValue(undefined); + + const options: AgentExecutionOptions = { + workDir: '/test', + featureId: 'test-feature', + prompt: 'Test prompt', + projectPath: '/project', + abortController: new AbortController(), + provider: mockProvider, + effectiveBareModel: 'claude-sonnet-4-6', + planningMode: 'skip', + previousContent: 'Previous session content', + status: 'pipeline_step1', // Pipeline status + }; + + const callbacks = { + waitForApproval: vi.fn().mockResolvedValue({ approved: true }), + saveFeatureSummary, + updateFeatureSummary: vi.fn(), + buildTaskPrompt: vi.fn().mockReturnValue('task prompt'), + }; + + await executor.execute(options, callbacks); + + // Should not save an empty fallback (after scaffold is stripped) + expect(saveFeatureSummary).not.toHaveBeenCalled(); + }); + + it('should save extracted summary when available, not fallback', async () => { + const executor = new AgentExecutor( + mockEventBus, + mockFeatureStateManager, + mockPlanApprovalService, + mockSettingsService + ); + + const mockProvider = { + getName: () => 'mock', + executeQuery: vi.fn().mockImplementation(function* () { + yield { + type: 'assistant', + message: { + content: [ + { + type: 'text', + text: 'Some content\n\nExtracted summary here\n\nMore content', + }, + ], + }, + }; + yield { type: 'result', subtype: 'success' }; + }), + } as unknown as BaseProvider; + + const saveFeatureSummary = vi.fn().mockResolvedValue(undefined); + + const options: AgentExecutionOptions = { + workDir: '/test', + featureId: 'test-feature', + prompt: 'Test prompt', + projectPath: '/project', + abortController: new AbortController(), + provider: mockProvider, + effectiveBareModel: 'claude-sonnet-4-6', + planningMode: 'skip', + previousContent: 'Previous session content', + status: 'pipeline_step1', // Pipeline status + }; + + const callbacks = { + waitForApproval: vi.fn().mockResolvedValue({ approved: true }), + saveFeatureSummary, + updateFeatureSummary: vi.fn(), + buildTaskPrompt: vi.fn().mockReturnValue('task prompt'), + }; + + await executor.execute(options, callbacks); + + // Should save the extracted summary, not the full content + expect(saveFeatureSummary).toHaveBeenCalledTimes(1); + expect(saveFeatureSummary).toHaveBeenCalledWith( + '/project', + 'test-feature', + 'Extracted summary here' + ); + }); + + it('should handle scaffold with various whitespace patterns', async () => { + const executor = new AgentExecutor( + mockEventBus, + mockFeatureStateManager, + mockPlanApprovalService, + mockSettingsService + ); + + const mockProvider = { + getName: () => 'mock', + executeQuery: vi.fn().mockImplementation(function* () { + yield { + type: 'assistant', + message: { + content: [{ type: 'text', text: 'Agent response here' }], + }, + }; + yield { type: 'result', subtype: 'success' }; + }), + } as unknown as BaseProvider; + + const saveFeatureSummary = vi.fn().mockResolvedValue(undefined); + + const options: AgentExecutionOptions = { + workDir: '/test', + featureId: 'test-feature', + prompt: 'Test prompt', + projectPath: '/project', + abortController: new AbortController(), + provider: mockProvider, + effectiveBareModel: 'claude-sonnet-4-6', + planningMode: 'skip', + previousContent: 'Previous session content', + status: 'pipeline_step1', + }; + + const callbacks = { + waitForApproval: vi.fn().mockResolvedValue({ approved: true }), + saveFeatureSummary, + updateFeatureSummary: vi.fn(), + buildTaskPrompt: vi.fn().mockReturnValue('task prompt'), + }; + + await executor.execute(options, callbacks); + + // Should strip scaffold and save actual content + expect(saveFeatureSummary).toHaveBeenCalled(); + const savedSummary = saveFeatureSummary.mock.calls[0][2]; + expect(savedSummary.trim()).toBe('Agent response here'); + }); + + it('should handle scaffold with extra newlines between markers', async () => { + const executor = new AgentExecutor( + mockEventBus, + mockFeatureStateManager, + mockPlanApprovalService, + mockSettingsService + ); + + const mockProvider = { + getName: () => 'mock', + executeQuery: vi.fn().mockImplementation(function* () { + yield { + type: 'assistant', + message: { + content: [{ type: 'text', text: 'Actual content after scaffold' }], + }, + }; + yield { type: 'result', subtype: 'success' }; + }), + } as unknown as BaseProvider; + + const saveFeatureSummary = vi.fn().mockResolvedValue(undefined); + + // Set up with previous content to trigger scaffold insertion + const options: AgentExecutionOptions = { + workDir: '/test', + featureId: 'test-feature', + prompt: 'Test prompt', + projectPath: '/project', + abortController: new AbortController(), + provider: mockProvider, + effectiveBareModel: 'claude-sonnet-4-6', + planningMode: 'skip', + previousContent: 'Previous session content', + status: 'pipeline_step1', + }; + + const callbacks = { + waitForApproval: vi.fn().mockResolvedValue({ approved: true }), + saveFeatureSummary, + updateFeatureSummary: vi.fn(), + buildTaskPrompt: vi.fn().mockReturnValue('task prompt'), + }; + + await executor.execute(options, callbacks); + + expect(saveFeatureSummary).toHaveBeenCalled(); + const savedSummary = saveFeatureSummary.mock.calls[0][2]; + // Verify the scaffold is stripped + expect(savedSummary).not.toMatch(/---\s*##\s*Follow-up Session/); + }); + + it('should handle content without any scaffold (first session)', async () => { + const executor = new AgentExecutor( + mockEventBus, + mockFeatureStateManager, + mockPlanApprovalService, + mockSettingsService + ); + + const mockProvider = { + getName: () => 'mock', + executeQuery: vi.fn().mockImplementation(function* () { + yield { + type: 'assistant', + message: { + content: [{ type: 'text', text: 'First session output without summary' }], + }, + }; + yield { type: 'result', subtype: 'success' }; + }), + } as unknown as BaseProvider; + + const saveFeatureSummary = vi.fn().mockResolvedValue(undefined); + + // No previousContent means no scaffold + const options: AgentExecutionOptions = { + workDir: '/test', + featureId: 'test-feature', + prompt: 'Test prompt', + projectPath: '/project', + abortController: new AbortController(), + provider: mockProvider, + effectiveBareModel: 'claude-sonnet-4-6', + planningMode: 'skip', + previousContent: undefined, // No previous content + status: 'pipeline_step1', + }; + + const callbacks = { + waitForApproval: vi.fn().mockResolvedValue({ approved: true }), + saveFeatureSummary, + updateFeatureSummary: vi.fn(), + buildTaskPrompt: vi.fn().mockReturnValue('task prompt'), + }; + + await executor.execute(options, callbacks); + + expect(saveFeatureSummary).toHaveBeenCalled(); + const savedSummary = saveFeatureSummary.mock.calls[0][2]; + expect(savedSummary).toBe('First session output without summary'); + }); + + it('should handle non-pipeline status without saving fallback', async () => { + const executor = new AgentExecutor( + mockEventBus, + mockFeatureStateManager, + mockPlanApprovalService, + mockSettingsService + ); + + const mockProvider = { + getName: () => 'mock', + executeQuery: vi.fn().mockImplementation(function* () { + yield { + type: 'assistant', + message: { + content: [{ type: 'text', text: 'Output without summary' }], + }, + }; + yield { type: 'result', subtype: 'success' }; + }), + } as unknown as BaseProvider; + + const saveFeatureSummary = vi.fn().mockResolvedValue(undefined); + + const options: AgentExecutionOptions = { + workDir: '/test', + featureId: 'test-feature', + prompt: 'Test prompt', + projectPath: '/project', + abortController: new AbortController(), + provider: mockProvider, + effectiveBareModel: 'claude-sonnet-4-6', + planningMode: 'skip', + previousContent: 'Previous content', + status: 'implementing', // Non-pipeline status + }; + + const callbacks = { + waitForApproval: vi.fn().mockResolvedValue({ approved: true }), + saveFeatureSummary, + updateFeatureSummary: vi.fn(), + buildTaskPrompt: vi.fn().mockReturnValue('task prompt'), + }; + + await executor.execute(options, callbacks); + + // Should NOT save fallback for non-pipeline status + expect(saveFeatureSummary).not.toHaveBeenCalled(); + }); + + it('should correctly handle content that starts with dashes but is not scaffold', async () => { + const executor = new AgentExecutor( + mockEventBus, + mockFeatureStateManager, + mockPlanApprovalService, + mockSettingsService + ); + + // Content that looks like it might have dashes but is actual content + const mockProvider = { + getName: () => 'mock', + executeQuery: vi.fn().mockImplementation(function* () { + yield { + type: 'assistant', + message: { + content: [{ type: 'text', text: '---This is a code comment or separator---' }], + }, + }; + yield { type: 'result', subtype: 'success' }; + }), + } as unknown as BaseProvider; + + const saveFeatureSummary = vi.fn().mockResolvedValue(undefined); + + const options: AgentExecutionOptions = { + workDir: '/test', + featureId: 'test-feature', + prompt: 'Test prompt', + projectPath: '/project', + abortController: new AbortController(), + provider: mockProvider, + effectiveBareModel: 'claude-sonnet-4-6', + planningMode: 'skip', + previousContent: undefined, + status: 'pipeline_step1', + }; + + const callbacks = { + waitForApproval: vi.fn().mockResolvedValue({ approved: true }), + saveFeatureSummary, + updateFeatureSummary: vi.fn(), + buildTaskPrompt: vi.fn().mockReturnValue('task prompt'), + }; + + await executor.execute(options, callbacks); + + expect(saveFeatureSummary).toHaveBeenCalled(); + const savedSummary = saveFeatureSummary.mock.calls[0][2]; + // Content should be preserved since it's not the scaffold pattern + expect(savedSummary).toContain('---This is a code comment or separator---'); + }); + + it('should handle scaffold at different positions in content', async () => { + const executor = new AgentExecutor( + mockEventBus, + mockFeatureStateManager, + mockPlanApprovalService, + mockSettingsService + ); + + const mockProvider = { + getName: () => 'mock', + executeQuery: vi.fn().mockImplementation(function* () { + yield { + type: 'assistant', + message: { + content: [{ type: 'text', text: 'Content after scaffold marker' }], + }, + }; + yield { type: 'result', subtype: 'success' }; + }), + } as unknown as BaseProvider; + + const saveFeatureSummary = vi.fn().mockResolvedValue(undefined); + + // With previousContent, scaffold will be at the start of sessionContent + const options: AgentExecutionOptions = { + workDir: '/test', + featureId: 'test-feature', + prompt: 'Test prompt', + projectPath: '/project', + abortController: new AbortController(), + provider: mockProvider, + effectiveBareModel: 'claude-sonnet-4-6', + planningMode: 'skip', + previousContent: 'Previous content', + status: 'pipeline_step1', + }; + + const callbacks = { + waitForApproval: vi.fn().mockResolvedValue({ approved: true }), + saveFeatureSummary, + updateFeatureSummary: vi.fn(), + buildTaskPrompt: vi.fn().mockReturnValue('task prompt'), + }; + + await executor.execute(options, callbacks); + + expect(saveFeatureSummary).toHaveBeenCalled(); + const savedSummary = saveFeatureSummary.mock.calls[0][2]; + // Scaffold should be stripped, only actual content remains + expect(savedSummary).toBe('Content after scaffold marker'); + }); + }); }); diff --git a/apps/server/tests/unit/services/auto-loop-coordinator.test.ts b/apps/server/tests/unit/services/auto-loop-coordinator.test.ts index 92239997c..7c39ea971 100644 --- a/apps/server/tests/unit/services/auto-loop-coordinator.test.ts +++ b/apps/server/tests/unit/services/auto-loop-coordinator.test.ts @@ -1050,4 +1050,383 @@ describe('auto-loop-coordinator.ts', () => { ); }); }); + + describe('auto_mode_idle emission timing (idle check fix)', () => { + it('emits auto_mode_idle when no features in any state (empty project)', async () => { + vi.mocked(mockLoadPendingFeatures).mockResolvedValue([]); + vi.mocked(mockLoadAllFeatures).mockResolvedValue([]); + vi.mocked(mockConcurrencyManager.getRunningCountForWorktree).mockResolvedValue(0); + + await coordinator.startAutoLoopForProject('/test/project', null, 1); + + // Clear the initial event mock calls + vi.mocked(mockEventBus.emitAutoModeEvent).mockClear(); + + // Advance time to trigger loop iteration and idle event + await vi.advanceTimersByTimeAsync(11000); + + // Stop the loop + await coordinator.stopAutoLoopForProject('/test/project', null); + + expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith('auto_mode_idle', { + message: 'No pending features - auto mode idle', + projectPath: '/test/project', + branchName: null, + }); + }); + + it('does NOT emit auto_mode_idle when features are in in_progress status', async () => { + // No pending features (backlog/ready) + vi.mocked(mockLoadPendingFeatures).mockResolvedValue([]); + // But there are features in in_progress status + const inProgressFeature: Feature = { + ...testFeature, + id: 'feature-1', + status: 'in_progress', + title: 'In Progress Feature', + }; + vi.mocked(mockLoadAllFeatures).mockResolvedValue([inProgressFeature]); + // No running features in concurrency manager (they were released during status update) + vi.mocked(mockConcurrencyManager.getRunningCountForWorktree).mockResolvedValue(0); + + await coordinator.startAutoLoopForProject('/test/project', null, 1); + + // Clear the initial event mock calls + vi.mocked(mockEventBus.emitAutoModeEvent).mockClear(); + + // Advance time to trigger loop iteration + await vi.advanceTimersByTimeAsync(11000); + + // Stop the loop + await coordinator.stopAutoLoopForProject('/test/project', null); + + // Should NOT emit auto_mode_idle because there's an in_progress feature + expect(mockEventBus.emitAutoModeEvent).not.toHaveBeenCalledWith('auto_mode_idle', { + message: 'No pending features - auto mode idle', + projectPath: '/test/project', + branchName: null, + }); + }); + + it('emits auto_mode_idle after in_progress feature completes', async () => { + const completedFeature: Feature = { + ...testFeature, + id: 'feature-1', + status: 'completed', + title: 'Completed Feature', + }; + + // Initially has in_progress feature + vi.mocked(mockLoadPendingFeatures).mockResolvedValue([]); + vi.mocked(mockLoadAllFeatures).mockResolvedValue([completedFeature]); + vi.mocked(mockConcurrencyManager.getRunningCountForWorktree).mockResolvedValue(0); + + await coordinator.startAutoLoopForProject('/test/project', null, 1); + + // Clear the initial event mock calls + vi.mocked(mockEventBus.emitAutoModeEvent).mockClear(); + + // Advance time to trigger loop iteration + await vi.advanceTimersByTimeAsync(11000); + + // Stop the loop + await coordinator.stopAutoLoopForProject('/test/project', null); + + // Should emit auto_mode_idle because all features are completed + expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith('auto_mode_idle', { + message: 'No pending features - auto mode idle', + projectPath: '/test/project', + branchName: null, + }); + }); + + it('does NOT emit auto_mode_idle for in_progress features in main worktree (no branchName)', async () => { + vi.mocked(mockLoadPendingFeatures).mockResolvedValue([]); + // Feature in main worktree has no branchName + const mainWorktreeFeature: Feature = { + ...testFeature, + id: 'feature-main', + status: 'in_progress', + title: 'Main Worktree Feature', + branchName: undefined, // Main worktree feature + }; + // Feature in branch worktree has branchName + const branchFeature: Feature = { + ...testFeature, + id: 'feature-branch', + status: 'in_progress', + title: 'Branch Feature', + branchName: 'feature/some-branch', + }; + vi.mocked(mockLoadAllFeatures).mockResolvedValue([mainWorktreeFeature, branchFeature]); + vi.mocked(mockConcurrencyManager.getRunningCountForWorktree).mockResolvedValue(0); + + // Start auto mode for main worktree + await coordinator.startAutoLoopForProject('/test/project', null, 1); + + // Clear the initial event mock calls + vi.mocked(mockEventBus.emitAutoModeEvent).mockClear(); + + // Advance time to trigger loop iteration + await vi.advanceTimersByTimeAsync(11000); + + // Stop the loop + await coordinator.stopAutoLoopForProject('/test/project', null); + + // Should NOT emit auto_mode_idle because there's an in_progress feature in main worktree + expect(mockEventBus.emitAutoModeEvent).not.toHaveBeenCalledWith( + 'auto_mode_idle', + expect.objectContaining({ + projectPath: '/test/project', + branchName: null, + }) + ); + }); + + it('does NOT emit auto_mode_idle for in_progress features with matching branchName', async () => { + vi.mocked(mockLoadPendingFeatures).mockResolvedValue([]); + // Feature in matching branch + const matchingBranchFeature: Feature = { + ...testFeature, + id: 'feature-matching', + status: 'in_progress', + title: 'Matching Branch Feature', + branchName: 'feature/test-branch', + }; + // Feature in different branch + const differentBranchFeature: Feature = { + ...testFeature, + id: 'feature-different', + status: 'in_progress', + title: 'Different Branch Feature', + branchName: 'feature/other-branch', + }; + vi.mocked(mockLoadAllFeatures).mockResolvedValue([ + matchingBranchFeature, + differentBranchFeature, + ]); + vi.mocked(mockConcurrencyManager.getRunningCountForWorktree).mockResolvedValue(0); + + // Start auto mode for feature/test-branch + await coordinator.startAutoLoopForProject('/test/project', 'feature/test-branch', 1); + + // Clear the initial event mock calls + vi.mocked(mockEventBus.emitAutoModeEvent).mockClear(); + + // Advance time to trigger loop iteration + await vi.advanceTimersByTimeAsync(11000); + + // Stop the loop + await coordinator.stopAutoLoopForProject('/test/project', 'feature/test-branch'); + + // Should NOT emit auto_mode_idle because there's an in_progress feature with matching branch + expect(mockEventBus.emitAutoModeEvent).not.toHaveBeenCalledWith( + 'auto_mode_idle', + expect.objectContaining({ + projectPath: '/test/project', + branchName: 'feature/test-branch', + }) + ); + }); + + it('emits auto_mode_idle when in_progress feature has different branchName', async () => { + vi.mocked(mockLoadPendingFeatures).mockResolvedValue([]); + // Only feature is in a different branch + const differentBranchFeature: Feature = { + ...testFeature, + id: 'feature-different', + status: 'in_progress', + title: 'Different Branch Feature', + branchName: 'feature/other-branch', + }; + vi.mocked(mockLoadAllFeatures).mockResolvedValue([differentBranchFeature]); + vi.mocked(mockConcurrencyManager.getRunningCountForWorktree).mockResolvedValue(0); + + // Start auto mode for feature/test-branch + await coordinator.startAutoLoopForProject('/test/project', 'feature/test-branch', 1); + + // Clear the initial event mock calls + vi.mocked(mockEventBus.emitAutoModeEvent).mockClear(); + + // Advance time to trigger loop iteration + await vi.advanceTimersByTimeAsync(11000); + + // Stop the loop + await coordinator.stopAutoLoopForProject('/test/project', 'feature/test-branch'); + + // Should emit auto_mode_idle because the in_progress feature is in a different branch + expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith('auto_mode_idle', { + message: 'No pending features - auto mode idle', + projectPath: '/test/project', + branchName: 'feature/test-branch', + }); + }); + + it('emits auto_mode_idle when only backlog/ready features exist and no running/in_progress features', async () => { + // backlog/ready features should be in loadPendingFeatures, not loadAllFeatures for idle check + // But this test verifies the idle check doesn't incorrectly block on backlog/ready + vi.mocked(mockLoadPendingFeatures).mockResolvedValue([]); // No pending (for current iteration check) + const backlogFeature: Feature = { + ...testFeature, + id: 'feature-1', + status: 'backlog', + title: 'Backlog Feature', + }; + const readyFeature: Feature = { + ...testFeature, + id: 'feature-2', + status: 'ready', + title: 'Ready Feature', + }; + vi.mocked(mockLoadAllFeatures).mockResolvedValue([backlogFeature, readyFeature]); + vi.mocked(mockConcurrencyManager.getRunningCountForWorktree).mockResolvedValue(0); + + await coordinator.startAutoLoopForProject('/test/project', null, 1); + + // Clear the initial event mock calls + vi.mocked(mockEventBus.emitAutoModeEvent).mockClear(); + + // Advance time to trigger loop iteration + await vi.advanceTimersByTimeAsync(11000); + + // Stop the loop + await coordinator.stopAutoLoopForProject('/test/project', null); + + // Should NOT emit auto_mode_idle because there are backlog/ready features + // (even though they're not in_progress, the idle check only looks at in_progress status) + // Actually, backlog/ready would be caught by loadPendingFeatures on next iteration, + // so this should emit idle since runningCount=0 and no in_progress features + expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith('auto_mode_idle', { + message: 'No pending features - auto mode idle', + projectPath: '/test/project', + branchName: null, + }); + }); + + it('handles loadAllFeaturesFn error gracefully (falls back to emitting idle)', async () => { + vi.mocked(mockLoadPendingFeatures).mockResolvedValue([]); + vi.mocked(mockLoadAllFeatures).mockRejectedValue(new Error('Failed to load features')); + vi.mocked(mockConcurrencyManager.getRunningCountForWorktree).mockResolvedValue(0); + + await coordinator.startAutoLoopForProject('/test/project', null, 1); + + // Clear the initial event mock calls + vi.mocked(mockEventBus.emitAutoModeEvent).mockClear(); + + // Advance time to trigger loop iteration + await vi.advanceTimersByTimeAsync(11000); + + // Stop the loop + await coordinator.stopAutoLoopForProject('/test/project', null); + + // Should still emit auto_mode_idle when loadAllFeatures fails (defensive behavior) + expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith('auto_mode_idle', { + message: 'No pending features - auto mode idle', + projectPath: '/test/project', + branchName: null, + }); + }); + + it('handles missing loadAllFeaturesFn gracefully (falls back to emitting idle)', async () => { + // Create coordinator without loadAllFeaturesFn + const coordWithoutLoadAll = new AutoLoopCoordinator( + mockEventBus, + mockConcurrencyManager, + mockSettingsService, + mockExecuteFeature, + mockLoadPendingFeatures, + mockSaveExecutionState, + mockClearExecutionState, + mockResetStuckFeatures, + mockIsFeatureFinished, + mockIsFeatureRunning + // loadAllFeaturesFn omitted + ); + + vi.mocked(mockLoadPendingFeatures).mockResolvedValue([]); + vi.mocked(mockConcurrencyManager.getRunningCountForWorktree).mockResolvedValue(0); + + await coordWithoutLoadAll.startAutoLoopForProject('/test/project', null, 1); + + // Clear the initial event mock calls + vi.mocked(mockEventBus.emitAutoModeEvent).mockClear(); + + // Advance time to trigger loop iteration + await vi.advanceTimersByTimeAsync(11000); + + // Stop the loop + await coordWithoutLoadAll.stopAutoLoopForProject('/test/project', null); + + // Should emit auto_mode_idle when loadAllFeaturesFn is missing (defensive behavior) + expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith('auto_mode_idle', { + message: 'No pending features - auto mode idle', + projectPath: '/test/project', + branchName: null, + }); + }); + + it('only emits auto_mode_idle once per idle period (hasEmittedIdleEvent flag)', async () => { + vi.mocked(mockLoadPendingFeatures).mockResolvedValue([]); + vi.mocked(mockLoadAllFeatures).mockResolvedValue([]); + vi.mocked(mockConcurrencyManager.getRunningCountForWorktree).mockResolvedValue(0); + + await coordinator.startAutoLoopForProject('/test/project', null, 1); + + // Clear the initial event mock calls + vi.mocked(mockEventBus.emitAutoModeEvent).mockClear(); + + // Advance time multiple times to trigger multiple loop iterations + await vi.advanceTimersByTimeAsync(11000); // First idle check + await vi.advanceTimersByTimeAsync(11000); // Second idle check + await vi.advanceTimersByTimeAsync(11000); // Third idle check + + // Stop the loop + await coordinator.stopAutoLoopForProject('/test/project', null); + + // Should only emit auto_mode_idle once despite multiple iterations + const idleCalls = vi + .mocked(mockEventBus.emitAutoModeEvent) + .mock.calls.filter((call) => call[0] === 'auto_mode_idle'); + expect(idleCalls.length).toBe(1); + }); + + it('premature auto_mode_idle bug scenario: runningCount=0 but feature still in_progress', async () => { + // This test reproduces the exact bug scenario described in the feature: + // When a feature completes, there's a brief window where: + // 1. The feature has been released from runningFeatures (so runningCount = 0) + // 2. The feature's status is still 'in_progress' during the status update transition + // 3. pendingFeatures returns empty (only checks 'backlog'/'ready' statuses) + // The fix ensures auto_mode_idle is NOT emitted in this window + + vi.mocked(mockLoadPendingFeatures).mockResolvedValue([]); // No backlog/ready features + // Feature is still in in_progress status (during status update transition) + const transitioningFeature: Feature = { + ...testFeature, + id: 'feature-1', + status: 'in_progress', + title: 'Transitioning Feature', + }; + vi.mocked(mockLoadAllFeatures).mockResolvedValue([transitioningFeature]); + // Feature has been released from concurrency manager (runningCount = 0) + vi.mocked(mockConcurrencyManager.getRunningCountForWorktree).mockResolvedValue(0); + + await coordinator.startAutoLoopForProject('/test/project', null, 1); + + // Clear the initial event mock calls + vi.mocked(mockEventBus.emitAutoModeEvent).mockClear(); + + // Advance time to trigger loop iteration + await vi.advanceTimersByTimeAsync(11000); + + // Stop the loop + await coordinator.stopAutoLoopForProject('/test/project', null); + + // The fix prevents auto_mode_idle from being emitted in this scenario + expect(mockEventBus.emitAutoModeEvent).not.toHaveBeenCalledWith('auto_mode_idle', { + message: 'No pending features - auto mode idle', + projectPath: '/test/project', + branchName: null, + }); + }); + }); }); diff --git a/apps/server/tests/unit/services/auto-mode-facade.test.ts b/apps/server/tests/unit/services/auto-mode-facade.test.ts new file mode 100644 index 000000000..8a2ab4cf9 --- /dev/null +++ b/apps/server/tests/unit/services/auto-mode-facade.test.ts @@ -0,0 +1,127 @@ +import { describe, it, expect } from 'vitest'; +import { AutoModeServiceFacade } from '@/services/auto-mode/facade.js'; +import type { Feature } from '@automaker/types'; + +describe('AutoModeServiceFacade', () => { + describe('isFeatureEligibleForAutoMode', () => { + it('should include features with pipeline_* status', () => { + const features: Partial[] = [ + { id: '1', status: 'ready', branchName: 'main' }, + { id: '2', status: 'pipeline_testing', branchName: 'main' }, + { id: '3', status: 'in_progress', branchName: 'main' }, + { id: '4', status: 'interrupted', branchName: 'main' }, + { id: '5', status: 'backlog', branchName: 'main' }, + ]; + + const branchName = 'main'; + const primaryBranch = 'main'; + + const filtered = features.filter((f) => + AutoModeServiceFacade.isFeatureEligibleForAutoMode(f as Feature, branchName, primaryBranch) + ); + + expect(filtered.map((f) => f.id)).toContain('1'); // ready + expect(filtered.map((f) => f.id)).toContain('2'); // pipeline_testing + expect(filtered.map((f) => f.id)).toContain('4'); // interrupted + expect(filtered.map((f) => f.id)).toContain('5'); // backlog + expect(filtered.map((f) => f.id)).not.toContain('3'); // in_progress + }); + + it('should correctly handle main worktree alignment', () => { + const features: Partial[] = [ + { id: '1', status: 'ready', branchName: undefined }, + { id: '2', status: 'ready', branchName: 'main' }, + { id: '3', status: 'ready', branchName: 'other' }, + ]; + + const branchName = null; // main worktree + const primaryBranch = 'main'; + + const filtered = features.filter((f) => + AutoModeServiceFacade.isFeatureEligibleForAutoMode(f as Feature, branchName, primaryBranch) + ); + + expect(filtered.map((f) => f.id)).toContain('1'); // no branch + expect(filtered.map((f) => f.id)).toContain('2'); // matching primary branch + expect(filtered.map((f) => f.id)).not.toContain('3'); // mismatching branch + }); + + it('should exclude completed, verified, and waiting_approval statuses', () => { + const features: Partial[] = [ + { id: '1', status: 'completed', branchName: 'main' }, + { id: '2', status: 'verified', branchName: 'main' }, + { id: '3', status: 'waiting_approval', branchName: 'main' }, + ]; + + const filtered = features.filter((f) => + AutoModeServiceFacade.isFeatureEligibleForAutoMode(f as Feature, 'main', 'main') + ); + + expect(filtered).toHaveLength(0); + }); + + it('should include pipeline_complete as eligible (still a pipeline status)', () => { + const feature: Partial = { + id: '1', + status: 'pipeline_complete', + branchName: 'main', + }; + + const result = AutoModeServiceFacade.isFeatureEligibleForAutoMode( + feature as Feature, + 'main', + 'main' + ); + + expect(result).toBe(true); + }); + + it('should filter pipeline features by branch in named worktrees', () => { + const features: Partial[] = [ + { id: '1', status: 'pipeline_testing', branchName: 'feature-branch' }, + { id: '2', status: 'pipeline_review', branchName: 'other-branch' }, + { id: '3', status: 'pipeline_deploy', branchName: undefined }, + ]; + + const filtered = features.filter((f) => + AutoModeServiceFacade.isFeatureEligibleForAutoMode(f as Feature, 'feature-branch', null) + ); + + expect(filtered.map((f) => f.id)).toEqual(['1']); + }); + + it('should handle null primaryBranch for main worktree', () => { + const features: Partial[] = [ + { id: '1', status: 'ready', branchName: undefined }, + { id: '2', status: 'ready', branchName: 'main' }, + ]; + + const filtered = features.filter((f) => + AutoModeServiceFacade.isFeatureEligibleForAutoMode(f as Feature, null, null) + ); + + // When primaryBranch is null, only features with no branchName are included + expect(filtered.map((f) => f.id)).toEqual(['1']); + }); + + it('should include various pipeline_* step IDs as eligible', () => { + const statuses = [ + 'pipeline_step_abc_123', + 'pipeline_code_review', + 'pipeline_step1', + 'pipeline_testing', + 'pipeline_deploy', + ]; + + for (const status of statuses) { + const feature: Partial = { id: '1', status, branchName: 'main' }; + const result = AutoModeServiceFacade.isFeatureEligibleForAutoMode( + feature as Feature, + 'main', + 'main' + ); + expect(result).toBe(true); + } + }); + }); +}); diff --git a/apps/server/tests/unit/services/auto-mode/facade-agent-runner.test.ts b/apps/server/tests/unit/services/auto-mode/facade-agent-runner.test.ts new file mode 100644 index 000000000..58cbaeb96 --- /dev/null +++ b/apps/server/tests/unit/services/auto-mode/facade-agent-runner.test.ts @@ -0,0 +1,207 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Mock dependencies (hoisted) +vi.mock('../../../../src/services/agent-executor.js'); +vi.mock('../../../../src/lib/settings-helpers.js'); +vi.mock('../../../../src/providers/provider-factory.js'); +vi.mock('../../../../src/lib/sdk-options.js'); +vi.mock('@automaker/model-resolver', () => ({ + resolveModelString: vi.fn((model, fallback) => model || fallback), + DEFAULT_MODELS: { claude: 'claude-3-5-sonnet' }, +})); + +import { AutoModeServiceFacade } from '../../../../src/services/auto-mode/facade.js'; +import { AgentExecutor } from '../../../../src/services/agent-executor.js'; +import * as settingsHelpers from '../../../../src/lib/settings-helpers.js'; +import { ProviderFactory } from '../../../../src/providers/provider-factory.js'; +import * as sdkOptions from '../../../../src/lib/sdk-options.js'; + +describe('AutoModeServiceFacade Agent Runner', () => { + let mockAgentExecutor: MockAgentExecutor; + let mockSettingsService: MockSettingsService; + let facade: AutoModeServiceFacade; + + // Type definitions for mocks + interface MockAgentExecutor { + execute: ReturnType; + } + interface MockSettingsService { + getGlobalSettings: ReturnType; + getCredentials: ReturnType; + getProjectSettings: ReturnType; + } + + beforeEach(() => { + vi.clearAllMocks(); + + // Set up the mock for createAutoModeOptions + // Note: Using 'as any' because Options type from SDK is complex and we only need + // the specific fields that are verified in tests (maxTurns, allowedTools, etc.) + vi.mocked(sdkOptions.createAutoModeOptions).mockReturnValue({ + maxTurns: 123, + allowedTools: ['tool1'], + systemPrompt: 'system-prompt', + } as any); + + mockAgentExecutor = { + execute: vi.fn().mockResolvedValue(undefined), + }; + (AgentExecutor as any).mockImplementation(function (this: MockAgentExecutor) { + return mockAgentExecutor; + }); + + mockSettingsService = { + getGlobalSettings: vi.fn().mockResolvedValue({}), + getCredentials: vi.fn().mockResolvedValue({}), + getProjectSettings: vi.fn().mockResolvedValue({}), + }; + + // Helper to access the private createRunAgentFn via factory creation + facade = AutoModeServiceFacade.create('/project', { + events: { on: vi.fn(), emit: vi.fn(), subscribe: vi.fn().mockReturnValue(vi.fn()) } as any, + settingsService: mockSettingsService, + sharedServices: { + eventBus: { emitAutoModeEvent: vi.fn() } as any, + worktreeResolver: { getCurrentBranch: vi.fn().mockResolvedValue('main') } as any, + concurrencyManager: { + isRunning: vi.fn().mockReturnValue(false), + getRunningFeature: vi.fn().mockReturnValue(null), + } as any, + } as any, + }); + }); + + it('should resolve provider by providerId and pass to AgentExecutor', async () => { + // 1. Setup mocks + const mockProvider = { getName: () => 'mock-provider' }; + (ProviderFactory.getProviderForModel as any).mockReturnValue(mockProvider); + + const mockClaudeProvider = { id: 'zai-1', name: 'Zai' }; + const mockCredentials = { apiKey: 'test-key' }; + (settingsHelpers.resolveProviderContext as any).mockResolvedValue({ + provider: mockClaudeProvider, + credentials: mockCredentials, + resolvedModel: undefined, + }); + + const runAgentFn = (facade as any).executionService.runAgentFn; + + // 2. Execute + await runAgentFn( + '/workdir', + 'feature-1', + 'prompt', + new AbortController(), + '/project', + [], + 'model-1', + { + providerId: 'zai-1', + } + ); + + // 3. Verify + expect(settingsHelpers.resolveProviderContext).toHaveBeenCalledWith( + mockSettingsService, + 'model-1', + 'zai-1', + '[AutoModeFacade]' + ); + + expect(mockAgentExecutor.execute).toHaveBeenCalledWith( + expect.objectContaining({ + claudeCompatibleProvider: mockClaudeProvider, + credentials: mockCredentials, + model: 'model-1', // Original model ID + }), + expect.any(Object) + ); + }); + + it('should fallback to model-based lookup if providerId is not provided', async () => { + const mockProvider = { getName: () => 'mock-provider' }; + (ProviderFactory.getProviderForModel as any).mockReturnValue(mockProvider); + + const mockClaudeProvider = { id: 'zai-model', name: 'Zai Model' }; + (settingsHelpers.resolveProviderContext as any).mockResolvedValue({ + provider: mockClaudeProvider, + credentials: { apiKey: 'model-key' }, + resolvedModel: 'resolved-model-1', + }); + + const runAgentFn = (facade as any).executionService.runAgentFn; + + await runAgentFn( + '/workdir', + 'feature-1', + 'prompt', + new AbortController(), + '/project', + [], + 'model-1', + { + // no providerId + } + ); + + expect(settingsHelpers.resolveProviderContext).toHaveBeenCalledWith( + mockSettingsService, + 'model-1', + undefined, + '[AutoModeFacade]' + ); + + expect(mockAgentExecutor.execute).toHaveBeenCalledWith( + expect.objectContaining({ + claudeCompatibleProvider: mockClaudeProvider, + }), + expect.any(Object) + ); + }); + + it('should use resolvedModel from provider config for createAutoModeOptions if it maps to a Claude model', async () => { + const mockProvider = { getName: () => 'mock-provider' }; + (ProviderFactory.getProviderForModel as any).mockReturnValue(mockProvider); + + const mockClaudeProvider = { + id: 'zai-1', + name: 'Zai', + models: [{ id: 'custom-model-1', mapsToClaudeModel: 'claude-3-opus' }], + }; + (settingsHelpers.resolveProviderContext as any).mockResolvedValue({ + provider: mockClaudeProvider, + credentials: { apiKey: 'test-key' }, + resolvedModel: 'claude-3-5-opus', + }); + + const runAgentFn = (facade as any).executionService.runAgentFn; + + await runAgentFn( + '/workdir', + 'feature-1', + 'prompt', + new AbortController(), + '/project', + [], + 'custom-model-1', + { + providerId: 'zai-1', + } + ); + + // Verify createAutoModeOptions was called with the mapped model + expect(sdkOptions.createAutoModeOptions).toHaveBeenCalledWith( + expect.objectContaining({ + model: 'claude-3-5-opus', + }) + ); + + // Verify AgentExecutor.execute still gets the original custom model ID + expect(mockAgentExecutor.execute).toHaveBeenCalledWith( + expect.objectContaining({ + model: 'custom-model-1', + }), + expect.any(Object) + ); + }); +}); diff --git a/apps/server/tests/unit/services/dev-server-event-types.test.ts b/apps/server/tests/unit/services/dev-server-event-types.test.ts new file mode 100644 index 000000000..95fdfd943 --- /dev/null +++ b/apps/server/tests/unit/services/dev-server-event-types.test.ts @@ -0,0 +1,115 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { EventEmitter } from 'events'; +import path from 'path'; +import os from 'os'; +import fs from 'fs/promises'; +import { spawn } from 'child_process'; + +// Mock child_process +vi.mock('child_process', () => ({ + spawn: vi.fn(), + execSync: vi.fn(), + execFile: vi.fn(), +})); + +// Mock secure-fs +vi.mock('@/lib/secure-fs.js', () => ({ + access: vi.fn(), +})); + +// Mock net +vi.mock('net', () => ({ + default: { + createServer: vi.fn(), + }, + createServer: vi.fn(), +})); + +import * as secureFs from '@/lib/secure-fs.js'; +import net from 'net'; + +describe('DevServerService Event Types', () => { + let testDataDir: string; + let worktreeDir: string; + let mockEmitter: EventEmitter; + + beforeEach(async () => { + vi.clearAllMocks(); + vi.resetModules(); + + testDataDir = path.join(os.tmpdir(), `dev-server-events-test-${Date.now()}`); + worktreeDir = path.join(os.tmpdir(), `dev-server-worktree-events-test-${Date.now()}`); + await fs.mkdir(testDataDir, { recursive: true }); + await fs.mkdir(worktreeDir, { recursive: true }); + + mockEmitter = new EventEmitter(); + + vi.mocked(secureFs.access).mockResolvedValue(undefined); + + const mockServer = new EventEmitter() as any; + mockServer.listen = vi.fn().mockImplementation((port: number, host: string) => { + process.nextTick(() => mockServer.emit('listening')); + }); + mockServer.close = vi.fn(); + vi.mocked(net.createServer).mockReturnValue(mockServer); + }); + + afterEach(async () => { + try { + await fs.rm(testDataDir, { recursive: true, force: true }); + await fs.rm(worktreeDir, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } + }); + + it('should emit all required event types during dev server lifecycle', async () => { + const { getDevServerService } = await import('@/services/dev-server-service.js'); + const service = getDevServerService(); + await service.initialize(testDataDir, mockEmitter as any); + + const mockProcess = createMockProcess(); + vi.mocked(spawn).mockReturnValue(mockProcess as any); + + const emittedEvents: Record = { + 'dev-server:starting': [], + 'dev-server:started': [], + 'dev-server:url-detected': [], + 'dev-server:output': [], + 'dev-server:stopped': [], + }; + + Object.keys(emittedEvents).forEach((type) => { + mockEmitter.on(type, (payload) => emittedEvents[type].push(payload)); + }); + + // 1. Starting & Started + await service.startDevServer(worktreeDir, worktreeDir); + expect(emittedEvents['dev-server:starting'].length).toBe(1); + expect(emittedEvents['dev-server:started'].length).toBe(1); + + // 2. Output & URL Detected + mockProcess.stdout.emit('data', Buffer.from('Local: http://localhost:5173/\n')); + // Throttled output needs a bit of time + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(emittedEvents['dev-server:output'].length).toBeGreaterThanOrEqual(1); + expect(emittedEvents['dev-server:url-detected'].length).toBe(1); + expect(emittedEvents['dev-server:url-detected'][0].url).toBe('http://localhost:5173/'); + + // 3. Stopped + await service.stopDevServer(worktreeDir); + expect(emittedEvents['dev-server:stopped'].length).toBe(1); + }); +}); + +// Helper to create a mock child process +function createMockProcess() { + const mockProcess = new EventEmitter() as any; + mockProcess.stdout = new EventEmitter(); + mockProcess.stderr = new EventEmitter(); + mockProcess.kill = vi.fn(); + mockProcess.killed = false; + mockProcess.pid = 12345; + mockProcess.unref = vi.fn(); + return mockProcess; +} diff --git a/apps/server/tests/unit/services/dev-server-persistence.test.ts b/apps/server/tests/unit/services/dev-server-persistence.test.ts new file mode 100644 index 000000000..24dca037f --- /dev/null +++ b/apps/server/tests/unit/services/dev-server-persistence.test.ts @@ -0,0 +1,240 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { EventEmitter } from 'events'; +import path from 'path'; +import os from 'os'; +import fs from 'fs/promises'; +import { spawn, execSync } from 'child_process'; + +// Mock child_process +vi.mock('child_process', () => ({ + spawn: vi.fn(), + execSync: vi.fn(), + execFile: vi.fn(), +})); + +// Mock secure-fs +vi.mock('@/lib/secure-fs.js', () => ({ + access: vi.fn(), +})); + +// Mock net +vi.mock('net', () => ({ + default: { + createServer: vi.fn(), + }, + createServer: vi.fn(), +})); + +import * as secureFs from '@/lib/secure-fs.js'; +import net from 'net'; + +describe('DevServerService Persistence & Sync', () => { + let testDataDir: string; + let worktreeDir: string; + let mockEmitter: EventEmitter; + + beforeEach(async () => { + vi.clearAllMocks(); + vi.resetModules(); + + testDataDir = path.join(os.tmpdir(), `dev-server-persistence-test-${Date.now()}`); + worktreeDir = path.join(os.tmpdir(), `dev-server-worktree-test-${Date.now()}`); + await fs.mkdir(testDataDir, { recursive: true }); + await fs.mkdir(worktreeDir, { recursive: true }); + + mockEmitter = new EventEmitter(); + + // Default mock for secureFs.access - return resolved (file exists) + vi.mocked(secureFs.access).mockResolvedValue(undefined); + + // Default mock for net.createServer - port available + const mockServer = new EventEmitter() as any; + mockServer.listen = vi.fn().mockImplementation((port: number, host: string) => { + process.nextTick(() => mockServer.emit('listening')); + }); + mockServer.close = vi.fn(); + vi.mocked(net.createServer).mockReturnValue(mockServer); + + // Default mock for execSync - no process on port + vi.mocked(execSync).mockImplementation(() => { + throw new Error('No process found'); + }); + }); + + afterEach(async () => { + try { + await fs.rm(testDataDir, { recursive: true, force: true }); + await fs.rm(worktreeDir, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } + }); + + it('should emit dev-server:starting when startDevServer is called', async () => { + const { getDevServerService } = await import('@/services/dev-server-service.js'); + const service = getDevServerService(); + await service.initialize(testDataDir, mockEmitter as any); + + const mockProcess = createMockProcess(); + vi.mocked(spawn).mockReturnValue(mockProcess as any); + + const events: any[] = []; + mockEmitter.on('dev-server:starting', (payload) => events.push(payload)); + + await service.startDevServer(worktreeDir, worktreeDir); + + expect(events.length).toBe(1); + expect(events[0].worktreePath).toBe(worktreeDir); + }); + + it('should prevent concurrent starts for the same worktree', async () => { + const { getDevServerService } = await import('@/services/dev-server-service.js'); + const service = getDevServerService(); + await service.initialize(testDataDir, mockEmitter as any); + + // Delay spawn to simulate long starting time + vi.mocked(spawn).mockImplementation(() => { + const p = createMockProcess(); + // Don't return immediately, simulate some work + return p as any; + }); + + // Start first one (don't await yet if we want to test concurrency) + const promise1 = service.startDevServer(worktreeDir, worktreeDir); + + // Try to start second one immediately + const result2 = await service.startDevServer(worktreeDir, worktreeDir); + + expect(result2.success).toBe(false); + expect(result2.error).toContain('already starting'); + + await promise1; + }); + + it('should persist state to dev-servers.json when started', async () => { + const { getDevServerService } = await import('@/services/dev-server-service.js'); + const service = getDevServerService(); + await service.initialize(testDataDir, mockEmitter as any); + + const mockProcess = createMockProcess(); + vi.mocked(spawn).mockReturnValue(mockProcess as any); + + await service.startDevServer(worktreeDir, worktreeDir); + + const statePath = path.join(testDataDir, 'dev-servers.json'); + const exists = await fs + .access(statePath) + .then(() => true) + .catch(() => false); + expect(exists).toBe(true); + + const content = await fs.readFile(statePath, 'utf-8'); + const state = JSON.parse(content); + expect(state.length).toBe(1); + expect(state[0].worktreePath).toBe(worktreeDir); + }); + + it('should load state from dev-servers.json on initialize', async () => { + // 1. Create a fake state file + const persistedInfo = [ + { + worktreePath: worktreeDir, + allocatedPort: 3005, + port: 3005, + url: 'http://localhost:3005', + startedAt: new Date().toISOString(), + urlDetected: true, + customCommand: 'npm run dev', + }, + ]; + await fs.writeFile(path.join(testDataDir, 'dev-servers.json'), JSON.stringify(persistedInfo)); + + // 2. Mock port as IN USE (so it re-attaches) + const mockServer = new EventEmitter() as any; + mockServer.listen = vi.fn().mockImplementation((port: number, host: string) => { + // Fail to listen = port in use + process.nextTick(() => mockServer.emit('error', new Error('EADDRINUSE'))); + }); + vi.mocked(net.createServer).mockReturnValue(mockServer); + + const { getDevServerService } = await import('@/services/dev-server-service.js'); + const service = getDevServerService(); + await service.initialize(testDataDir, mockEmitter as any); + + expect(service.isRunning(worktreeDir)).toBe(true); + const info = service.getServerInfo(worktreeDir); + expect(info?.port).toBe(3005); + }); + + it('should prune stale servers from state on initialize if port is available', async () => { + // 1. Create a fake state file + const persistedInfo = [ + { + worktreePath: worktreeDir, + allocatedPort: 3005, + port: 3005, + url: 'http://localhost:3005', + startedAt: new Date().toISOString(), + urlDetected: true, + }, + ]; + await fs.writeFile(path.join(testDataDir, 'dev-servers.json'), JSON.stringify(persistedInfo)); + + // 2. Mock port as AVAILABLE (so it prunes) + const mockServer = new EventEmitter() as any; + mockServer.listen = vi.fn().mockImplementation((port: number, host: string) => { + process.nextTick(() => mockServer.emit('listening')); + }); + mockServer.close = vi.fn(); + vi.mocked(net.createServer).mockReturnValue(mockServer); + + const { getDevServerService } = await import('@/services/dev-server-service.js'); + const service = getDevServerService(); + await service.initialize(testDataDir, mockEmitter as any); + + expect(service.isRunning(worktreeDir)).toBe(false); + + // Give it a moment to complete the pruning saveState + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Check if file was updated + const content = await fs.readFile(path.join(testDataDir, 'dev-servers.json'), 'utf-8'); + const state = JSON.parse(content); + expect(state.length).toBe(0); + }); + + it('should update persisted state when URL is detected', async () => { + const { getDevServerService } = await import('@/services/dev-server-service.js'); + const service = getDevServerService(); + await service.initialize(testDataDir, mockEmitter as any); + + const mockProcess = createMockProcess(); + vi.mocked(spawn).mockReturnValue(mockProcess as any); + + await service.startDevServer(worktreeDir, worktreeDir); + + // Simulate output with URL + mockProcess.stdout.emit('data', Buffer.from('Local: http://localhost:5555/\n')); + + // Give it a moment to process and save (needs to wait for saveQueue) + await new Promise((resolve) => setTimeout(resolve, 300)); + + const content = await fs.readFile(path.join(testDataDir, 'dev-servers.json'), 'utf-8'); + const state = JSON.parse(content); + expect(state[0].url).toBe('http://localhost:5555/'); + expect(state[0].port).toBe(5555); + expect(state[0].urlDetected).toBe(true); + }); +}); + +// Helper to create a mock child process +function createMockProcess() { + const mockProcess = new EventEmitter() as any; + mockProcess.stdout = new EventEmitter(); + mockProcess.stderr = new EventEmitter(); + mockProcess.kill = vi.fn(); + mockProcess.killed = false; + mockProcess.pid = 12345; + mockProcess.unref = vi.fn(); + return mockProcess; +} diff --git a/apps/server/tests/unit/services/event-hook-service.test.ts b/apps/server/tests/unit/services/event-hook-service.test.ts index ab06f9c1f..900bb3b36 100644 --- a/apps/server/tests/unit/services/event-hook-service.test.ts +++ b/apps/server/tests/unit/services/event-hook-service.test.ts @@ -5,6 +5,9 @@ import type { SettingsService } from '../../../src/services/settings-service.js' import type { EventHistoryService } from '../../../src/services/event-history-service.js'; import type { FeatureLoader } from '../../../src/services/feature-loader.js'; +// Mock global fetch for ntfy tests +const originalFetch = global.fetch; + /** * Create a mock EventEmitter for testing */ @@ -38,9 +41,15 @@ function createMockEventEmitter(): EventEmitter & { /** * Create a mock SettingsService */ -function createMockSettingsService(hooks: unknown[] = []): SettingsService { +function createMockSettingsService( + hooks: unknown[] = [], + ntfyEndpoints: unknown[] = [] +): SettingsService { return { - getGlobalSettings: vi.fn().mockResolvedValue({ eventHooks: hooks }), + getGlobalSettings: vi.fn().mockResolvedValue({ + eventHooks: hooks, + ntfyEndpoints: ntfyEndpoints, + }), } as unknown as SettingsService; } @@ -70,6 +79,7 @@ describe('EventHookService', () => { let mockSettingsService: ReturnType; let mockEventHistoryService: ReturnType; let mockFeatureLoader: ReturnType; + let mockFetch: ReturnType; beforeEach(() => { service = new EventHookService(); @@ -77,10 +87,14 @@ describe('EventHookService', () => { mockSettingsService = createMockSettingsService(); mockEventHistoryService = createMockEventHistoryService(); mockFeatureLoader = createMockFeatureLoader(); + // Set up mock fetch for ntfy tests + mockFetch = vi.fn(); + global.fetch = mockFetch; }); afterEach(() => { service.destroy(); + global.fetch = originalFetch; }); describe('initialize', () => { @@ -832,4 +846,628 @@ describe('EventHookService', () => { expect(storeCall.error).toBe('Feature stopped by user'); }); }); + + describe('ntfy hook execution', () => { + const mockNtfyEndpoint = { + id: 'endpoint-1', + name: 'Test Endpoint', + serverUrl: 'https://ntfy.sh', + topic: 'test-topic', + authType: 'none' as const, + enabled: true, + }; + + it('should execute ntfy hook when endpoint is configured', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + }); + + const hooks = [ + { + id: 'ntfy-hook-1', + enabled: true, + trigger: 'feature_success', + name: 'Ntfy Success Hook', + action: { + type: 'ntfy', + endpointId: 'endpoint-1', + title: 'Feature {{featureName}} completed!', + priority: 3, + }, + }, + ]; + + mockSettingsService = createMockSettingsService(hooks, [mockNtfyEndpoint]); + service.initialize( + mockEmitter, + mockSettingsService, + mockEventHistoryService, + mockFeatureLoader + ); + + mockEmitter.simulateEvent('auto-mode:event', { + type: 'auto_mode_feature_complete', + executionMode: 'auto', + featureId: 'feat-1', + featureName: 'Test Feature', + passes: true, + message: 'Feature completed', + projectPath: '/test/project', + }); + + await vi.waitFor(() => { + expect(mockFetch).toHaveBeenCalled(); + }); + + const [url, options] = mockFetch.mock.calls[0]; + expect(url).toBe('https://ntfy.sh/test-topic'); + expect(options.method).toBe('POST'); + expect(options.headers['Title']).toBe('Feature Test Feature completed!'); + }); + + it('should NOT execute ntfy hook when endpoint is not found', async () => { + const hooks = [ + { + id: 'ntfy-hook-1', + enabled: true, + trigger: 'feature_success', + name: 'Ntfy Hook with Missing Endpoint', + action: { + type: 'ntfy', + endpointId: 'non-existent-endpoint', + }, + }, + ]; + + mockSettingsService = createMockSettingsService(hooks, [mockNtfyEndpoint]); + service.initialize( + mockEmitter, + mockSettingsService, + mockEventHistoryService, + mockFeatureLoader + ); + + mockEmitter.simulateEvent('auto-mode:event', { + type: 'auto_mode_feature_complete', + executionMode: 'auto', + featureId: 'feat-1', + featureName: 'Test Feature', + passes: true, + message: 'Feature completed', + projectPath: '/test/project', + }); + + await vi.waitFor(() => { + expect(mockEventHistoryService.storeEvent).toHaveBeenCalled(); + }); + + // Fetch should NOT have been called since endpoint doesn't exist + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('should use ntfy endpoint default values when hook does not override', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + }); + + const endpointWithDefaults = { + ...mockNtfyEndpoint, + defaultTags: 'default-tag', + defaultEmoji: 'tada', + defaultClickUrl: 'https://default.example.com', + }; + + const hooks = [ + { + id: 'ntfy-hook-1', + enabled: true, + trigger: 'feature_error', + name: 'Ntfy Error Hook', + action: { + type: 'ntfy', + endpointId: 'endpoint-1', + // No title, tags, or emoji - should use endpoint defaults + }, + }, + ]; + + mockSettingsService = createMockSettingsService(hooks, [endpointWithDefaults]); + service.initialize( + mockEmitter, + mockSettingsService, + mockEventHistoryService, + mockFeatureLoader + ); + + mockEmitter.simulateEvent('auto-mode:event', { + type: 'auto_mode_feature_complete', + executionMode: 'auto', + featureId: 'feat-1', + featureName: 'Failed Feature', + passes: false, + message: 'Something went wrong', + projectPath: '/test/project', + }); + + await vi.waitFor(() => { + expect(mockFetch).toHaveBeenCalled(); + }); + + const options = mockFetch.mock.calls[0][1]; + // Should use default tags and emoji from endpoint + expect(options.headers['Tags']).toBe('tada,default-tag'); + // Click URL gets deep-link query param when feature context is available + expect(options.headers['Click']).toContain('https://default.example.com/board'); + expect(options.headers['Click']).toContain('featureId=feat-1'); + }); + + it('should send ntfy notification with authentication', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + }); + + const endpointWithAuth = { + ...mockNtfyEndpoint, + authType: 'token' as const, + token: 'tk_test_token', + }; + + const hooks = [ + { + id: 'ntfy-hook-1', + enabled: true, + trigger: 'feature_success', + name: 'Authenticated Ntfy Hook', + action: { + type: 'ntfy', + endpointId: 'endpoint-1', + }, + }, + ]; + + mockSettingsService = createMockSettingsService(hooks, [endpointWithAuth]); + service.initialize( + mockEmitter, + mockSettingsService, + mockEventHistoryService, + mockFeatureLoader + ); + + mockEmitter.simulateEvent('auto-mode:event', { + type: 'auto_mode_feature_complete', + executionMode: 'auto', + featureId: 'feat-1', + featureName: 'Test Feature', + passes: true, + message: 'Feature completed', + projectPath: '/test/project', + }); + + await vi.waitFor(() => { + expect(mockFetch).toHaveBeenCalled(); + }); + + const options = mockFetch.mock.calls[0][1]; + expect(options.headers['Authorization']).toBe('Bearer tk_test_token'); + }); + + it('should handle ntfy notification failure gracefully', async () => { + mockFetch.mockRejectedValueOnce(new Error('Network error')); + + const hooks = [ + { + id: 'ntfy-hook-1', + enabled: true, + trigger: 'feature_success', + name: 'Ntfy Hook That Will Fail', + action: { + type: 'ntfy', + endpointId: 'endpoint-1', + }, + }, + ]; + + mockSettingsService = createMockSettingsService(hooks, [mockNtfyEndpoint]); + service.initialize( + mockEmitter, + mockSettingsService, + mockEventHistoryService, + mockFeatureLoader + ); + + mockEmitter.simulateEvent('auto-mode:event', { + type: 'auto_mode_feature_complete', + executionMode: 'auto', + featureId: 'feat-1', + featureName: 'Test Feature', + passes: true, + message: 'Feature completed', + projectPath: '/test/project', + }); + + // Should not throw - error should be caught gracefully + await vi.waitFor(() => { + expect(mockFetch).toHaveBeenCalled(); + }); + + // Event should still be stored even if ntfy hook fails + expect(mockEventHistoryService.storeEvent).toHaveBeenCalled(); + }); + + it('should substitute variables in ntfy title and body', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + }); + + const hooks = [ + { + id: 'ntfy-hook-1', + enabled: true, + trigger: 'feature_success', + name: 'Ntfy Hook with Variables', + action: { + type: 'ntfy', + endpointId: 'endpoint-1', + title: '[{{projectName}}] {{featureName}}', + body: 'Feature {{featureId}} completed at {{timestamp}}', + }, + }, + ]; + + mockSettingsService = createMockSettingsService(hooks, [mockNtfyEndpoint]); + service.initialize( + mockEmitter, + mockSettingsService, + mockEventHistoryService, + mockFeatureLoader + ); + + mockEmitter.simulateEvent('auto-mode:event', { + type: 'auto_mode_feature_complete', + executionMode: 'auto', + featureId: 'feat-123', + featureName: 'Cool Feature', + passes: true, + message: 'Feature completed', + projectPath: '/test/my-project', + projectName: 'my-project', + }); + + await vi.waitFor(() => { + expect(mockFetch).toHaveBeenCalled(); + }); + + const options = mockFetch.mock.calls[0][1]; + expect(options.headers['Title']).toBe('[my-project] Cool Feature'); + expect(options.body).toContain('feat-123'); + }); + + it('should NOT execute ntfy hook when endpoint is disabled', async () => { + const disabledEndpoint = { + ...mockNtfyEndpoint, + enabled: false, + }; + + const hooks = [ + { + id: 'ntfy-hook-1', + enabled: true, + trigger: 'feature_success', + name: 'Ntfy Hook with Disabled Endpoint', + action: { + type: 'ntfy', + endpointId: 'endpoint-1', + }, + }, + ]; + + mockSettingsService = createMockSettingsService(hooks, [disabledEndpoint]); + service.initialize( + mockEmitter, + mockSettingsService, + mockEventHistoryService, + mockFeatureLoader + ); + + mockEmitter.simulateEvent('auto-mode:event', { + type: 'auto_mode_feature_complete', + executionMode: 'auto', + featureId: 'feat-1', + featureName: 'Test Feature', + passes: true, + message: 'Feature completed', + projectPath: '/test/project', + }); + + await vi.waitFor(() => { + expect(mockEventHistoryService.storeEvent).toHaveBeenCalled(); + }); + + // Fetch should not be called because endpoint is disabled + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('should use hook-specific values over endpoint defaults', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + }); + + const endpointWithDefaults = { + ...mockNtfyEndpoint, + defaultTags: 'default-tag', + defaultEmoji: 'default-emoji', + defaultClickUrl: 'https://default.example.com', + }; + + const hooks = [ + { + id: 'ntfy-hook-1', + enabled: true, + trigger: 'feature_success', + name: 'Ntfy Hook with Overrides', + action: { + type: 'ntfy', + endpointId: 'endpoint-1', + tags: 'override-tag', + emoji: 'override-emoji', + clickUrl: 'https://override.example.com', + priority: 5, + }, + }, + ]; + + mockSettingsService = createMockSettingsService(hooks, [endpointWithDefaults]); + service.initialize( + mockEmitter, + mockSettingsService, + mockEventHistoryService, + mockFeatureLoader + ); + + mockEmitter.simulateEvent('auto-mode:event', { + type: 'auto_mode_feature_complete', + executionMode: 'auto', + featureId: 'feat-1', + featureName: 'Test Feature', + passes: true, + message: 'Feature completed', + projectPath: '/test/project', + }); + + await vi.waitFor(() => { + expect(mockFetch).toHaveBeenCalled(); + }); + + const options = mockFetch.mock.calls[0][1]; + // Hook values should override endpoint defaults + expect(options.headers['Tags']).toBe('override-emoji,override-tag'); + expect(options.headers['Click']).toBe('https://override.example.com'); + expect(options.headers['Priority']).toBe('5'); + }); + + describe('click URL deep linking', () => { + it('should generate board URL with featureId query param when feature context is available', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + }); + + const endpointWithDefaultClickUrl = { + ...mockNtfyEndpoint, + defaultClickUrl: 'https://app.example.com', + }; + + const hooks = [ + { + id: 'ntfy-hook-1', + enabled: true, + trigger: 'feature_success', + name: 'Ntfy Hook', + action: { + type: 'ntfy', + endpointId: 'endpoint-1', + }, + }, + ]; + + mockSettingsService = createMockSettingsService(hooks, [endpointWithDefaultClickUrl]); + service.initialize( + mockEmitter, + mockSettingsService, + mockEventHistoryService, + mockFeatureLoader + ); + + mockEmitter.simulateEvent('auto-mode:event', { + type: 'auto_mode_feature_complete', + executionMode: 'auto', + featureId: 'test-feature-123', + featureName: 'Test Feature', + passes: true, + message: 'Feature completed', + projectPath: '/test/project', + }); + + await vi.waitFor(() => { + expect(mockFetch).toHaveBeenCalled(); + }); + + const options = mockFetch.mock.calls[0][1]; + const clickUrl = options.headers['Click']; + + // Should use /board path with featureId query param + expect(clickUrl).toContain('/board'); + expect(clickUrl).toContain('featureId=test-feature-123'); + // Should NOT use the old path-based format + expect(clickUrl).not.toContain('/feature/'); + }); + + it('should generate board URL without featureId when no feature context', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + }); + + const endpointWithDefaultClickUrl = { + ...mockNtfyEndpoint, + defaultClickUrl: 'https://app.example.com', + }; + + const hooks = [ + { + id: 'ntfy-hook-1', + enabled: true, + trigger: 'auto_mode_complete', + name: 'Auto Mode Complete Hook', + action: { + type: 'ntfy', + endpointId: 'endpoint-1', + }, + }, + ]; + + mockSettingsService = createMockSettingsService(hooks, [endpointWithDefaultClickUrl]); + service.initialize( + mockEmitter, + mockSettingsService, + mockEventHistoryService, + mockFeatureLoader + ); + + // Event without featureId but with projectPath (auto_mode_idle triggers auto_mode_complete) + mockEmitter.simulateEvent('auto-mode:event', { + type: 'auto_mode_idle', + executionMode: 'auto', + projectPath: '/test/project', + totalFeatures: 5, + }); + + await vi.waitFor(() => { + expect(mockFetch).toHaveBeenCalled(); + }); + + const options = mockFetch.mock.calls[0][1]; + const clickUrl = options.headers['Click']; + + // Should navigate to board without featureId + expect(clickUrl).toContain('/board'); + expect(clickUrl).not.toContain('featureId='); + }); + + it('should use hook-specific click URL overriding default with featureId', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + }); + + const endpointWithDefaultClickUrl = { + ...mockNtfyEndpoint, + defaultClickUrl: 'https://default.example.com', + }; + + const hooks = [ + { + id: 'ntfy-hook-1', + enabled: true, + trigger: 'feature_success', + name: 'Ntfy Hook with Custom Click URL', + action: { + type: 'ntfy', + endpointId: 'endpoint-1', + clickUrl: 'https://custom.example.com/custom-page', + }, + }, + ]; + + mockSettingsService = createMockSettingsService(hooks, [endpointWithDefaultClickUrl]); + service.initialize( + mockEmitter, + mockSettingsService, + mockEventHistoryService, + mockFeatureLoader + ); + + mockEmitter.simulateEvent('auto-mode:event', { + type: 'auto_mode_feature_complete', + executionMode: 'auto', + featureId: 'feat-789', + featureName: 'Custom URL Test', + passes: true, + message: 'Feature completed', + projectPath: '/test/project', + }); + + await vi.waitFor(() => { + expect(mockFetch).toHaveBeenCalled(); + }); + + const options = mockFetch.mock.calls[0][1]; + const clickUrl = options.headers['Click']; + + // Should use the hook-specific click URL (not modified with featureId since it's a custom URL) + expect(clickUrl).toBe('https://custom.example.com/custom-page'); + }); + + it('should preserve existing query params when adding featureId', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + }); + + const endpointWithDefaultClickUrl = { + ...mockNtfyEndpoint, + defaultClickUrl: 'https://app.example.com/board?view=list', + }; + + const hooks = [ + { + id: 'ntfy-hook-1', + enabled: true, + trigger: 'feature_success', + name: 'Ntfy Hook', + action: { + type: 'ntfy', + endpointId: 'endpoint-1', + }, + }, + ]; + + mockSettingsService = createMockSettingsService(hooks, [endpointWithDefaultClickUrl]); + service.initialize( + mockEmitter, + mockSettingsService, + mockEventHistoryService, + mockFeatureLoader + ); + + mockEmitter.simulateEvent('auto-mode:event', { + type: 'auto_mode_feature_complete', + executionMode: 'auto', + featureId: 'feat-456', + featureName: 'Test Feature', + passes: true, + message: 'Feature completed', + projectPath: '/test/project', + }); + + await vi.waitFor(() => { + expect(mockFetch).toHaveBeenCalled(); + }); + + const options = mockFetch.mock.calls[0][1]; + const clickUrl = options.headers['Click']; + + // Should preserve existing query params and add featureId + expect(clickUrl).toContain('view=list'); + expect(clickUrl).toContain('featureId=feat-456'); + // Should be properly formatted URL + expect(clickUrl).toMatch(/^https:\/\/app\.example\.com\/board\?.+$/); + }); + }); + }); }); diff --git a/apps/server/tests/unit/services/execution-service.test.ts b/apps/server/tests/unit/services/execution-service.test.ts index 7c2f3e0f9..0d976c9f7 100644 --- a/apps/server/tests/unit/services/execution-service.test.ts +++ b/apps/server/tests/unit/services/execution-service.test.ts @@ -451,13 +451,28 @@ describe('execution-service.ts', () => { const callArgs = mockRunAgentFn.mock.calls[0]; expect(callArgs[0]).toMatch(/test.*project/); // workDir contains project expect(callArgs[1]).toBe('feature-1'); - expect(callArgs[2]).toContain('Feature Implementation Task'); + expect(callArgs[2]).toContain('Feature Task'); expect(callArgs[3]).toBeInstanceOf(AbortController); expect(callArgs[4]).toBe('/test/project'); // Model (index 6) should be resolved expect(callArgs[6]).toBe('claude-sonnet-4'); }); + it('passes providerId to runAgentFn when present on feature', async () => { + const featureWithProvider: Feature = { + ...testFeature, + providerId: 'zai-provider-1', + }; + vi.mocked(mockLoadFeatureFn).mockResolvedValue(featureWithProvider); + + await service.executeFeature('/test/project', 'feature-1'); + + expect(mockRunAgentFn).toHaveBeenCalled(); + const callArgs = mockRunAgentFn.mock.calls[0]; + const options = callArgs[7]; + expect(options.providerId).toBe('zai-provider-1'); + }); + it('executes pipeline after agent completes', async () => { const pipelineSteps = [{ id: 'step-1', name: 'Step 1', order: 1, instructions: 'Do step 1' }]; vi.mocked(pipelineService.getPipelineConfig).mockResolvedValue({ @@ -1316,16 +1331,19 @@ describe('execution-service.ts', () => { ); }); - it('falls back to project path when worktree not found', async () => { + it('emits error and does not execute agent when worktree is not found in worktree mode', async () => { vi.mocked(mockWorktreeResolver.findWorktreeForBranch).mockResolvedValue(null); await service.executeFeature('/test/project', 'feature-1', true); - // Should still run agent, just with project path - expect(mockRunAgentFn).toHaveBeenCalled(); - const callArgs = mockRunAgentFn.mock.calls[0]; - // First argument is workDir - should be normalized path to /test/project - expect(callArgs[0]).toBe(normalizePath('/test/project')); + expect(mockRunAgentFn).not.toHaveBeenCalled(); + expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith( + 'auto_mode_error', + expect.objectContaining({ + featureId: 'feature-1', + error: 'Worktree enabled but no worktree found for feature branch "feature/test-1".', + }) + ); }); it('skips worktree resolution when useWorktrees is false', async () => { @@ -1439,6 +1457,114 @@ describe('execution-service.ts', () => { expect.objectContaining({ passes: true }) ); }); + + // Helper to create ExecutionService with a custom loadFeatureFn that returns + // different features on first load (initial) vs subsequent loads (after completion) + const createServiceWithCustomLoad = (completedFeature: Feature): ExecutionService => { + let loadCallCount = 0; + mockLoadFeatureFn = vi.fn().mockImplementation(() => { + loadCallCount++; + return loadCallCount === 1 ? testFeature : completedFeature; + }); + + return new ExecutionService( + mockEventBus, + mockConcurrencyManager, + mockWorktreeResolver, + mockSettingsService, + mockRunAgentFn, + mockExecutePipelineFn, + mockUpdateFeatureStatusFn, + mockLoadFeatureFn, + mockGetPlanningPromptPrefixFn, + mockSaveFeatureSummaryFn, + mockRecordLearningsFn, + mockContextExistsFn, + mockResumeFeatureFn, + mockTrackFailureFn, + mockSignalPauseFn, + mockRecordSuccessFn, + mockSaveExecutionStateFn, + mockLoadContextFilesFn + ); + }; + + it('does not overwrite accumulated summary when feature already has one', async () => { + const featureWithAccumulatedSummary: Feature = { + ...testFeature, + summary: + '### Implementation\n\nFirst step output\n\n---\n\n### Code Review\n\nReview findings', + }; + + const svc = createServiceWithCustomLoad(featureWithAccumulatedSummary); + await svc.executeFeature('/test/project', 'feature-1'); + + // saveFeatureSummaryFn should NOT be called because feature already has a summary + // This prevents overwriting accumulated pipeline summaries with just the last step's output + expect(mockSaveFeatureSummaryFn).not.toHaveBeenCalled(); + }); + + it('saves summary when feature has no existing summary', async () => { + const featureWithoutSummary: Feature = { + ...testFeature, + summary: undefined, + }; + + vi.mocked(secureFs.readFile).mockResolvedValue( + '🔧 Tool: Edit\nInput: {"file_path": "/src/index.ts"}\n\nNew summary' + ); + + const svc = createServiceWithCustomLoad(featureWithoutSummary); + await svc.executeFeature('/test/project', 'feature-1'); + + // Should save the extracted summary since feature has none + expect(mockSaveFeatureSummaryFn).toHaveBeenCalledWith( + '/test/project', + 'feature-1', + 'Test summary' + ); + }); + + it('does not overwrite summary when feature has empty string summary (treats as no summary)', async () => { + // Empty string is falsy, so it should be treated as "no summary" and a new one should be saved + const featureWithEmptySummary: Feature = { + ...testFeature, + summary: '', + }; + + vi.mocked(secureFs.readFile).mockResolvedValue( + '🔧 Tool: Edit\nInput: {"file_path": "/src/index.ts"}\n\nNew summary' + ); + + const svc = createServiceWithCustomLoad(featureWithEmptySummary); + await svc.executeFeature('/test/project', 'feature-1'); + + // Empty string is falsy, so it should save a new summary + expect(mockSaveFeatureSummaryFn).toHaveBeenCalledWith( + '/test/project', + 'feature-1', + 'Test summary' + ); + }); + + it('preserves accumulated summary when feature is transitioned from pipeline to verified', async () => { + // This is the key scenario: feature went through pipeline steps, accumulated a summary, + // then status changed to 'verified' - we should NOT overwrite the accumulated summary + const featureWithAccumulatedSummary: Feature = { + ...testFeature, + status: 'verified', + summary: + '### Implementation\n\nCreated auth module\n\n---\n\n### Code Review\n\nApproved\n\n---\n\n### Testing\n\nAll tests pass', + }; + + vi.mocked(secureFs.readFile).mockResolvedValue('Agent output with summary'); + + const svc = createServiceWithCustomLoad(featureWithAccumulatedSummary); + await svc.executeFeature('/test/project', 'feature-1'); + + // The accumulated summary should be preserved + expect(mockSaveFeatureSummaryFn).not.toHaveBeenCalled(); + }); }); describe('executeFeature - agent output validation', () => { diff --git a/apps/server/tests/unit/services/feature-state-manager.test.ts b/apps/server/tests/unit/services/feature-state-manager.test.ts index 6abd4764d..d0c3ea4b0 100644 --- a/apps/server/tests/unit/services/feature-state-manager.test.ts +++ b/apps/server/tests/unit/services/feature-state-manager.test.ts @@ -2,12 +2,17 @@ import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest'; import path from 'path'; import { FeatureStateManager } from '@/services/feature-state-manager.js'; import type { Feature } from '@automaker/types'; +import { isPipelineStatus } from '@automaker/types'; + +const PIPELINE_SUMMARY_SEPARATOR = '\n\n---\n\n'; +const PIPELINE_SUMMARY_HEADER_PREFIX = '### '; import type { EventEmitter } from '@/lib/events.js'; import type { FeatureLoader } from '@/services/feature-loader.js'; import * as secureFs from '@/lib/secure-fs.js'; import { atomicWriteJson, readJsonWithRecovery } from '@automaker/utils'; import { getFeatureDir, getFeaturesDir } from '@automaker/platform'; import { getNotificationService } from '@/services/notification-service.js'; +import { pipelineService } from '@/services/pipeline-service.js'; /** * Helper to normalize paths for cross-platform test compatibility. @@ -42,6 +47,16 @@ vi.mock('@/services/notification-service.js', () => ({ })), })); +vi.mock('@/services/pipeline-service.js', () => ({ + pipelineService: { + getStepIdFromStatus: vi.fn((status: string) => { + if (status.startsWith('pipeline_')) return status.replace('pipeline_', ''); + return null; + }), + getStep: vi.fn(), + }, +})); + describe('FeatureStateManager', () => { let manager: FeatureStateManager; let mockEvents: EventEmitter; @@ -264,6 +279,81 @@ describe('FeatureStateManager', () => { ); }); + it('should use feature.title as notification title for waiting_approval status', async () => { + const mockNotificationService = { createNotification: vi.fn() }; + (getNotificationService as Mock).mockReturnValue(mockNotificationService); + const featureWithTitle: Feature = { + ...mockFeature, + title: 'My Awesome Feature Title', + name: 'old-name-property', // name property exists but should not be used + }; + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: featureWithTitle, + recovered: false, + source: 'main', + }); + + await manager.updateFeatureStatus('/project', 'feature-123', 'waiting_approval'); + + expect(mockNotificationService.createNotification).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'feature_waiting_approval', + title: 'My Awesome Feature Title', + message: 'Feature Ready for Review', + }) + ); + }); + + it('should fallback to featureId as notification title when feature.title is undefined in waiting_approval notification', async () => { + const mockNotificationService = { createNotification: vi.fn() }; + (getNotificationService as Mock).mockReturnValue(mockNotificationService); + const featureWithoutTitle: Feature = { + ...mockFeature, + title: undefined, + name: 'old-name-property', + }; + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: featureWithoutTitle, + recovered: false, + source: 'main', + }); + + await manager.updateFeatureStatus('/project', 'feature-123', 'waiting_approval'); + + expect(mockNotificationService.createNotification).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'feature_waiting_approval', + title: 'feature-123', + message: 'Feature Ready for Review', + }) + ); + }); + + it('should handle empty string title by using featureId as notification title in waiting_approval notification', async () => { + const mockNotificationService = { createNotification: vi.fn() }; + (getNotificationService as Mock).mockReturnValue(mockNotificationService); + const featureWithEmptyTitle: Feature = { + ...mockFeature, + title: '', + name: 'old-name-property', + }; + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: featureWithEmptyTitle, + recovered: false, + source: 'main', + }); + + await manager.updateFeatureStatus('/project', 'feature-123', 'waiting_approval'); + + expect(mockNotificationService.createNotification).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'feature_waiting_approval', + title: 'feature-123', + message: 'Feature Ready for Review', + }) + ); + }); + it('should create notification for verified status', async () => { const mockNotificationService = { createNotification: vi.fn() }; (getNotificationService as Mock).mockReturnValue(mockNotificationService); @@ -283,6 +373,81 @@ describe('FeatureStateManager', () => { ); }); + it('should use feature.title as notification title for verified status', async () => { + const mockNotificationService = { createNotification: vi.fn() }; + (getNotificationService as Mock).mockReturnValue(mockNotificationService); + const featureWithTitle: Feature = { + ...mockFeature, + title: 'My Awesome Feature Title', + name: 'old-name-property', // name property exists but should not be used + }; + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: featureWithTitle, + recovered: false, + source: 'main', + }); + + await manager.updateFeatureStatus('/project', 'feature-123', 'verified'); + + expect(mockNotificationService.createNotification).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'feature_verified', + title: 'My Awesome Feature Title', + message: 'Feature Verified', + }) + ); + }); + + it('should fallback to featureId as notification title when feature.title is undefined in verified notification', async () => { + const mockNotificationService = { createNotification: vi.fn() }; + (getNotificationService as Mock).mockReturnValue(mockNotificationService); + const featureWithoutTitle: Feature = { + ...mockFeature, + title: undefined, + name: 'old-name-property', + }; + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: featureWithoutTitle, + recovered: false, + source: 'main', + }); + + await manager.updateFeatureStatus('/project', 'feature-123', 'verified'); + + expect(mockNotificationService.createNotification).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'feature_verified', + title: 'feature-123', + message: 'Feature Verified', + }) + ); + }); + + it('should handle empty string title by using featureId as notification title in verified notification', async () => { + const mockNotificationService = { createNotification: vi.fn() }; + (getNotificationService as Mock).mockReturnValue(mockNotificationService); + const featureWithEmptyTitle: Feature = { + ...mockFeature, + title: '', + name: 'old-name-property', + }; + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: featureWithEmptyTitle, + recovered: false, + source: 'main', + }); + + await manager.updateFeatureStatus('/project', 'feature-123', 'verified'); + + expect(mockNotificationService.createNotification).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'feature_verified', + title: 'feature-123', + message: 'Feature Verified', + }) + ); + }); + it('should sync to app_spec for completed status', async () => { (readJsonWithRecovery as Mock).mockResolvedValue({ data: { ...mockFeature }, @@ -341,9 +506,6 @@ describe('FeatureStateManager', () => { describe('markFeatureInterrupted', () => { it('should mark feature as interrupted', async () => { - (secureFs.readFile as Mock).mockResolvedValue( - JSON.stringify({ ...mockFeature, status: 'in_progress' }) - ); (readJsonWithRecovery as Mock).mockResolvedValue({ data: { ...mockFeature, status: 'in_progress' }, recovered: false, @@ -358,20 +520,25 @@ describe('FeatureStateManager', () => { }); it('should preserve pipeline_* statuses', async () => { - (secureFs.readFile as Mock).mockResolvedValue( - JSON.stringify({ ...mockFeature, status: 'pipeline_step_1' }) - ); + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: { ...mockFeature, status: 'pipeline_step_1' }, + recovered: false, + source: 'main', + }); await manager.markFeatureInterrupted('/project', 'feature-123', 'server shutdown'); // Should NOT call atomicWriteJson because pipeline status is preserved expect(atomicWriteJson).not.toHaveBeenCalled(); + expect(isPipelineStatus('pipeline_step_1')).toBe(true); }); it('should preserve pipeline_complete status', async () => { - (secureFs.readFile as Mock).mockResolvedValue( - JSON.stringify({ ...mockFeature, status: 'pipeline_complete' }) - ); + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: { ...mockFeature, status: 'pipeline_complete' }, + recovered: false, + source: 'main', + }); await manager.markFeatureInterrupted('/project', 'feature-123'); @@ -379,7 +546,6 @@ describe('FeatureStateManager', () => { }); it('should handle feature not found', async () => { - (secureFs.readFile as Mock).mockRejectedValue(new Error('ENOENT')); (readJsonWithRecovery as Mock).mockResolvedValue({ data: null, recovered: true, @@ -439,6 +605,29 @@ describe('FeatureStateManager', () => { expect(savedFeature.status).toBe('backlog'); }); + it('should preserve pipeline_* statuses during reset', async () => { + const pipelineFeature: Feature = { + ...mockFeature, + status: 'pipeline_testing', + planSpec: { status: 'approved', version: 1, reviewedByUser: true }, + }; + + (secureFs.readdir as Mock).mockResolvedValue([ + { name: 'feature-123', isDirectory: () => true }, + ]); + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: pipelineFeature, + recovered: false, + source: 'main', + }); + + await manager.resetStuckFeatures('/project'); + + // Status should NOT be changed, but needsUpdate might be true if other things reset + // In this case, nothing else should be reset, so atomicWriteJson shouldn't be called + expect(atomicWriteJson).not.toHaveBeenCalled(); + }); + it('should reset generating planSpec status to pending', async () => { const stuckFeature: Feature = { ...mockFeature, @@ -628,6 +817,379 @@ describe('FeatureStateManager', () => { expect(atomicWriteJson).not.toHaveBeenCalled(); expect(mockEvents.emit).not.toHaveBeenCalled(); }); + + it('should accumulate summary with step header for pipeline features', async () => { + (pipelineService.getStep as Mock).mockResolvedValue({ name: 'Code Review', id: 'step1' }); + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: { ...mockFeature, status: 'pipeline_step1', summary: undefined }, + recovered: false, + source: 'main', + }); + + await manager.saveFeatureSummary('/project', 'feature-123', 'First step output'); + + const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature; + expect(savedFeature.summary).toBe( + `${PIPELINE_SUMMARY_HEADER_PREFIX}Code Review\n\nFirst step output` + ); + }); + + it('should append subsequent pipeline step summaries with separator', async () => { + const existingSummary = `${PIPELINE_SUMMARY_HEADER_PREFIX}Code Review\n\nFirst step output`; + (pipelineService.getStep as Mock).mockResolvedValue({ name: 'Testing', id: 'step2' }); + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: { ...mockFeature, status: 'pipeline_step2', summary: existingSummary }, + recovered: false, + source: 'main', + }); + + await manager.saveFeatureSummary('/project', 'feature-123', 'Second step output'); + + const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature; + expect(savedFeature.summary).toBe( + `${PIPELINE_SUMMARY_HEADER_PREFIX}Code Review\n\nFirst step output${PIPELINE_SUMMARY_SEPARATOR}${PIPELINE_SUMMARY_HEADER_PREFIX}Testing\n\nSecond step output` + ); + }); + + it('should normalize existing non-phase summary before appending pipeline step summary', async () => { + const existingSummary = 'Implemented authentication and settings management.'; + (pipelineService.getStep as Mock).mockResolvedValue({ name: 'Code Review', id: 'step1' }); + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: { ...mockFeature, status: 'pipeline_step1', summary: existingSummary }, + recovered: false, + source: 'main', + }); + + await manager.saveFeatureSummary('/project', 'feature-123', 'Reviewed and approved changes'); + + const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature; + expect(savedFeature.summary).toBe( + `${PIPELINE_SUMMARY_HEADER_PREFIX}Implementation\n\nImplemented authentication and settings management.${PIPELINE_SUMMARY_SEPARATOR}${PIPELINE_SUMMARY_HEADER_PREFIX}Code Review\n\nReviewed and approved changes` + ); + }); + + it('should use fallback step name when pipeline step not found', async () => { + (pipelineService.getStep as Mock).mockResolvedValue(null); + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: { ...mockFeature, status: 'pipeline_unknown_step', summary: undefined }, + recovered: false, + source: 'main', + }); + + await manager.saveFeatureSummary('/project', 'feature-123', 'Step output'); + + const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature; + expect(savedFeature.summary).toBe( + `${PIPELINE_SUMMARY_HEADER_PREFIX}Unknown Step\n\nStep output` + ); + }); + + it('should overwrite summary for non-pipeline features', async () => { + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: { ...mockFeature, status: 'in_progress', summary: 'Old summary' }, + recovered: false, + source: 'main', + }); + + await manager.saveFeatureSummary('/project', 'feature-123', 'New summary'); + + const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature; + expect(savedFeature.summary).toBe('New summary'); + }); + + it('should emit full accumulated summary for pipeline features', async () => { + const existingSummary = '### Code Review\n\nFirst step output'; + (pipelineService.getStep as Mock).mockResolvedValue({ name: 'Refinement', id: 'step2' }); + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: { ...mockFeature, status: 'pipeline_step2', summary: existingSummary }, + recovered: false, + source: 'main', + }); + + await manager.saveFeatureSummary('/project', 'feature-123', 'Refinement output'); + + const expectedSummary = + '### Code Review\n\nFirst step output\n\n---\n\n### Refinement\n\nRefinement output'; + expect(mockEvents.emit).toHaveBeenCalledWith('auto-mode:event', { + type: 'auto_mode_summary', + featureId: 'feature-123', + projectPath: '/project', + summary: expectedSummary, + }); + }); + + it('should skip accumulation for pipeline features when summary is empty', async () => { + (pipelineService.getStep as Mock).mockResolvedValue({ name: 'Testing', id: 'step1' }); + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: { ...mockFeature, status: 'pipeline_step1', summary: '' }, + recovered: false, + source: 'main', + }); + + await manager.saveFeatureSummary('/project', 'feature-123', 'Test output'); + + const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature; + // Empty string is falsy, so should start fresh + expect(savedFeature.summary).toBe('### Testing\n\nTest output'); + }); + + it('should skip persistence when incoming summary is only whitespace', async () => { + (pipelineService.getStep as Mock).mockResolvedValue({ name: 'Testing', id: 'step1' }); + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: { ...mockFeature, status: 'pipeline_step1', summary: '### Existing\n\nValue' }, + recovered: false, + source: 'main', + }); + + await manager.saveFeatureSummary('/project', 'feature-123', ' \n\t '); + + expect(atomicWriteJson).not.toHaveBeenCalled(); + expect(mockEvents.emit).not.toHaveBeenCalled(); + }); + + it('should accumulate three pipeline steps in chronological order', async () => { + // Step 1: Code Review + (pipelineService.getStep as Mock).mockResolvedValue({ name: 'Code Review', id: 'step1' }); + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: { ...mockFeature, status: 'pipeline_step1', summary: undefined }, + recovered: false, + source: 'main', + }); + + await manager.saveFeatureSummary('/project', 'feature-123', 'Review findings'); + const afterStep1 = (atomicWriteJson as Mock).mock.calls[0][1] as Feature; + expect(afterStep1.summary).toBe('### Code Review\n\nReview findings'); + + // Step 2: Testing (summary from step 1 exists) + vi.clearAllMocks(); + (getFeatureDir as Mock).mockReturnValue('/project/.automaker/features/feature-123'); + (pipelineService.getStep as Mock).mockResolvedValue({ name: 'Testing', id: 'step2' }); + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: { ...mockFeature, status: 'pipeline_step2', summary: afterStep1.summary }, + recovered: false, + source: 'main', + }); + + await manager.saveFeatureSummary('/project', 'feature-123', 'All tests pass'); + const afterStep2 = (atomicWriteJson as Mock).mock.calls[0][1] as Feature; + + // Step 3: Refinement (summaries from steps 1+2 exist) + vi.clearAllMocks(); + (getFeatureDir as Mock).mockReturnValue('/project/.automaker/features/feature-123'); + (pipelineService.getStep as Mock).mockResolvedValue({ name: 'Refinement', id: 'step3' }); + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: { ...mockFeature, status: 'pipeline_step3', summary: afterStep2.summary }, + recovered: false, + source: 'main', + }); + + await manager.saveFeatureSummary('/project', 'feature-123', 'Code polished'); + const afterStep3 = (atomicWriteJson as Mock).mock.calls[0][1] as Feature; + + // Verify the full accumulated summary has all three steps in order + expect(afterStep3.summary).toBe( + `${PIPELINE_SUMMARY_HEADER_PREFIX}Code Review\n\nReview findings${PIPELINE_SUMMARY_SEPARATOR}${PIPELINE_SUMMARY_HEADER_PREFIX}Testing\n\nAll tests pass${PIPELINE_SUMMARY_SEPARATOR}${PIPELINE_SUMMARY_HEADER_PREFIX}Refinement\n\nCode polished` + ); + }); + + it('should replace existing step summary if called again for the same step', async () => { + const existingSummary = `${PIPELINE_SUMMARY_HEADER_PREFIX}Implementation\n\nInitial code${PIPELINE_SUMMARY_SEPARATOR}${PIPELINE_SUMMARY_HEADER_PREFIX}Code Review\n\nFirst review attempt`; + (pipelineService.getStep as Mock).mockResolvedValue({ name: 'Code Review', id: 'step1' }); + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: { ...mockFeature, status: 'pipeline_step1', summary: existingSummary }, + recovered: false, + source: 'main', + }); + + await manager.saveFeatureSummary( + '/project', + 'feature-123', + 'Second review attempt (success)' + ); + + const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature; + // Should REPLACE "First review attempt" with "Second review attempt (success)" + // and NOT append it as a new section + expect(savedFeature.summary).toBe( + `${PIPELINE_SUMMARY_HEADER_PREFIX}Implementation\n\nInitial code${PIPELINE_SUMMARY_SEPARATOR}${PIPELINE_SUMMARY_HEADER_PREFIX}Code Review\n\nSecond review attempt (success)` + ); + // Ensure it didn't duplicate the separator or header + expect( + savedFeature.summary.match(new RegExp(PIPELINE_SUMMARY_HEADER_PREFIX + 'Code Review', 'g')) + ?.length + ).toBe(1); + expect( + savedFeature.summary.match(new RegExp(PIPELINE_SUMMARY_SEPARATOR.trim(), 'g'))?.length + ).toBe(1); + }); + + it('should replace last step summary without trailing separator', async () => { + // Test case: replacing the last step which has no separator after it + const existingSummary = `${PIPELINE_SUMMARY_HEADER_PREFIX}Implementation\n\nInitial code${PIPELINE_SUMMARY_SEPARATOR}${PIPELINE_SUMMARY_HEADER_PREFIX}Testing\n\nFirst test run`; + (pipelineService.getStep as Mock).mockResolvedValue({ name: 'Testing', id: 'step2' }); + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: { ...mockFeature, status: 'pipeline_step2', summary: existingSummary }, + recovered: false, + source: 'main', + }); + + await manager.saveFeatureSummary('/project', 'feature-123', 'All tests pass'); + + const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature; + expect(savedFeature.summary).toBe( + `${PIPELINE_SUMMARY_HEADER_PREFIX}Implementation\n\nInitial code${PIPELINE_SUMMARY_SEPARATOR}${PIPELINE_SUMMARY_HEADER_PREFIX}Testing\n\nAll tests pass` + ); + }); + + it('should replace first step summary with separator after it', async () => { + // Test case: replacing the first step which has a separator after it + const existingSummary = `${PIPELINE_SUMMARY_HEADER_PREFIX}Implementation\n\nFirst attempt${PIPELINE_SUMMARY_SEPARATOR}${PIPELINE_SUMMARY_HEADER_PREFIX}Testing\n\nAll tests pass`; + (pipelineService.getStep as Mock).mockResolvedValue({ name: 'Implementation', id: 'step1' }); + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: { ...mockFeature, status: 'pipeline_step1', summary: existingSummary }, + recovered: false, + source: 'main', + }); + + await manager.saveFeatureSummary('/project', 'feature-123', 'Second attempt'); + + const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature; + expect(savedFeature.summary).toBe( + `${PIPELINE_SUMMARY_HEADER_PREFIX}Implementation\n\nSecond attempt${PIPELINE_SUMMARY_SEPARATOR}${PIPELINE_SUMMARY_HEADER_PREFIX}Testing\n\nAll tests pass` + ); + }); + + it('should not match step header appearing in body text, only at section boundaries', async () => { + // Test case: body text contains "### Testing" which should NOT be matched + // Only headers at actual section boundaries should be replaced + const existingSummary = `${PIPELINE_SUMMARY_HEADER_PREFIX}Implementation\n\nThis step covers the Testing module.\n\n### Testing\n\nThe above is just markdown in body, not a section header.${PIPELINE_SUMMARY_SEPARATOR}${PIPELINE_SUMMARY_HEADER_PREFIX}Testing\n\nReal test section`; + (pipelineService.getStep as Mock).mockResolvedValue({ name: 'Testing', id: 'step2' }); + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: { ...mockFeature, status: 'pipeline_step2', summary: existingSummary }, + recovered: false, + source: 'main', + }); + + await manager.saveFeatureSummary('/project', 'feature-123', 'Updated test results'); + + const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature; + // The section replacement should only replace the actual Testing section at the boundary + // NOT the "### Testing" that appears in the body text + expect(savedFeature.summary).toBe( + `${PIPELINE_SUMMARY_HEADER_PREFIX}Implementation\n\nThis step covers the Testing module.\n\n### Testing\n\nThe above is just markdown in body, not a section header.${PIPELINE_SUMMARY_SEPARATOR}${PIPELINE_SUMMARY_HEADER_PREFIX}Testing\n\nUpdated test results` + ); + }); + + it('should handle step name with special regex characters safely', async () => { + // Test case: step name contains characters that would break regex + const existingSummary = `${PIPELINE_SUMMARY_HEADER_PREFIX}Code (Review)\n\nFirst attempt`; + (pipelineService.getStep as Mock).mockResolvedValue({ name: 'Code (Review)', id: 'step1' }); + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: { ...mockFeature, status: 'pipeline_step1', summary: existingSummary }, + recovered: false, + source: 'main', + }); + + await manager.saveFeatureSummary('/project', 'feature-123', 'Second attempt'); + + const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature; + expect(savedFeature.summary).toBe( + `${PIPELINE_SUMMARY_HEADER_PREFIX}Code (Review)\n\nSecond attempt` + ); + }); + + it('should handle step name with brackets safely', async () => { + // Test case: step name contains array-like syntax [0] + const existingSummary = `${PIPELINE_SUMMARY_HEADER_PREFIX}Step [0]\n\nFirst attempt`; + (pipelineService.getStep as Mock).mockResolvedValue({ name: 'Step [0]', id: 'step1' }); + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: { ...mockFeature, status: 'pipeline_step1', summary: existingSummary }, + recovered: false, + source: 'main', + }); + + await manager.saveFeatureSummary('/project', 'feature-123', 'Second attempt'); + + const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature; + expect(savedFeature.summary).toBe( + `${PIPELINE_SUMMARY_HEADER_PREFIX}Step [0]\n\nSecond attempt` + ); + }); + + it('should handle pipelineService.getStepIdFromStatus throwing an error gracefully', async () => { + (pipelineService.getStepIdFromStatus as Mock).mockImplementation(() => { + throw new Error('Config not found'); + }); + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: { ...mockFeature, status: 'pipeline_my_step', summary: undefined }, + recovered: false, + source: 'main', + }); + + await manager.saveFeatureSummary('/project', 'feature-123', 'Step output'); + + const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature; + // Should use fallback: capitalize each word in the status suffix + expect(savedFeature.summary).toBe(`${PIPELINE_SUMMARY_HEADER_PREFIX}My Step\n\nStep output`); + }); + + it('should handle pipelineService.getStep throwing an error gracefully', async () => { + (pipelineService.getStep as Mock).mockRejectedValue(new Error('Disk read error')); + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: { ...mockFeature, status: 'pipeline_code_review', summary: undefined }, + recovered: false, + source: 'main', + }); + + await manager.saveFeatureSummary('/project', 'feature-123', 'Step output'); + + const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature; + // Should use fallback: capitalize each word in the status suffix + expect(savedFeature.summary).toBe( + `${PIPELINE_SUMMARY_HEADER_PREFIX}Code Review\n\nStep output` + ); + }); + + it('should handle summary content with markdown formatting', async () => { + (pipelineService.getStep as Mock).mockResolvedValue({ name: 'Code Review', id: 'step1' }); + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: { ...mockFeature, status: 'pipeline_step1', summary: undefined }, + recovered: false, + source: 'main', + }); + + const markdownSummary = + '## Changes Made\n- Fixed **bug** in `parser.ts`\n- Added `validateInput()` function\n\n```typescript\nconst x = 1;\n```'; + + await manager.saveFeatureSummary('/project', 'feature-123', markdownSummary); + + const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature; + expect(savedFeature.summary).toBe( + `${PIPELINE_SUMMARY_HEADER_PREFIX}Code Review\n\n${markdownSummary}` + ); + }); + + it('should persist before emitting event for pipeline summary accumulation', async () => { + const callOrder: string[] = []; + const existingSummary = '### Code Review\n\nFirst step output'; + + (pipelineService.getStep as Mock).mockResolvedValue({ name: 'Testing', id: 'step2' }); + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: { ...mockFeature, status: 'pipeline_step2', summary: existingSummary }, + recovered: false, + source: 'main', + }); + (atomicWriteJson as Mock).mockImplementation(async () => { + callOrder.push('persist'); + }); + (mockEvents.emit as Mock).mockImplementation(() => { + callOrder.push('emit'); + }); + + await manager.saveFeatureSummary('/project', 'feature-123', 'Test results'); + + expect(callOrder).toEqual(['persist', 'emit']); + }); }); describe('updateTaskStatus', () => { @@ -668,6 +1230,48 @@ describe('FeatureStateManager', () => { }); }); + it('should update task status and summary and emit event', async () => { + const featureWithTasks: Feature = { + ...mockFeature, + planSpec: { + status: 'approved', + version: 1, + reviewedByUser: true, + tasks: [{ id: 'task-1', title: 'Task 1', status: 'pending', description: '' }], + }, + }; + + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: featureWithTasks, + recovered: false, + source: 'main', + }); + + await manager.updateTaskStatus( + '/project', + 'feature-123', + 'task-1', + 'completed', + 'Task finished successfully' + ); + + // Verify persisted + const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature; + expect(savedFeature.planSpec?.tasks?.[0].status).toBe('completed'); + expect(savedFeature.planSpec?.tasks?.[0].summary).toBe('Task finished successfully'); + + // Verify event emitted + expect(mockEvents.emit).toHaveBeenCalledWith('auto-mode:event', { + type: 'auto_mode_task_status', + featureId: 'feature-123', + projectPath: '/project', + taskId: 'task-1', + status: 'completed', + summary: 'Task finished successfully', + tasks: expect.any(Array), + }); + }); + it('should handle task not found', async () => { const featureWithTasks: Feature = { ...mockFeature, @@ -757,4 +1361,179 @@ describe('FeatureStateManager', () => { expect(callOrder).toEqual(['persist', 'emit']); }); }); + + describe('handleAutoModeEventError', () => { + let subscribeCallback: (type: string, payload: unknown) => void; + + beforeEach(() => { + // Get the subscribe callback from the mock - the callback passed TO subscribe is at index [0] + // subscribe is called like: events.subscribe(callback), so callback is at mock.calls[0][0] + const mockCalls = (mockEvents.subscribe as Mock).mock.calls; + if (mockCalls.length > 0 && mockCalls[0].length > 0) { + subscribeCallback = mockCalls[0][0] as typeof subscribeCallback; + } + }); + + it('should ignore events with no type', async () => { + const mockNotificationService = { createNotification: vi.fn() }; + (getNotificationService as Mock).mockReturnValue(mockNotificationService); + + await subscribeCallback('auto-mode:event', {}); + + expect(mockNotificationService.createNotification).not.toHaveBeenCalled(); + }); + + it('should ignore non-error events', async () => { + const mockNotificationService = { createNotification: vi.fn() }; + (getNotificationService as Mock).mockReturnValue(mockNotificationService); + + await subscribeCallback('auto-mode:event', { + type: 'auto_mode_feature_complete', + passes: true, + projectPath: '/project', + }); + + expect(mockNotificationService.createNotification).not.toHaveBeenCalled(); + }); + + it('should create auto_mode_error notification with gesture name as title when no featureId', async () => { + const mockNotificationService = { createNotification: vi.fn() }; + (getNotificationService as Mock).mockReturnValue(mockNotificationService); + + await subscribeCallback('auto-mode:event', { + type: 'auto_mode_error', + message: 'Something went wrong', + projectPath: '/project', + }); + + expect(mockNotificationService.createNotification).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'auto_mode_error', + title: 'Auto Mode Error', + message: 'Something went wrong', + projectPath: '/project', + }) + ); + }); + + it('should use error field instead of message when available', async () => { + const mockNotificationService = { createNotification: vi.fn() }; + (getNotificationService as Mock).mockReturnValue(mockNotificationService); + + await subscribeCallback('auto-mode:event', { + type: 'auto_mode_error', + message: 'Some message', + error: 'The actual error', + projectPath: '/project', + }); + + expect(mockNotificationService.createNotification).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'auto_mode_error', + message: 'The actual error', + }) + ); + }); + + it('should use feature title as notification title for feature error with featureId', async () => { + const mockNotificationService = { createNotification: vi.fn() }; + (getNotificationService as Mock).mockReturnValue(mockNotificationService); + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: { ...mockFeature, title: 'Login Page Feature' }, + recovered: false, + source: 'main', + }); + + subscribeCallback('auto-mode:event', { + type: 'auto_mode_feature_complete', + passes: false, + featureId: 'feature-123', + error: 'Build failed', + projectPath: '/project', + }); + + // Wait for async handleAutoModeEventError to complete + await vi.waitFor(() => { + expect(mockNotificationService.createNotification).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'feature_error', + title: 'Login Page Feature', + message: 'Feature Failed: Build failed', + featureId: 'feature-123', + }) + ); + }); + }); + + it('should ignore auto_mode_feature_complete without passes=false', async () => { + const mockNotificationService = { createNotification: vi.fn() }; + (getNotificationService as Mock).mockReturnValue(mockNotificationService); + + await subscribeCallback('auto-mode:event', { + type: 'auto_mode_feature_complete', + passes: true, + projectPath: '/project', + }); + + expect(mockNotificationService.createNotification).not.toHaveBeenCalled(); + }); + + it('should handle missing projectPath gracefully', async () => { + const mockNotificationService = { createNotification: vi.fn() }; + (getNotificationService as Mock).mockReturnValue(mockNotificationService); + + await subscribeCallback('auto-mode:event', { + type: 'auto_mode_error', + message: 'Error occurred', + }); + + expect(mockNotificationService.createNotification).not.toHaveBeenCalled(); + }); + + it('should handle notification service failures gracefully', async () => { + (getNotificationService as Mock).mockImplementation(() => { + throw new Error('Service unavailable'); + }); + + // Should not throw - the callback returns void so we just call it and wait for async work + subscribeCallback('auto-mode:event', { + type: 'auto_mode_error', + message: 'Error', + projectPath: '/project', + }); + + // Give async handleAutoModeEventError time to complete + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + }); + + describe('destroy', () => { + it('should unsubscribe from event subscription', () => { + const unsubscribeFn = vi.fn(); + (mockEvents.subscribe as Mock).mockReturnValue(unsubscribeFn); + + // Create a new manager to get a fresh subscription + const newManager = new FeatureStateManager(mockEvents, mockFeatureLoader); + + // Call destroy + newManager.destroy(); + + // Verify unsubscribe was called + expect(unsubscribeFn).toHaveBeenCalled(); + }); + + it('should handle destroy being called multiple times', () => { + const unsubscribeFn = vi.fn(); + (mockEvents.subscribe as Mock).mockReturnValue(unsubscribeFn); + + const newManager = new FeatureStateManager(mockEvents, mockFeatureLoader); + + // Call destroy multiple times + newManager.destroy(); + newManager.destroy(); + + // Should only unsubscribe once + expect(unsubscribeFn).toHaveBeenCalledTimes(1); + }); + }); }); diff --git a/apps/server/tests/unit/services/ntfy-service.test.ts b/apps/server/tests/unit/services/ntfy-service.test.ts new file mode 100644 index 000000000..0a2cc1951 --- /dev/null +++ b/apps/server/tests/unit/services/ntfy-service.test.ts @@ -0,0 +1,642 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { NtfyService } from '../../../src/services/ntfy-service.js'; +import type { NtfyEndpointConfig } from '@automaker/types'; + +// Mock global fetch +const originalFetch = global.fetch; + +describe('NtfyService', () => { + let service: NtfyService; + let mockFetch: ReturnType; + + beforeEach(() => { + service = new NtfyService(); + mockFetch = vi.fn(); + global.fetch = mockFetch; + }); + + afterEach(() => { + global.fetch = originalFetch; + vi.restoreAllMocks(); + }); + + /** + * Create a valid endpoint config for testing + */ + function createEndpoint(overrides: Partial = {}): NtfyEndpointConfig { + return { + id: 'test-endpoint-id', + name: 'Test Endpoint', + serverUrl: 'https://ntfy.sh', + topic: 'test-topic', + authType: 'none', + enabled: true, + ...overrides, + }; + } + + /** + * Create a basic context for testing + */ + function createContext() { + return { + featureId: 'feat-123', + featureName: 'Test Feature', + projectPath: '/test/project', + projectName: 'test-project', + timestamp: '2024-01-15T10:30:00.000Z', + eventType: 'feature_success', + }; + } + + describe('validateEndpoint', () => { + it('should return null for valid endpoint with no auth', () => { + const endpoint = createEndpoint(); + const result = service.validateEndpoint(endpoint); + expect(result).toBeNull(); + }); + + it('should return null for valid endpoint with basic auth', () => { + const endpoint = createEndpoint({ + authType: 'basic', + username: 'user', + password: 'pass', + }); + const result = service.validateEndpoint(endpoint); + expect(result).toBeNull(); + }); + + it('should return null for valid endpoint with token auth', () => { + const endpoint = createEndpoint({ + authType: 'token', + token: 'tk_123456', + }); + const result = service.validateEndpoint(endpoint); + expect(result).toBeNull(); + }); + + it('should return error when serverUrl is missing', () => { + const endpoint = createEndpoint({ serverUrl: '' }); + const result = service.validateEndpoint(endpoint); + expect(result).toBe('Server URL is required'); + }); + + it('should return error when serverUrl is invalid', () => { + const endpoint = createEndpoint({ serverUrl: 'not-a-valid-url' }); + const result = service.validateEndpoint(endpoint); + expect(result).toBe('Invalid server URL format'); + }); + + it('should return error when topic is missing', () => { + const endpoint = createEndpoint({ topic: '' }); + const result = service.validateEndpoint(endpoint); + expect(result).toBe('Topic is required'); + }); + + it('should return error when topic contains spaces', () => { + const endpoint = createEndpoint({ topic: 'invalid topic' }); + const result = service.validateEndpoint(endpoint); + expect(result).toBe('Topic cannot contain spaces'); + }); + + it('should return error when topic contains tabs', () => { + const endpoint = createEndpoint({ topic: 'invalid\ttopic' }); + const result = service.validateEndpoint(endpoint); + expect(result).toBe('Topic cannot contain spaces'); + }); + + it('should return error when basic auth is missing username', () => { + const endpoint = createEndpoint({ + authType: 'basic', + username: '', + password: 'pass', + }); + const result = service.validateEndpoint(endpoint); + expect(result).toBe('Username and password are required for basic authentication'); + }); + + it('should return error when basic auth is missing password', () => { + const endpoint = createEndpoint({ + authType: 'basic', + username: 'user', + password: '', + }); + const result = service.validateEndpoint(endpoint); + expect(result).toBe('Username and password are required for basic authentication'); + }); + + it('should return error when token auth is missing token', () => { + const endpoint = createEndpoint({ + authType: 'token', + token: '', + }); + const result = service.validateEndpoint(endpoint); + expect(result).toBe('Access token is required for token authentication'); + }); + }); + + describe('sendNotification', () => { + it('should return error when endpoint is disabled', async () => { + const endpoint = createEndpoint({ enabled: false }); + const result = await service.sendNotification(endpoint, {}, createContext()); + expect(result.success).toBe(false); + expect(result.error).toBe('Endpoint is disabled'); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('should return error when endpoint validation fails', async () => { + const endpoint = createEndpoint({ serverUrl: '' }); + const result = await service.sendNotification(endpoint, {}, createContext()); + expect(result.success).toBe(false); + expect(result.error).toBe('Server URL is required'); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('should send notification with default values', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + }); + + const endpoint = createEndpoint(); + const result = await service.sendNotification(endpoint, {}, createContext()); + + expect(result.success).toBe(true); + expect(mockFetch).toHaveBeenCalledTimes(1); + + const [url, options] = mockFetch.mock.calls[0]; + expect(url).toBe('https://ntfy.sh/test-topic'); + expect(options.method).toBe('POST'); + expect(options.headers['Content-Type']).toBe('text/plain; charset=utf-8'); + expect(options.headers['Title']).toContain('Feature Completed'); + expect(options.headers['Priority']).toBe('3'); + }); + + it('should send notification with custom title and body', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + }); + + const endpoint = createEndpoint(); + const result = await service.sendNotification( + endpoint, + { + title: 'Custom Title', + body: 'Custom body message', + }, + createContext() + ); + + expect(result.success).toBe(true); + const options = mockFetch.mock.calls[0][1]; + expect(options.headers['Title']).toBe('Custom Title'); + expect(options.body).toBe('Custom body message'); + }); + + it('should send notification with tags and emoji', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + }); + + const endpoint = createEndpoint(); + const result = await service.sendNotification( + endpoint, + { + tags: 'warning,skull', + emoji: 'tada', + }, + createContext() + ); + + expect(result.success).toBe(true); + const options = mockFetch.mock.calls[0][1]; + expect(options.headers['Tags']).toBe('tada,warning,skull'); + }); + + it('should send notification with priority', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + }); + + const endpoint = createEndpoint(); + const result = await service.sendNotification(endpoint, { priority: 5 }, createContext()); + + expect(result.success).toBe(true); + const options = mockFetch.mock.calls[0][1]; + expect(options.headers['Priority']).toBe('5'); + }); + + it('should send notification with click URL', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + }); + + const endpoint = createEndpoint(); + const result = await service.sendNotification( + endpoint, + { clickUrl: 'https://example.com/feature/123' }, + createContext() + ); + + expect(result.success).toBe(true); + const options = mockFetch.mock.calls[0][1]; + expect(options.headers['Click']).toBe('https://example.com/feature/123'); + }); + + it('should use endpoint default tags and emoji when not specified', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + }); + + const endpoint = createEndpoint({ + defaultTags: 'default-tag', + defaultEmoji: 'rocket', + }); + const result = await service.sendNotification(endpoint, {}, createContext()); + + expect(result.success).toBe(true); + const options = mockFetch.mock.calls[0][1]; + expect(options.headers['Tags']).toBe('rocket,default-tag'); + }); + + it('should use endpoint default click URL when not specified', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + }); + + const endpoint = createEndpoint({ + defaultClickUrl: 'https://default.example.com', + }); + const result = await service.sendNotification(endpoint, {}, createContext()); + + expect(result.success).toBe(true); + const options = mockFetch.mock.calls[0][1]; + expect(options.headers['Click']).toBe('https://default.example.com'); + }); + + it('should send notification with basic authentication', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + }); + + const endpoint = createEndpoint({ + authType: 'basic', + username: 'testuser', + password: 'testpass', + }); + const result = await service.sendNotification(endpoint, {}, createContext()); + + expect(result.success).toBe(true); + const options = mockFetch.mock.calls[0][1]; + // Basic auth should be base64 encoded + const expectedAuth = Buffer.from('testuser:testpass').toString('base64'); + expect(options.headers['Authorization']).toBe(`Basic ${expectedAuth}`); + }); + + it('should send notification with token authentication', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + }); + + const endpoint = createEndpoint({ + authType: 'token', + token: 'tk_test_token_123', + }); + const result = await service.sendNotification(endpoint, {}, createContext()); + + expect(result.success).toBe(true); + const options = mockFetch.mock.calls[0][1]; + expect(options.headers['Authorization']).toBe('Bearer tk_test_token_123'); + }); + + it('should return error on HTTP error response', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 403, + text: () => Promise.resolve('Forbidden - invalid token'), + }); + + const endpoint = createEndpoint(); + const result = await service.sendNotification(endpoint, {}, createContext()); + + expect(result.success).toBe(false); + expect(result.error).toContain('403'); + expect(result.error).toContain('Forbidden'); + }); + + it('should return error on timeout', async () => { + mockFetch.mockImplementationOnce(() => { + const error = new Error('Aborted'); + error.name = 'AbortError'; + throw error; + }); + + const endpoint = createEndpoint(); + const result = await service.sendNotification(endpoint, {}, createContext()); + + expect(result.success).toBe(false); + expect(result.error).toBe('Request timed out'); + }); + + it('should return error on network error', async () => { + mockFetch.mockRejectedValueOnce(new Error('Network error')); + + const endpoint = createEndpoint(); + const result = await service.sendNotification(endpoint, {}, createContext()); + + expect(result.success).toBe(false); + expect(result.error).toBe('Network error'); + }); + + it('should handle server URL with trailing slash', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + }); + + const endpoint = createEndpoint({ serverUrl: 'https://ntfy.sh/' }); + await service.sendNotification(endpoint, {}, createContext()); + + const url = mockFetch.mock.calls[0][0]; + expect(url).toBe('https://ntfy.sh/test-topic'); + }); + + it('should URL encode the topic', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + }); + + const endpoint = createEndpoint({ topic: 'test/topic#special' }); + await service.sendNotification(endpoint, {}, createContext()); + + const url = mockFetch.mock.calls[0][0]; + expect(url).toContain('test%2Ftopic%23special'); + }); + }); + + describe('variable substitution', () => { + it('should substitute {{featureId}} in title', async () => { + mockFetch.mockResolvedValueOnce({ ok: true, status: 200 }); + + const endpoint = createEndpoint(); + await service.sendNotification( + endpoint, + { title: 'Feature {{featureId}} completed' }, + createContext() + ); + + const options = mockFetch.mock.calls[0][1]; + expect(options.headers['Title']).toBe('Feature feat-123 completed'); + }); + + it('should substitute {{featureName}} in body', async () => { + mockFetch.mockResolvedValueOnce({ ok: true, status: 200 }); + + const endpoint = createEndpoint(); + await service.sendNotification( + endpoint, + { body: 'The feature "{{featureName}}" is done!' }, + createContext() + ); + + const options = mockFetch.mock.calls[0][1]; + expect(options.body).toBe('The feature "Test Feature" is done!'); + }); + + it('should substitute {{projectName}} in title', async () => { + mockFetch.mockResolvedValueOnce({ ok: true, status: 200 }); + + const endpoint = createEndpoint(); + await service.sendNotification( + endpoint, + { title: '[{{projectName}}] Event: {{eventType}}' }, + createContext() + ); + + const options = mockFetch.mock.calls[0][1]; + expect(options.headers['Title']).toBe('[test-project] Event: feature_success'); + }); + + it('should substitute {{timestamp}} in body', async () => { + mockFetch.mockResolvedValueOnce({ ok: true, status: 200 }); + + const endpoint = createEndpoint(); + await service.sendNotification( + endpoint, + { body: 'Completed at: {{timestamp}}' }, + createContext() + ); + + const options = mockFetch.mock.calls[0][1]; + expect(options.body).toBe('Completed at: 2024-01-15T10:30:00.000Z'); + }); + + it('should substitute {{error}} in body for error events', async () => { + mockFetch.mockResolvedValueOnce({ ok: true, status: 200 }); + + const endpoint = createEndpoint(); + const context = { + ...createContext(), + eventType: 'feature_error', + error: 'Something went wrong', + }; + await service.sendNotification(endpoint, { title: 'Error: {{error}}' }, context); + + const options = mockFetch.mock.calls[0][1]; + expect(options.headers['Title']).toBe('Error: Something went wrong'); + }); + + it('should substitute multiple variables', async () => { + mockFetch.mockResolvedValueOnce({ ok: true, status: 200 }); + + const endpoint = createEndpoint(); + await service.sendNotification( + endpoint, + { + title: '[{{projectName}}] {{featureName}}', + body: 'Feature {{featureId}} completed at {{timestamp}}', + }, + createContext() + ); + + const options = mockFetch.mock.calls[0][1]; + expect(options.headers['Title']).toBe('[test-project] Test Feature'); + expect(options.body).toBe('Feature feat-123 completed at 2024-01-15T10:30:00.000Z'); + }); + + it('should replace unknown variables with empty string', async () => { + mockFetch.mockResolvedValueOnce({ ok: true, status: 200 }); + + const endpoint = createEndpoint(); + await service.sendNotification( + endpoint, + { title: 'Value: {{unknownVariable}}' }, + createContext() + ); + + const options = mockFetch.mock.calls[0][1]; + expect(options.headers['Title']).toBe('Value: '); + }); + }); + + describe('default title generation', () => { + it('should generate title with feature name for feature_success', async () => { + mockFetch.mockResolvedValueOnce({ ok: true, status: 200 }); + + const endpoint = createEndpoint(); + await service.sendNotification(endpoint, {}, createContext()); + + const options = mockFetch.mock.calls[0][1]; + expect(options.headers['Title']).toBe('Feature Completed: Test Feature'); + }); + + it('should generate title without feature name when missing', async () => { + mockFetch.mockResolvedValueOnce({ ok: true, status: 200 }); + + const endpoint = createEndpoint(); + const context = { ...createContext(), featureName: undefined }; + await service.sendNotification(endpoint, {}, context); + + const options = mockFetch.mock.calls[0][1]; + expect(options.headers['Title']).toBe('Feature Completed'); + }); + + it('should generate correct title for feature_created', async () => { + mockFetch.mockResolvedValueOnce({ ok: true, status: 200 }); + + const endpoint = createEndpoint(); + const context = { ...createContext(), eventType: 'feature_created' }; + await service.sendNotification(endpoint, {}, context); + + const options = mockFetch.mock.calls[0][1]; + expect(options.headers['Title']).toBe('Feature Created: Test Feature'); + }); + + it('should generate correct title for feature_error', async () => { + mockFetch.mockResolvedValueOnce({ ok: true, status: 200 }); + + const endpoint = createEndpoint(); + const context = { ...createContext(), eventType: 'feature_error' }; + await service.sendNotification(endpoint, {}, context); + + const options = mockFetch.mock.calls[0][1]; + expect(options.headers['Title']).toBe('Feature Failed: Test Feature'); + }); + + it('should generate correct title for auto_mode_complete', async () => { + mockFetch.mockResolvedValueOnce({ ok: true, status: 200 }); + + const endpoint = createEndpoint(); + const context = { + ...createContext(), + eventType: 'auto_mode_complete', + featureName: undefined, + }; + await service.sendNotification(endpoint, {}, context); + + const options = mockFetch.mock.calls[0][1]; + expect(options.headers['Title']).toBe('Auto Mode Complete'); + }); + + it('should generate correct title for auto_mode_error', async () => { + mockFetch.mockResolvedValueOnce({ ok: true, status: 200 }); + + const endpoint = createEndpoint(); + const context = { ...createContext(), eventType: 'auto_mode_error', featureName: undefined }; + await service.sendNotification(endpoint, {}, context); + + const options = mockFetch.mock.calls[0][1]; + expect(options.headers['Title']).toBe('Auto Mode Error'); + }); + }); + + describe('default body generation', () => { + it('should generate body with feature info', async () => { + mockFetch.mockResolvedValueOnce({ ok: true, status: 200 }); + + const endpoint = createEndpoint(); + await service.sendNotification(endpoint, {}, createContext()); + + const options = mockFetch.mock.calls[0][1]; + expect(options.body).toContain('Feature: Test Feature'); + expect(options.body).toContain('ID: feat-123'); + expect(options.body).toContain('Project: test-project'); + expect(options.body).toContain('Time: 2024-01-15T10:30:00.000Z'); + }); + + it('should include error in body for error events', async () => { + mockFetch.mockResolvedValueOnce({ ok: true, status: 200 }); + + const endpoint = createEndpoint(); + const context = { + ...createContext(), + eventType: 'feature_error', + error: 'Build failed', + }; + await service.sendNotification(endpoint, {}, context); + + const options = mockFetch.mock.calls[0][1]; + expect(options.body).toContain('Error: Build failed'); + }); + }); + + describe('emoji and tags handling', () => { + it('should handle emoji shortcode with colons', async () => { + mockFetch.mockResolvedValueOnce({ ok: true, status: 200 }); + + const endpoint = createEndpoint(); + await service.sendNotification(endpoint, { emoji: ':tada:' }, createContext()); + + const options = mockFetch.mock.calls[0][1]; + expect(options.headers['Tags']).toBe('tada'); + }); + + it('should handle emoji without colons', async () => { + mockFetch.mockResolvedValueOnce({ ok: true, status: 200 }); + + const endpoint = createEndpoint(); + await service.sendNotification(endpoint, { emoji: 'warning' }, createContext()); + + const options = mockFetch.mock.calls[0][1]; + expect(options.headers['Tags']).toBe('warning'); + }); + + it('should combine emoji and tags correctly', async () => { + mockFetch.mockResolvedValueOnce({ ok: true, status: 200 }); + + const endpoint = createEndpoint(); + await service.sendNotification( + endpoint, + { emoji: 'rotating_light', tags: 'urgent,alert' }, + createContext() + ); + + const options = mockFetch.mock.calls[0][1]; + // Emoji comes first, then tags + expect(options.headers['Tags']).toBe('rotating_light,urgent,alert'); + }); + + it('should ignore emoji with spaces', async () => { + mockFetch.mockResolvedValueOnce({ ok: true, status: 200 }); + + const endpoint = createEndpoint(); + await service.sendNotification( + endpoint, + { emoji: 'multi word emoji', tags: 'test' }, + createContext() + ); + + const options = mockFetch.mock.calls[0][1]; + expect(options.headers['Tags']).toBe('test'); + }); + }); +}); diff --git a/apps/server/tests/unit/services/pipeline-orchestrator-prompts.test.ts b/apps/server/tests/unit/services/pipeline-orchestrator-prompts.test.ts new file mode 100644 index 000000000..ba847bd09 --- /dev/null +++ b/apps/server/tests/unit/services/pipeline-orchestrator-prompts.test.ts @@ -0,0 +1,57 @@ +import { describe, it, expect, vi } from 'vitest'; +import { PipelineOrchestrator } from '../../../src/services/pipeline-orchestrator.js'; +import type { Feature } from '@automaker/types'; + +describe('PipelineOrchestrator Prompts', () => { + const mockFeature: Feature = { + id: 'feature-123', + title: 'Test Feature', + description: 'A test feature', + status: 'in_progress', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + tasks: [], + }; + + const mockBuildFeaturePrompt = (feature: Feature) => `Feature: ${feature.title}`; + + it('should include mandatory summary requirement in pipeline step prompt', () => { + const orchestrator = new PipelineOrchestrator( + null as any, // eventBus + null as any, // featureStateManager + null as any, // agentExecutor + null as any, // testRunnerService + null as any, // worktreeResolver + null as any, // concurrencyManager + null as any, // settingsService + null as any, // updateFeatureStatusFn + null as any, // loadContextFilesFn + mockBuildFeaturePrompt, + null as any, // executeFeatureFn + null as any // runAgentFn + ); + + const step = { + id: 'step1', + name: 'Code Review', + instructions: 'Review the code for quality.', + }; + + const prompt = orchestrator.buildPipelineStepPrompt( + step as any, + mockFeature, + 'Previous work context', + { implementationInstructions: '', playwrightVerificationInstructions: '' } + ); + + expect(prompt).toContain('## Pipeline Step: Code Review'); + expect(prompt).toContain('Review the code for quality.'); + expect(prompt).toContain( + '**CRITICAL: After completing the instructions, you MUST output a summary using this EXACT format:**' + ); + expect(prompt).toContain(''); + expect(prompt).toContain('## Summary: Code Review'); + expect(prompt).toContain(''); + expect(prompt).toContain('The and tags MUST be on their own lines.'); + }); +}); diff --git a/apps/server/tests/unit/services/pipeline-orchestrator-provider-id.test.ts b/apps/server/tests/unit/services/pipeline-orchestrator-provider-id.test.ts new file mode 100644 index 000000000..58822100d --- /dev/null +++ b/apps/server/tests/unit/services/pipeline-orchestrator-provider-id.test.ts @@ -0,0 +1,356 @@ +/** + * Tests for providerId passthrough in PipelineOrchestrator + * Verifies that feature.providerId is forwarded to runAgentFn in both + * executePipeline (step execution) and executeTestStep (test fix) contexts. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { Feature, PipelineStep } from '@automaker/types'; +import { + PipelineOrchestrator, + type PipelineContext, + type UpdateFeatureStatusFn, + type BuildFeaturePromptFn, + type ExecuteFeatureFn, + type RunAgentFn, +} from '../../../src/services/pipeline-orchestrator.js'; +import type { TypedEventBus } from '../../../src/services/typed-event-bus.js'; +import type { FeatureStateManager } from '../../../src/services/feature-state-manager.js'; +import type { AgentExecutor } from '../../../src/services/agent-executor.js'; +import type { WorktreeResolver } from '../../../src/services/worktree-resolver.js'; +import type { SettingsService } from '../../../src/services/settings-service.js'; +import type { ConcurrencyManager } from '../../../src/services/concurrency-manager.js'; +import type { TestRunnerService } from '../../../src/services/test-runner-service.js'; +import * as secureFs from '../../../src/lib/secure-fs.js'; +import { getFeatureDir } from '@automaker/platform'; +import { + getPromptCustomization, + getAutoLoadClaudeMdSetting, + filterClaudeMdFromContext, +} from '../../../src/lib/settings-helpers.js'; + +// Mock pipelineService +vi.mock('../../../src/services/pipeline-service.js', () => ({ + pipelineService: { + isPipelineStatus: vi.fn(), + getStepIdFromStatus: vi.fn(), + getPipelineConfig: vi.fn(), + getNextStatus: vi.fn(), + }, +})); + +// Mock merge-service +vi.mock('../../../src/services/merge-service.js', () => ({ + performMerge: vi.fn().mockResolvedValue({ success: true }), +})); + +// Mock secureFs +vi.mock('../../../src/lib/secure-fs.js', () => ({ + readFile: vi.fn(), + access: vi.fn(), +})); + +// Mock settings helpers +vi.mock('../../../src/lib/settings-helpers.js', () => ({ + getPromptCustomization: vi.fn().mockResolvedValue({ + taskExecution: { + implementationInstructions: 'test instructions', + playwrightVerificationInstructions: 'test playwright', + }, + }), + getAutoLoadClaudeMdSetting: vi.fn().mockResolvedValue(true), + getUseClaudeCodeSystemPromptSetting: vi.fn().mockResolvedValue(true), + filterClaudeMdFromContext: vi.fn().mockReturnValue('context prompt'), +})); + +// Mock validateWorkingDirectory +vi.mock('../../../src/lib/sdk-options.js', () => ({ + validateWorkingDirectory: vi.fn(), +})); + +// Mock platform +vi.mock('@automaker/platform', () => ({ + getFeatureDir: vi + .fn() + .mockImplementation( + (projectPath: string, featureId: string) => `${projectPath}/.automaker/features/${featureId}` + ), +})); + +// Mock model-resolver +vi.mock('@automaker/model-resolver', () => ({ + resolveModelString: vi.fn().mockReturnValue('claude-sonnet-4'), + DEFAULT_MODELS: { claude: 'claude-sonnet-4' }, +})); + +describe('PipelineOrchestrator - providerId passthrough', () => { + let mockEventBus: TypedEventBus; + let mockFeatureStateManager: FeatureStateManager; + let mockAgentExecutor: AgentExecutor; + let mockTestRunnerService: TestRunnerService; + let mockWorktreeResolver: WorktreeResolver; + let mockConcurrencyManager: ConcurrencyManager; + let mockUpdateFeatureStatusFn: UpdateFeatureStatusFn; + let mockLoadContextFilesFn: vi.Mock; + let mockBuildFeaturePromptFn: BuildFeaturePromptFn; + let mockExecuteFeatureFn: ExecuteFeatureFn; + let mockRunAgentFn: RunAgentFn; + let orchestrator: PipelineOrchestrator; + + const testSteps: PipelineStep[] = [ + { + id: 'step-1', + name: 'Step 1', + order: 1, + instructions: 'Do step 1', + colorClass: 'blue', + createdAt: '', + updatedAt: '', + }, + ]; + + const createFeatureWithProvider = (providerId?: string): Feature => ({ + id: 'feature-1', + title: 'Test Feature', + category: 'test', + description: 'Test description', + status: 'pipeline_step-1', + branchName: 'feature/test-1', + providerId, + }); + + beforeEach(() => { + vi.clearAllMocks(); + + mockEventBus = { + emitAutoModeEvent: vi.fn(), + getUnderlyingEmitter: vi.fn().mockReturnValue({}), + } as unknown as TypedEventBus; + + mockFeatureStateManager = { + updateFeatureStatus: vi.fn().mockResolvedValue(undefined), + loadFeature: vi.fn().mockResolvedValue(createFeatureWithProvider()), + } as unknown as FeatureStateManager; + + mockAgentExecutor = { + execute: vi.fn().mockResolvedValue({ success: true }), + } as unknown as AgentExecutor; + + mockTestRunnerService = { + startTests: vi + .fn() + .mockResolvedValue({ success: true, result: { sessionId: 'test-session-1' } }), + getSession: vi.fn().mockReturnValue({ + status: 'passed', + exitCode: 0, + startedAt: new Date(), + finishedAt: new Date(), + }), + getSessionOutput: vi + .fn() + .mockReturnValue({ success: true, result: { output: 'All tests passed' } }), + } as unknown as TestRunnerService; + + mockWorktreeResolver = { + findWorktreeForBranch: vi.fn().mockResolvedValue('/test/worktree'), + getCurrentBranch: vi.fn().mockResolvedValue('main'), + } as unknown as WorktreeResolver; + + mockConcurrencyManager = { + acquire: vi.fn().mockImplementation(({ featureId, isAutoMode }) => ({ + featureId, + projectPath: '/test/project', + abortController: new AbortController(), + branchName: null, + worktreePath: null, + isAutoMode: isAutoMode ?? false, + })), + release: vi.fn(), + getRunningFeature: vi.fn().mockReturnValue(undefined), + } as unknown as ConcurrencyManager; + + mockUpdateFeatureStatusFn = vi.fn().mockResolvedValue(undefined); + mockLoadContextFilesFn = vi.fn().mockResolvedValue({ contextPrompt: 'test context' }); + mockBuildFeaturePromptFn = vi.fn().mockReturnValue('Feature prompt content'); + mockExecuteFeatureFn = vi.fn().mockResolvedValue(undefined); + mockRunAgentFn = vi.fn().mockResolvedValue(undefined); + + vi.mocked(secureFs.readFile).mockResolvedValue('Previous context'); + vi.mocked(secureFs.access).mockResolvedValue(undefined); + vi.mocked(getFeatureDir).mockImplementation( + (projectPath: string, featureId: string) => `${projectPath}/.automaker/features/${featureId}` + ); + vi.mocked(getPromptCustomization).mockResolvedValue({ + taskExecution: { + implementationInstructions: 'test instructions', + playwrightVerificationInstructions: 'test playwright', + }, + } as any); + vi.mocked(getAutoLoadClaudeMdSetting).mockResolvedValue(true); + vi.mocked(filterClaudeMdFromContext).mockReturnValue('context prompt'); + + orchestrator = new PipelineOrchestrator( + mockEventBus, + mockFeatureStateManager, + mockAgentExecutor, + mockTestRunnerService, + mockWorktreeResolver, + mockConcurrencyManager, + null, + mockUpdateFeatureStatusFn, + mockLoadContextFilesFn, + mockBuildFeaturePromptFn, + mockExecuteFeatureFn, + mockRunAgentFn + ); + }); + + describe('executePipeline', () => { + it('should pass providerId to runAgentFn options when feature has providerId', async () => { + const feature = createFeatureWithProvider('moonshot-ai'); + const context: PipelineContext = { + projectPath: '/test/project', + featureId: 'feature-1', + feature, + steps: testSteps, + workDir: '/test/project', + worktreePath: null, + branchName: 'feature/test-1', + abortController: new AbortController(), + autoLoadClaudeMd: true, + testAttempts: 0, + maxTestAttempts: 5, + }; + + await orchestrator.executePipeline(context); + + expect(mockRunAgentFn).toHaveBeenCalledTimes(1); + const options = mockRunAgentFn.mock.calls[0][7]; + expect(options).toHaveProperty('providerId', 'moonshot-ai'); + }); + + it('should pass undefined providerId when feature has no providerId', async () => { + const feature = createFeatureWithProvider(undefined); + const context: PipelineContext = { + projectPath: '/test/project', + featureId: 'feature-1', + feature, + steps: testSteps, + workDir: '/test/project', + worktreePath: null, + branchName: 'feature/test-1', + abortController: new AbortController(), + autoLoadClaudeMd: true, + testAttempts: 0, + maxTestAttempts: 5, + }; + + await orchestrator.executePipeline(context); + + expect(mockRunAgentFn).toHaveBeenCalledTimes(1); + const options = mockRunAgentFn.mock.calls[0][7]; + expect(options).toHaveProperty('providerId', undefined); + }); + + it('should pass status alongside providerId in options', async () => { + const feature = createFeatureWithProvider('zhipu'); + const context: PipelineContext = { + projectPath: '/test/project', + featureId: 'feature-1', + feature, + steps: testSteps, + workDir: '/test/project', + worktreePath: null, + branchName: 'feature/test-1', + abortController: new AbortController(), + autoLoadClaudeMd: true, + testAttempts: 0, + maxTestAttempts: 5, + }; + + await orchestrator.executePipeline(context); + + const options = mockRunAgentFn.mock.calls[0][7]; + expect(options).toHaveProperty('providerId', 'zhipu'); + expect(options).toHaveProperty('status'); + }); + }); + + describe('executeTestStep', () => { + it('should pass providerId in test fix agent options when tests fail', async () => { + vi.mocked(mockTestRunnerService.getSession) + .mockReturnValueOnce({ + status: 'failed', + exitCode: 1, + startedAt: new Date(), + finishedAt: new Date(), + } as never) + .mockReturnValueOnce({ + status: 'passed', + exitCode: 0, + startedAt: new Date(), + finishedAt: new Date(), + } as never); + + const feature = createFeatureWithProvider('custom-provider'); + const context: PipelineContext = { + projectPath: '/test/project', + featureId: 'feature-1', + feature, + steps: testSteps, + workDir: '/test/project', + worktreePath: null, + branchName: 'feature/test-1', + abortController: new AbortController(), + autoLoadClaudeMd: true, + testAttempts: 0, + maxTestAttempts: 5, + }; + + await orchestrator.executeTestStep(context, 'npm test'); + + // The fix agent should receive providerId + expect(mockRunAgentFn).toHaveBeenCalledTimes(1); + const options = mockRunAgentFn.mock.calls[0][7]; + expect(options).toHaveProperty('providerId', 'custom-provider'); + }, 15000); + + it('should pass thinkingLevel in test fix agent options', async () => { + vi.mocked(mockTestRunnerService.getSession) + .mockReturnValueOnce({ + status: 'failed', + exitCode: 1, + startedAt: new Date(), + finishedAt: new Date(), + } as never) + .mockReturnValueOnce({ + status: 'passed', + exitCode: 0, + startedAt: new Date(), + finishedAt: new Date(), + } as never); + + const feature = createFeatureWithProvider('moonshot-ai'); + feature.thinkingLevel = 'high'; + const context: PipelineContext = { + projectPath: '/test/project', + featureId: 'feature-1', + feature, + steps: testSteps, + workDir: '/test/project', + worktreePath: null, + branchName: 'feature/test-1', + abortController: new AbortController(), + autoLoadClaudeMd: true, + testAttempts: 0, + maxTestAttempts: 5, + }; + + await orchestrator.executeTestStep(context, 'npm test'); + + const options = mockRunAgentFn.mock.calls[0][7]; + expect(options).toHaveProperty('thinkingLevel', 'high'); + expect(options).toHaveProperty('providerId', 'moonshot-ai'); + }, 15000); + }); +}); diff --git a/apps/server/tests/unit/services/pipeline-orchestrator-status-provider.test.ts b/apps/server/tests/unit/services/pipeline-orchestrator-status-provider.test.ts new file mode 100644 index 000000000..880d8debd --- /dev/null +++ b/apps/server/tests/unit/services/pipeline-orchestrator-status-provider.test.ts @@ -0,0 +1,302 @@ +/** + * Tests for status + providerId coexistence in PipelineOrchestrator options. + * + * During rebase onto upstream/v1.0.0rc, a merge conflict arose where + * upstream added `status: currentStatus` and the incoming branch added + * `providerId: feature.providerId`. The conflict resolution kept BOTH fields. + * + * This test validates that both fields coexist correctly in the options + * object passed to runAgentFn in both executePipeline and executeTestStep. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { Feature, PipelineStep } from '@automaker/types'; +import { + PipelineOrchestrator, + type PipelineContext, + type UpdateFeatureStatusFn, + type BuildFeaturePromptFn, + type ExecuteFeatureFn, + type RunAgentFn, +} from '../../../src/services/pipeline-orchestrator.js'; +import type { TypedEventBus } from '../../../src/services/typed-event-bus.js'; +import type { FeatureStateManager } from '../../../src/services/feature-state-manager.js'; +import type { AgentExecutor } from '../../../src/services/agent-executor.js'; +import type { WorktreeResolver } from '../../../src/services/worktree-resolver.js'; +import type { ConcurrencyManager } from '../../../src/services/concurrency-manager.js'; +import type { TestRunnerService } from '../../../src/services/test-runner-service.js'; +import * as secureFs from '../../../src/lib/secure-fs.js'; +import { getFeatureDir } from '@automaker/platform'; +import { + getPromptCustomization, + getAutoLoadClaudeMdSetting, + filterClaudeMdFromContext, +} from '../../../src/lib/settings-helpers.js'; + +vi.mock('../../../src/services/pipeline-service.js', () => ({ + pipelineService: { + isPipelineStatus: vi.fn(), + getStepIdFromStatus: vi.fn(), + getPipelineConfig: vi.fn(), + getNextStatus: vi.fn(), + }, +})); + +vi.mock('../../../src/services/merge-service.js', () => ({ + performMerge: vi.fn().mockResolvedValue({ success: true }), +})); + +vi.mock('../../../src/lib/secure-fs.js', () => ({ + readFile: vi.fn(), + access: vi.fn(), +})); + +vi.mock('../../../src/lib/settings-helpers.js', () => ({ + getPromptCustomization: vi.fn().mockResolvedValue({ + taskExecution: { + implementationInstructions: 'test instructions', + playwrightVerificationInstructions: 'test playwright', + }, + }), + getAutoLoadClaudeMdSetting: vi.fn().mockResolvedValue(true), + getUseClaudeCodeSystemPromptSetting: vi.fn().mockResolvedValue(true), + filterClaudeMdFromContext: vi.fn().mockReturnValue('context prompt'), +})); + +vi.mock('../../../src/lib/sdk-options.js', () => ({ + validateWorkingDirectory: vi.fn(), +})); + +vi.mock('@automaker/platform', () => ({ + getFeatureDir: vi + .fn() + .mockImplementation( + (projectPath: string, featureId: string) => `${projectPath}/.automaker/features/${featureId}` + ), +})); + +vi.mock('@automaker/model-resolver', () => ({ + resolveModelString: vi.fn().mockReturnValue('claude-sonnet-4'), + DEFAULT_MODELS: { claude: 'claude-sonnet-4' }, +})); + +describe('PipelineOrchestrator - status and providerId coexistence', () => { + let mockRunAgentFn: RunAgentFn; + let orchestrator: PipelineOrchestrator; + + const testSteps: PipelineStep[] = [ + { + id: 'implement', + name: 'Implement Feature', + order: 1, + instructions: 'Implement the feature', + colorClass: 'blue', + createdAt: '', + updatedAt: '', + }, + ]; + + const createFeature = (overrides: Partial = {}): Feature => ({ + id: 'feature-1', + title: 'Test Feature', + category: 'test', + description: 'Test description', + status: 'pipeline_implement', + branchName: 'feature/test-1', + providerId: 'moonshot-ai', + thinkingLevel: 'medium', + reasoningEffort: 'high', + ...overrides, + }); + + const createContext = (feature: Feature): PipelineContext => ({ + projectPath: '/test/project', + featureId: feature.id, + feature, + steps: testSteps, + workDir: '/test/project', + worktreePath: null, + branchName: feature.branchName ?? 'main', + abortController: new AbortController(), + autoLoadClaudeMd: true, + testAttempts: 0, + maxTestAttempts: 5, + }); + + beforeEach(() => { + vi.clearAllMocks(); + mockRunAgentFn = vi.fn().mockResolvedValue(undefined); + + vi.mocked(secureFs.readFile).mockResolvedValue('Previous context'); + vi.mocked(secureFs.access).mockResolvedValue(undefined); + vi.mocked(getFeatureDir).mockImplementation( + (projectPath: string, featureId: string) => `${projectPath}/.automaker/features/${featureId}` + ); + vi.mocked(getPromptCustomization).mockResolvedValue({ + taskExecution: { + implementationInstructions: 'test instructions', + playwrightVerificationInstructions: 'test playwright', + }, + } as any); + vi.mocked(getAutoLoadClaudeMdSetting).mockResolvedValue(true); + vi.mocked(filterClaudeMdFromContext).mockReturnValue('context prompt'); + + const mockEventBus = { + emitAutoModeEvent: vi.fn(), + getUnderlyingEmitter: vi.fn().mockReturnValue({}), + } as unknown as TypedEventBus; + + const mockFeatureStateManager = { + updateFeatureStatus: vi.fn().mockResolvedValue(undefined), + loadFeature: vi.fn().mockResolvedValue(createFeature()), + } as unknown as FeatureStateManager; + + const mockTestRunnerService = { + startTests: vi + .fn() + .mockResolvedValue({ success: true, result: { sessionId: 'test-session-1' } }), + getSession: vi.fn().mockReturnValue({ + status: 'passed', + exitCode: 0, + startedAt: new Date(), + finishedAt: new Date(), + }), + getSessionOutput: vi + .fn() + .mockReturnValue({ success: true, result: { output: 'All tests passed' } }), + } as unknown as TestRunnerService; + + orchestrator = new PipelineOrchestrator( + mockEventBus, + mockFeatureStateManager, + {} as AgentExecutor, + mockTestRunnerService, + { + findWorktreeForBranch: vi.fn().mockResolvedValue('/test/worktree'), + getCurrentBranch: vi.fn().mockResolvedValue('main'), + } as unknown as WorktreeResolver, + { + acquire: vi.fn().mockImplementation(({ featureId }) => ({ + featureId, + projectPath: '/test/project', + abortController: new AbortController(), + branchName: null, + worktreePath: null, + isAutoMode: false, + })), + release: vi.fn(), + getRunningFeature: vi.fn().mockReturnValue(undefined), + } as unknown as ConcurrencyManager, + null, + vi.fn().mockResolvedValue(undefined), + vi.fn().mockResolvedValue({ contextPrompt: 'test context' }), + vi.fn().mockReturnValue('Feature prompt content'), + vi.fn().mockResolvedValue(undefined), + mockRunAgentFn + ); + }); + + describe('executePipeline - options object', () => { + it('should pass both status and providerId in options', async () => { + const feature = createFeature({ providerId: 'moonshot-ai' }); + const context = createContext(feature); + + await orchestrator.executePipeline(context); + + expect(mockRunAgentFn).toHaveBeenCalledTimes(1); + const options = mockRunAgentFn.mock.calls[0][7]; + expect(options).toHaveProperty('status', 'pipeline_implement'); + expect(options).toHaveProperty('providerId', 'moonshot-ai'); + }); + + it('should pass status even when providerId is undefined', async () => { + const feature = createFeature({ providerId: undefined }); + const context = createContext(feature); + + await orchestrator.executePipeline(context); + + const options = mockRunAgentFn.mock.calls[0][7]; + expect(options).toHaveProperty('status', 'pipeline_implement'); + expect(options).toHaveProperty('providerId', undefined); + }); + + it('should pass thinkingLevel and reasoningEffort alongside status and providerId', async () => { + const feature = createFeature({ + providerId: 'zhipu', + thinkingLevel: 'high', + reasoningEffort: 'medium', + }); + const context = createContext(feature); + + await orchestrator.executePipeline(context); + + const options = mockRunAgentFn.mock.calls[0][7]; + expect(options).toHaveProperty('status', 'pipeline_implement'); + expect(options).toHaveProperty('providerId', 'zhipu'); + expect(options).toHaveProperty('thinkingLevel', 'high'); + expect(options).toHaveProperty('reasoningEffort', 'medium'); + }); + }); + + describe('executeTestStep - options object', () => { + it('should pass both status and providerId in test fix agent options', async () => { + const feature = createFeature({ + status: 'running', + providerId: 'custom-provider', + }); + const context = createContext(feature); + + const mockTestRunner = orchestrator['testRunnerService'] as any; + vi.mocked(mockTestRunner.getSession) + .mockReturnValueOnce({ + status: 'failed', + exitCode: 1, + startedAt: new Date(), + finishedAt: new Date(), + }) + .mockReturnValueOnce({ + status: 'passed', + exitCode: 0, + startedAt: new Date(), + finishedAt: new Date(), + }); + + await orchestrator.executeTestStep(context, 'npm test'); + + expect(mockRunAgentFn).toHaveBeenCalledTimes(1); + const options = mockRunAgentFn.mock.calls[0][7]; + expect(options).toHaveProperty('status', 'running'); + expect(options).toHaveProperty('providerId', 'custom-provider'); + }, 15000); + + it('should pass feature.status (not currentStatus) in test fix context', async () => { + const feature = createFeature({ + status: 'pipeline_test', + providerId: 'moonshot-ai', + }); + const context = createContext(feature); + + const mockTestRunner = orchestrator['testRunnerService'] as any; + vi.mocked(mockTestRunner.getSession) + .mockReturnValueOnce({ + status: 'failed', + exitCode: 1, + startedAt: new Date(), + finishedAt: new Date(), + }) + .mockReturnValueOnce({ + status: 'passed', + exitCode: 0, + startedAt: new Date(), + finishedAt: new Date(), + }); + + await orchestrator.executeTestStep(context, 'npm test'); + + const options = mockRunAgentFn.mock.calls[0][7]; + // In test fix context, status should come from context.feature.status + expect(options).toHaveProperty('status', 'pipeline_test'); + expect(options).toHaveProperty('providerId', 'moonshot-ai'); + }, 15000); + }); +}); diff --git a/apps/server/tests/unit/services/pipeline-summary-accumulation.test.ts b/apps/server/tests/unit/services/pipeline-summary-accumulation.test.ts new file mode 100644 index 000000000..23668a8be --- /dev/null +++ b/apps/server/tests/unit/services/pipeline-summary-accumulation.test.ts @@ -0,0 +1,598 @@ +/** + * Integration tests for pipeline summary accumulation across multiple steps. + * + * These tests verify the end-to-end behavior where: + * 1. Each pipeline step produces a summary via agent-executor → callbacks.saveFeatureSummary() + * 2. FeatureStateManager.saveFeatureSummary() accumulates summaries with step headers + * 3. The emitted auto_mode_summary event contains the full accumulated summary + * 4. The UI can use feature.summary (accumulated) instead of extractSummary() (last-only) + */ + +import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest'; +import { FeatureStateManager } from '@/services/feature-state-manager.js'; +import type { Feature } from '@automaker/types'; +import type { EventEmitter } from '@/lib/events.js'; +import type { FeatureLoader } from '@/services/feature-loader.js'; +import { atomicWriteJson, readJsonWithRecovery } from '@automaker/utils'; +import { getFeatureDir } from '@automaker/platform'; +import { pipelineService } from '@/services/pipeline-service.js'; + +// Mock dependencies +vi.mock('@/lib/secure-fs.js', () => ({ + readFile: vi.fn(), + readdir: vi.fn(), +})); + +vi.mock('@automaker/utils', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + atomicWriteJson: vi.fn(), + readJsonWithRecovery: vi.fn(), + logRecoveryWarning: vi.fn(), + }; +}); + +vi.mock('@automaker/platform', () => ({ + getFeatureDir: vi.fn(), + getFeaturesDir: vi.fn(), +})); + +vi.mock('@/services/notification-service.js', () => ({ + getNotificationService: vi.fn(() => ({ + createNotification: vi.fn(), + })), +})); + +vi.mock('@/services/pipeline-service.js', () => ({ + pipelineService: { + getStepIdFromStatus: vi.fn((status: string) => { + if (status.startsWith('pipeline_')) return status.replace('pipeline_', ''); + return null; + }), + getStep: vi.fn(), + }, +})); + +describe('Pipeline Summary Accumulation (Integration)', () => { + let manager: FeatureStateManager; + let mockEvents: EventEmitter; + + const baseFeature: Feature = { + id: 'pipeline-feature-1', + name: 'Pipeline Feature', + title: 'Pipeline Feature Title', + description: 'A feature going through pipeline steps', + status: 'pipeline_step1', + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + }; + + beforeEach(() => { + vi.clearAllMocks(); + + mockEvents = { + emit: vi.fn(), + subscribe: vi.fn(() => vi.fn()), + }; + + const mockFeatureLoader = { + syncFeatureToAppSpec: vi.fn(), + } as unknown as FeatureLoader; + + manager = new FeatureStateManager(mockEvents, mockFeatureLoader); + + (getFeatureDir as Mock).mockReturnValue('/project/.automaker/features/pipeline-feature-1'); + }); + + describe('multi-step pipeline summary accumulation', () => { + it('should accumulate summaries across three pipeline steps in chronological order', async () => { + // --- Step 1: Implementation --- + (pipelineService.getStep as Mock).mockResolvedValue({ name: 'Implementation', id: 'step1' }); + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: { ...baseFeature, status: 'pipeline_step1', summary: undefined }, + recovered: false, + source: 'main', + }); + + await manager.saveFeatureSummary( + '/project', + 'pipeline-feature-1', + '## Changes\n- Added auth module\n- Created user service' + ); + + const step1Feature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature; + expect(step1Feature.summary).toBe( + '### Implementation\n\n## Changes\n- Added auth module\n- Created user service' + ); + + // --- Step 2: Code Review --- + vi.clearAllMocks(); + (getFeatureDir as Mock).mockReturnValue('/project/.automaker/features/pipeline-feature-1'); + (pipelineService.getStep as Mock).mockResolvedValue({ name: 'Code Review', id: 'step2' }); + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: { ...baseFeature, status: 'pipeline_step2', summary: step1Feature.summary }, + recovered: false, + source: 'main', + }); + + await manager.saveFeatureSummary( + '/project', + 'pipeline-feature-1', + '## Review Findings\n- Style issues fixed\n- Added error handling' + ); + + const step2Feature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature; + + // --- Step 3: Testing --- + vi.clearAllMocks(); + (getFeatureDir as Mock).mockReturnValue('/project/.automaker/features/pipeline-feature-1'); + (pipelineService.getStep as Mock).mockResolvedValue({ name: 'Testing', id: 'step3' }); + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: { ...baseFeature, status: 'pipeline_step3', summary: step2Feature.summary }, + recovered: false, + source: 'main', + }); + + await manager.saveFeatureSummary( + '/project', + 'pipeline-feature-1', + '## Test Results\n- 42 tests pass\n- 98% coverage' + ); + + const finalFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature; + + // Verify the full accumulated summary has all three steps separated by --- + const expectedSummary = [ + '### Implementation', + '', + '## Changes', + '- Added auth module', + '- Created user service', + '', + '---', + '', + '### Code Review', + '', + '## Review Findings', + '- Style issues fixed', + '- Added error handling', + '', + '---', + '', + '### Testing', + '', + '## Test Results', + '- 42 tests pass', + '- 98% coverage', + ].join('\n'); + + expect(finalFeature.summary).toBe(expectedSummary); + }); + + it('should emit the full accumulated summary in auto_mode_summary event', async () => { + // Step 1 + (pipelineService.getStep as Mock).mockResolvedValue({ name: 'Implementation', id: 'step1' }); + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: { ...baseFeature, status: 'pipeline_step1', summary: undefined }, + recovered: false, + source: 'main', + }); + + await manager.saveFeatureSummary('/project', 'pipeline-feature-1', 'Step 1 output'); + + // Verify the event was emitted with correct data + expect(mockEvents.emit).toHaveBeenCalledWith('auto-mode:event', { + type: 'auto_mode_summary', + featureId: 'pipeline-feature-1', + projectPath: '/project', + summary: '### Implementation\n\nStep 1 output', + }); + + // Step 2 (with accumulated summary from step 1) + vi.clearAllMocks(); + (getFeatureDir as Mock).mockReturnValue('/project/.automaker/features/pipeline-feature-1'); + (pipelineService.getStep as Mock).mockResolvedValue({ name: 'Testing', id: 'step2' }); + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: { + ...baseFeature, + status: 'pipeline_step2', + summary: '### Implementation\n\nStep 1 output', + }, + recovered: false, + source: 'main', + }); + + await manager.saveFeatureSummary('/project', 'pipeline-feature-1', 'Step 2 output'); + + // The event should contain the FULL accumulated summary, not just step 2 + expect(mockEvents.emit).toHaveBeenCalledWith('auto-mode:event', { + type: 'auto_mode_summary', + featureId: 'pipeline-feature-1', + projectPath: '/project', + summary: '### Implementation\n\nStep 1 output\n\n---\n\n### Testing\n\nStep 2 output', + }); + }); + }); + + describe('edge cases in pipeline accumulation', () => { + it('should normalize a legacy implementation summary before appending pipeline output', async () => { + (pipelineService.getStep as Mock).mockResolvedValue({ name: 'Code Review', id: 'step2' }); + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: { + ...baseFeature, + status: 'pipeline_step2', + summary: 'Implemented authentication and settings updates.', + }, + recovered: false, + source: 'main', + }); + + await manager.saveFeatureSummary('/project', 'pipeline-feature-1', 'Reviewed and approved'); + + const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature; + expect(savedFeature.summary).toBe( + '### Implementation\n\nImplemented authentication and settings updates.\n\n---\n\n### Code Review\n\nReviewed and approved' + ); + }); + + it('should skip persistence when a pipeline step summary is empty', async () => { + const existingSummary = '### Step 1\n\nFirst step output'; + (pipelineService.getStep as Mock).mockResolvedValue({ name: 'Step 2', id: 'step2' }); + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: { ...baseFeature, status: 'pipeline_step2', summary: existingSummary }, + recovered: false, + source: 'main', + }); + + // Empty summary should be ignored to avoid persisting blank sections. + await manager.saveFeatureSummary('/project', 'pipeline-feature-1', ''); + + expect(atomicWriteJson).not.toHaveBeenCalled(); + expect(mockEvents.emit).not.toHaveBeenCalled(); + }); + + it('should handle pipeline step name lookup failure with fallback', async () => { + (pipelineService.getStepIdFromStatus as Mock).mockImplementation(() => { + throw new Error('Pipeline config not loaded'); + }); + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: { ...baseFeature, status: 'pipeline_code_review', summary: undefined }, + recovered: false, + source: 'main', + }); + + await manager.saveFeatureSummary('/project', 'pipeline-feature-1', 'Review output'); + + const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature; + // Fallback: capitalize words from status suffix + expect(savedFeature.summary).toBe('### Code Review\n\nReview output'); + }); + + it('should handle summary with special markdown characters in pipeline mode', async () => { + (pipelineService.getStep as Mock).mockResolvedValue({ name: 'Implementation', id: 'step1' }); + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: { ...baseFeature, status: 'pipeline_step1', summary: undefined }, + recovered: false, + source: 'main', + }); + + const markdownSummary = [ + '## Changes Made', + '- Fixed **critical bug** in `parser.ts`', + '- Added `validateInput()` function', + '', + '```typescript', + 'const x = 1;', + '```', + '', + '| Column | Value |', + '|--------|-------|', + '| Tests | Pass |', + ].join('\n'); + + await manager.saveFeatureSummary('/project', 'pipeline-feature-1', markdownSummary); + + const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature; + expect(savedFeature.summary).toBe(`### Implementation\n\n${markdownSummary}`); + // Verify markdown is preserved + expect(savedFeature.summary).toContain('```typescript'); + expect(savedFeature.summary).toContain('| Column | Value |'); + }); + + it('should correctly handle rapid sequential pipeline steps without data loss', async () => { + // Simulate 5 rapid pipeline steps + const stepConfigs = [ + { name: 'Planning', status: 'pipeline_step1', content: 'Plan created' }, + { name: 'Implementation', status: 'pipeline_step2', content: 'Code written' }, + { name: 'Code Review', status: 'pipeline_step3', content: 'Review complete' }, + { name: 'Testing', status: 'pipeline_step4', content: 'All tests pass' }, + { name: 'Refinement', status: 'pipeline_step5', content: 'Code polished' }, + ]; + + let currentSummary: string | undefined = undefined; + + for (const step of stepConfigs) { + vi.clearAllMocks(); + (getFeatureDir as Mock).mockReturnValue('/project/.automaker/features/pipeline-feature-1'); + (pipelineService.getStep as Mock).mockResolvedValue({ + name: step.name, + id: step.status.replace('pipeline_', ''), + }); + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: { ...baseFeature, status: step.status, summary: currentSummary }, + recovered: false, + source: 'main', + }); + + await manager.saveFeatureSummary('/project', 'pipeline-feature-1', step.content); + + currentSummary = ((atomicWriteJson as Mock).mock.calls[0][1] as Feature).summary; + } + + // Final summary should contain all 5 steps + expect(currentSummary).toContain('### Planning'); + expect(currentSummary).toContain('Plan created'); + expect(currentSummary).toContain('### Implementation'); + expect(currentSummary).toContain('Code written'); + expect(currentSummary).toContain('### Code Review'); + expect(currentSummary).toContain('Review complete'); + expect(currentSummary).toContain('### Testing'); + expect(currentSummary).toContain('All tests pass'); + expect(currentSummary).toContain('### Refinement'); + expect(currentSummary).toContain('Code polished'); + + // Verify there are exactly 4 separators (between 5 steps) + const separatorCount = (currentSummary!.match(/\n\n---\n\n/g) || []).length; + expect(separatorCount).toBe(4); + }); + }); + + describe('UI summary display logic', () => { + it('should emit accumulated summary that UI can display directly (no extractSummary needed)', async () => { + // This test verifies the UI can use feature.summary directly + // without needing to call extractSummary() which only returns the last entry + (pipelineService.getStep as Mock).mockResolvedValue({ name: 'Implementation', id: 'step1' }); + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: { ...baseFeature, status: 'pipeline_step1', summary: undefined }, + recovered: false, + source: 'main', + }); + + await manager.saveFeatureSummary('/project', 'pipeline-feature-1', 'First step'); + + const step1Summary = ((atomicWriteJson as Mock).mock.calls[0][1] as Feature).summary; + + // Step 2 + vi.clearAllMocks(); + (getFeatureDir as Mock).mockReturnValue('/project/.automaker/features/pipeline-feature-1'); + (pipelineService.getStep as Mock).mockResolvedValue({ name: 'Testing', id: 'step2' }); + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: { ...baseFeature, status: 'pipeline_step2', summary: step1Summary }, + recovered: false, + source: 'main', + }); + + await manager.saveFeatureSummary('/project', 'pipeline-feature-1', 'Second step'); + + const emittedEvent = (mockEvents.emit as Mock).mock.calls[0][1]; + const accumulatedSummary = emittedEvent.summary; + + // The accumulated summary should contain BOTH steps + expect(accumulatedSummary).toContain('### Implementation'); + expect(accumulatedSummary).toContain('First step'); + expect(accumulatedSummary).toContain('### Testing'); + expect(accumulatedSummary).toContain('Second step'); + }); + + it('should handle single-step pipeline (no accumulation needed)', async () => { + (pipelineService.getStep as Mock).mockResolvedValue({ name: 'Implementation', id: 'step1' }); + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: { ...baseFeature, status: 'pipeline_step1', summary: undefined }, + recovered: false, + source: 'main', + }); + + await manager.saveFeatureSummary('/project', 'pipeline-feature-1', 'Single step output'); + + const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature; + expect(savedFeature.summary).toBe('### Implementation\n\nSingle step output'); + + // No separator should be present for single step + expect(savedFeature.summary).not.toContain('---'); + }); + + it('should preserve chronological order of summaries', async () => { + // Step 1 + (pipelineService.getStep as Mock).mockResolvedValue({ name: 'Alpha', id: 'step1' }); + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: { ...baseFeature, status: 'pipeline_step1', summary: undefined }, + recovered: false, + source: 'main', + }); + await manager.saveFeatureSummary('/project', 'pipeline-feature-1', 'First'); + + const step1Summary = ((atomicWriteJson as Mock).mock.calls[0][1] as Feature).summary; + + // Step 2 + vi.clearAllMocks(); + (getFeatureDir as Mock).mockReturnValue('/project/.automaker/features/pipeline-feature-1'); + (pipelineService.getStep as Mock).mockResolvedValue({ name: 'Beta', id: 'step2' }); + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: { ...baseFeature, status: 'pipeline_step2', summary: step1Summary }, + recovered: false, + source: 'main', + }); + await manager.saveFeatureSummary('/project', 'pipeline-feature-1', 'Second'); + + const finalSummary = ((atomicWriteJson as Mock).mock.calls[0][1] as Feature).summary; + + // Verify order: Alpha should come before Beta + const alphaIndex = finalSummary!.indexOf('### Alpha'); + const betaIndex = finalSummary!.indexOf('### Beta'); + expect(alphaIndex).toBeLessThan(betaIndex); + }); + }); + + describe('non-pipeline features', () => { + it('should overwrite summary for non-pipeline features', async () => { + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: { + ...baseFeature, + status: 'in_progress', // Non-pipeline status + summary: 'Old summary', + }, + recovered: false, + source: 'main', + }); + + await manager.saveFeatureSummary('/project', 'pipeline-feature-1', 'New summary'); + + const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature; + expect(savedFeature.summary).toBe('New summary'); + }); + + it('should not add step headers for non-pipeline features', async () => { + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: { + ...baseFeature, + status: 'in_progress', // Non-pipeline status + summary: undefined, + }, + recovered: false, + source: 'main', + }); + + await manager.saveFeatureSummary('/project', 'pipeline-feature-1', 'Simple summary'); + + const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature; + expect(savedFeature.summary).toBe('Simple summary'); + expect(savedFeature.summary).not.toContain('###'); + }); + }); + + describe('summary content edge cases', () => { + it('should handle summary with unicode characters', async () => { + (pipelineService.getStep as Mock).mockResolvedValue({ name: 'Testing', id: 'step1' }); + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: { ...baseFeature, status: 'pipeline_step1', summary: undefined }, + recovered: false, + source: 'main', + }); + + const unicodeSummary = 'Test results: ✅ 42 passed, ❌ 0 failed, 🎉 100% coverage'; + await manager.saveFeatureSummary('/project', 'pipeline-feature-1', unicodeSummary); + + const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature; + expect(savedFeature.summary).toContain('✅'); + expect(savedFeature.summary).toContain('❌'); + expect(savedFeature.summary).toContain('🎉'); + }); + + it('should handle very long summary content', async () => { + (pipelineService.getStep as Mock).mockResolvedValue({ name: 'Implementation', id: 'step1' }); + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: { ...baseFeature, status: 'pipeline_step1', summary: undefined }, + recovered: false, + source: 'main', + }); + + // Generate a very long summary (10KB+) + const longContent = 'This is a line of content.\n'.repeat(500); + await manager.saveFeatureSummary('/project', 'pipeline-feature-1', longContent); + + const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature; + expect(savedFeature.summary!.length).toBeGreaterThan(10000); + }); + + it('should handle summary with markdown tables', async () => { + (pipelineService.getStep as Mock).mockResolvedValue({ name: 'Testing', id: 'step1' }); + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: { ...baseFeature, status: 'pipeline_step1', summary: undefined }, + recovered: false, + source: 'main', + }); + + const tableSummary = ` +## Test Results + +| Test Suite | Passed | Failed | Skipped | +|------------|--------|--------|---------| +| Unit | 42 | 0 | 2 | +| Integration| 15 | 0 | 0 | +| E2E | 8 | 1 | 0 | +`; + await manager.saveFeatureSummary('/project', 'pipeline-feature-1', tableSummary); + + const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature; + expect(savedFeature.summary).toContain('| Test Suite |'); + expect(savedFeature.summary).toContain('| Unit | 42 |'); + }); + + it('should handle summary with nested markdown headers', async () => { + (pipelineService.getStep as Mock).mockResolvedValue({ name: 'Implementation', id: 'step1' }); + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: { ...baseFeature, status: 'pipeline_step1', summary: undefined }, + recovered: false, + source: 'main', + }); + + const nestedSummary = ` +## Main Changes +### Backend +- Added API endpoints +### Frontend +- Created components +#### Deep nesting +- Minor fix +`; + await manager.saveFeatureSummary('/project', 'pipeline-feature-1', nestedSummary); + + const savedFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature; + expect(savedFeature.summary).toContain('### Backend'); + expect(savedFeature.summary).toContain('### Frontend'); + expect(savedFeature.summary).toContain('#### Deep nesting'); + }); + }); + + describe('persistence and event ordering', () => { + it('should persist summary BEFORE emitting event', async () => { + const callOrder: string[] = []; + + (pipelineService.getStep as Mock).mockResolvedValue({ name: 'Testing', id: 'step1' }); + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: { ...baseFeature, status: 'pipeline_step1', summary: undefined }, + recovered: false, + source: 'main', + }); + (atomicWriteJson as Mock).mockImplementation(async () => { + callOrder.push('persist'); + }); + (mockEvents.emit as Mock).mockImplementation(() => { + callOrder.push('emit'); + }); + + await manager.saveFeatureSummary('/project', 'pipeline-feature-1', 'Summary'); + + expect(callOrder).toEqual(['persist', 'emit']); + }); + + it('should not emit event if persistence fails (error is caught silently)', async () => { + // Note: saveFeatureSummary catches errors internally and logs them + // It does NOT re-throw, so the method completes successfully even on error + (pipelineService.getStep as Mock).mockResolvedValue({ name: 'Testing', id: 'step1' }); + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: { ...baseFeature, status: 'pipeline_step1', summary: undefined }, + recovered: false, + source: 'main', + }); + (atomicWriteJson as Mock).mockRejectedValue(new Error('Disk full')); + + // Method completes without throwing (error is logged internally) + await manager.saveFeatureSummary('/project', 'pipeline-feature-1', 'Summary'); + + // Event should NOT be emitted since persistence failed + expect(mockEvents.emit).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/server/tests/unit/services/settings-service.test.ts b/apps/server/tests/unit/services/settings-service.test.ts index e54358fce..4188cd9de 100644 --- a/apps/server/tests/unit/services/settings-service.test.ts +++ b/apps/server/tests/unit/services/settings-service.test.ts @@ -14,12 +14,28 @@ import { type Credentials, type ProjectSettings, } from '@/types/settings.js'; +import type { NtfyEndpointConfig } from '@automaker/types'; describe('settings-service.ts', () => { let testDataDir: string; let testProjectDir: string; let settingsService: SettingsService; + /** + * Helper to create a test ntfy endpoint with sensible defaults + */ + function createTestNtfyEndpoint(overrides: Partial = {}): NtfyEndpointConfig { + return { + id: `endpoint-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, + name: 'Test Endpoint', + serverUrl: 'https://ntfy.sh', + topic: 'test-topic', + authType: 'none', + enabled: true, + ...overrides, + }; + } + beforeEach(async () => { testDataDir = path.join(os.tmpdir(), `settings-test-${Date.now()}`); testProjectDir = path.join(os.tmpdir(), `project-test-${Date.now()}`); @@ -171,6 +187,150 @@ describe('settings-service.ts', () => { expect(updated.theme).toBe('solarized'); }); + it('should not overwrite non-empty ntfyEndpoints with an empty array (data loss guard)', async () => { + const endpoint1 = createTestNtfyEndpoint({ + id: 'endpoint-1', + name: 'My Ntfy', + topic: 'my-topic', + }); + const initial: GlobalSettings = { + ...DEFAULT_GLOBAL_SETTINGS, + ntfyEndpoints: [endpoint1] as any, + }; + const settingsPath = path.join(testDataDir, 'settings.json'); + await fs.writeFile(settingsPath, JSON.stringify(initial, null, 2)); + + const updated = await settingsService.updateGlobalSettings({ + ntfyEndpoints: [], + } as any); + + // The empty array should be ignored - existing endpoints should be preserved + expect(updated.ntfyEndpoints?.length).toBe(1); + expect((updated.ntfyEndpoints as any)?.[0]?.id).toBe('endpoint-1'); + }); + + it('should allow adding new ntfyEndpoints to existing list', async () => { + const endpoint1 = createTestNtfyEndpoint({ + id: 'endpoint-1', + name: 'First Endpoint', + topic: 'first-topic', + }); + const endpoint2 = createTestNtfyEndpoint({ + id: 'endpoint-2', + name: 'Second Endpoint', + serverUrl: 'https://ntfy.example.com', + topic: 'second-topic', + authType: 'token', + token: 'test-token', + }); + + const initial: GlobalSettings = { + ...DEFAULT_GLOBAL_SETTINGS, + ntfyEndpoints: [endpoint1] as any, + }; + const settingsPath = path.join(testDataDir, 'settings.json'); + await fs.writeFile(settingsPath, JSON.stringify(initial, null, 2)); + + const updated = await settingsService.updateGlobalSettings({ + ntfyEndpoints: [endpoint1, endpoint2] as any, + }); + + // Both endpoints should be present + expect(updated.ntfyEndpoints?.length).toBe(2); + expect((updated.ntfyEndpoints as any)?.[0]?.id).toBe('endpoint-1'); + expect((updated.ntfyEndpoints as any)?.[1]?.id).toBe('endpoint-2'); + }); + + it('should allow updating ntfyEndpoints with non-empty array', async () => { + const originalEndpoint = createTestNtfyEndpoint({ + id: 'endpoint-1', + name: 'Original Name', + topic: 'original-topic', + }); + const updatedEndpoint = createTestNtfyEndpoint({ + id: 'endpoint-1', + name: 'Updated Name', + topic: 'updated-topic', + enabled: false, + }); + + const initial: GlobalSettings = { + ...DEFAULT_GLOBAL_SETTINGS, + ntfyEndpoints: [originalEndpoint] as any, + }; + const settingsPath = path.join(testDataDir, 'settings.json'); + await fs.writeFile(settingsPath, JSON.stringify(initial, null, 2)); + + const updated = await settingsService.updateGlobalSettings({ + ntfyEndpoints: [updatedEndpoint] as any, + }); + + // The update should go through with the new values + expect(updated.ntfyEndpoints?.length).toBe(1); + expect((updated.ntfyEndpoints as any)?.[0]?.name).toBe('Updated Name'); + expect((updated.ntfyEndpoints as any)?.[0]?.topic).toBe('updated-topic'); + expect((updated.ntfyEndpoints as any)?.[0]?.enabled).toBe(false); + }); + + it('should allow empty ntfyEndpoints when no existing endpoints exist', async () => { + // Start with no endpoints (default state) + const settingsPath = path.join(testDataDir, 'settings.json'); + await fs.writeFile(settingsPath, JSON.stringify(DEFAULT_GLOBAL_SETTINGS, null, 2)); + + // Trying to set empty array should be fine when there are no existing endpoints + const updated = await settingsService.updateGlobalSettings({ + ntfyEndpoints: [], + } as any); + + // Empty array should be set (no data loss because there was nothing to lose) + expect(updated.ntfyEndpoints?.length ?? 0).toBe(0); + }); + + it('should preserve ntfyEndpoints while updating other settings', async () => { + const endpoint = createTestNtfyEndpoint({ + id: 'endpoint-1', + name: 'My Endpoint', + topic: 'my-topic', + }); + const initial: GlobalSettings = { + ...DEFAULT_GLOBAL_SETTINGS, + theme: 'dark', + ntfyEndpoints: [endpoint] as any, + }; + const settingsPath = path.join(testDataDir, 'settings.json'); + await fs.writeFile(settingsPath, JSON.stringify(initial, null, 2)); + + // Update theme without sending ntfyEndpoints + const updated = await settingsService.updateGlobalSettings({ + theme: 'light', + }); + + // Theme should be updated + expect(updated.theme).toBe('light'); + // ntfyEndpoints should be preserved from existing settings + expect(updated.ntfyEndpoints?.length).toBe(1); + expect((updated.ntfyEndpoints as any)?.[0]?.id).toBe('endpoint-1'); + }); + + it('should allow clearing ntfyEndpoints with escape hatch flag', async () => { + const endpoint = createTestNtfyEndpoint({ id: 'endpoint-1' }); + const initial: GlobalSettings = { + ...DEFAULT_GLOBAL_SETTINGS, + ntfyEndpoints: [endpoint] as any, + }; + const settingsPath = path.join(testDataDir, 'settings.json'); + await fs.writeFile(settingsPath, JSON.stringify(initial, null, 2)); + + // Use escape hatch to intentionally clear ntfyEndpoints + const updated = await settingsService.updateGlobalSettings({ + ntfyEndpoints: [], + __allowEmptyNtfyEndpoints: true, + } as any); + + // The empty array should be applied because escape hatch was used + expect(updated.ntfyEndpoints?.length ?? 0).toBe(0); + }); + it('should create data directory if it does not exist', async () => { const newDataDir = path.join(os.tmpdir(), `new-data-dir-${Date.now()}`); const newService = new SettingsService(newDataDir); @@ -562,6 +722,73 @@ describe('settings-service.ts', () => { expect(projectSettings.boardBackground?.imagePath).toBe('/path/to/image.jpg'); }); + it('should migrate ntfyEndpoints from localStorage data', async () => { + const localStorageData = { + 'automaker-storage': JSON.stringify({ + state: { + ntfyEndpoints: [ + { + id: 'endpoint-1', + name: 'My Ntfy Server', + serverUrl: 'https://ntfy.sh', + topic: 'my-topic', + authType: 'none', + enabled: true, + }, + ], + }, + }), + }; + + const result = await settingsService.migrateFromLocalStorage(localStorageData); + + expect(result.success).toBe(true); + expect(result.migratedGlobalSettings).toBe(true); + + const settings = await settingsService.getGlobalSettings(); + expect(settings.ntfyEndpoints?.length).toBe(1); + expect((settings.ntfyEndpoints as any)?.[0]?.id).toBe('endpoint-1'); + expect((settings.ntfyEndpoints as any)?.[0]?.name).toBe('My Ntfy Server'); + expect((settings.ntfyEndpoints as any)?.[0]?.topic).toBe('my-topic'); + }); + + it('should migrate eventHooks and ntfyEndpoints together from localStorage data', async () => { + const localStorageData = { + 'automaker-storage': JSON.stringify({ + state: { + eventHooks: [ + { + id: 'hook-1', + name: 'Test Hook', + eventType: 'feature:started', + enabled: true, + actions: [], + }, + ], + ntfyEndpoints: [ + { + id: 'endpoint-1', + name: 'My Endpoint', + serverUrl: 'https://ntfy.sh', + topic: 'test-topic', + authType: 'none', + enabled: true, + }, + ], + }, + }), + }; + + const result = await settingsService.migrateFromLocalStorage(localStorageData); + + expect(result.success).toBe(true); + const settings = await settingsService.getGlobalSettings(); + expect(settings.eventHooks?.length).toBe(1); + expect(settings.ntfyEndpoints?.length).toBe(1); + expect((settings.eventHooks as any)?.[0]?.id).toBe('hook-1'); + expect((settings.ntfyEndpoints as any)?.[0]?.id).toBe('endpoint-1'); + }); + it('should handle direct localStorage values', async () => { const localStorageData = { 'automaker:lastProjectDir': '/path/to/project', diff --git a/apps/server/tests/unit/services/spec-parser.test.ts b/apps/server/tests/unit/services/spec-parser.test.ts index e917622c8..411c92909 100644 --- a/apps/server/tests/unit/services/spec-parser.test.ts +++ b/apps/server/tests/unit/services/spec-parser.test.ts @@ -207,12 +207,21 @@ Let me begin by... describe('detectTaskCompleteMarker', () => { it('should detect task complete marker and return task ID', () => { - expect(detectTaskCompleteMarker('[TASK_COMPLETE] T001')).toBe('T001'); - expect(detectTaskCompleteMarker('[TASK_COMPLETE] T042')).toBe('T042'); + expect(detectTaskCompleteMarker('[TASK_COMPLETE] T001')).toEqual({ + id: 'T001', + summary: undefined, + }); + expect(detectTaskCompleteMarker('[TASK_COMPLETE] T042')).toEqual({ + id: 'T042', + summary: undefined, + }); }); it('should handle marker with summary', () => { - expect(detectTaskCompleteMarker('[TASK_COMPLETE] T001: User model created')).toBe('T001'); + expect(detectTaskCompleteMarker('[TASK_COMPLETE] T001: User model created')).toEqual({ + id: 'T001', + summary: 'User model created', + }); }); it('should return null when no marker present', () => { @@ -229,7 +238,28 @@ Done with the implementation: Moving on to... `; - expect(detectTaskCompleteMarker(accumulated)).toBe('T003'); + expect(detectTaskCompleteMarker(accumulated)).toEqual({ + id: 'T003', + summary: 'Database setup complete', + }); + }); + + it('should find marker in the middle of a stream with trailing text', () => { + const streamText = + 'The implementation is complete! [TASK_COMPLETE] T001: Added user model and tests. Now let me check the next task...'; + expect(detectTaskCompleteMarker(streamText)).toEqual({ + id: 'T001', + summary: 'Added user model and tests. Now let me check the next task...', + }); + }); + + it('should find marker in the middle of a stream with multiple tasks and return the FIRST match', () => { + const streamText = + '[TASK_COMPLETE] T001: Task one done. Continuing... [TASK_COMPLETE] T002: Task two done. Moving on...'; + expect(detectTaskCompleteMarker(streamText)).toEqual({ + id: 'T001', + summary: 'Task one done. Continuing...', + }); }); it('should not confuse with TASK_START marker', () => { @@ -240,6 +270,44 @@ Moving on to... expect(detectTaskCompleteMarker('[TASK_COMPLETE] TASK1')).toBeNull(); expect(detectTaskCompleteMarker('[TASK_COMPLETE] T1')).toBeNull(); }); + + it('should allow brackets in summary text', () => { + // Regression test: summaries containing array[index] syntax should not be truncated + expect( + detectTaskCompleteMarker('[TASK_COMPLETE] T001: Supports array[index] access syntax') + ).toEqual({ + id: 'T001', + summary: 'Supports array[index] access syntax', + }); + }); + + it('should handle summary with multiple brackets', () => { + expect( + detectTaskCompleteMarker('[TASK_COMPLETE] T042: Fixed bug in data[0].items[key] mapping') + ).toEqual({ + id: 'T042', + summary: 'Fixed bug in data[0].items[key] mapping', + }); + }); + + it('should stop at newline in summary', () => { + const result = detectTaskCompleteMarker( + '[TASK_COMPLETE] T001: First line\nSecond line without marker' + ); + expect(result).toEqual({ + id: 'T001', + summary: 'First line', + }); + }); + + it('should stop at next TASK_START marker', () => { + expect( + detectTaskCompleteMarker('[TASK_COMPLETE] T001: Summary text[TASK_START] T002') + ).toEqual({ + id: 'T001', + summary: 'Summary text', + }); + }); }); describe('detectPhaseCompleteMarker', () => { @@ -637,5 +705,85 @@ Second paragraph of summary. expect(extractSummary(text)).toBe('First paragraph of summary.'); }); }); + + describe('pipeline accumulated output (multiple tags)', () => { + it('should return only the LAST summary tag from accumulated pipeline output', () => { + // Documents WHY the UI needs server-side feature.summary: + // When pipeline steps accumulate raw output in agent-output.md, each step + // writes its own tag. extractSummary takes only the LAST match, + // losing all previous steps' summaries. + const accumulatedOutput = ` +## Step 1: Code Review + +Some review output... + + +## Code Review Summary +- Found 3 issues +- Suggested 2 improvements + + +--- + +## Follow-up Session + +## Step 2: Testing + +Running tests... + + +## Testing Summary +- All 15 tests pass +- Coverage at 92% + +`; + const result = extractSummary(accumulatedOutput); + // Only the LAST summary tag is returned - the Code Review summary is lost + expect(result).toBe('## Testing Summary\n- All 15 tests pass\n- Coverage at 92%'); + expect(result).not.toContain('Code Review'); + }); + + it('should return only the LAST summary from three pipeline steps', () => { + const accumulatedOutput = ` +Step 1: Implementation complete + +--- + +## Follow-up Session + +Step 2: Code review findings + +--- + +## Follow-up Session + +Step 3: All tests passing +`; + const result = extractSummary(accumulatedOutput); + expect(result).toBe('Step 3: All tests passing'); + expect(result).not.toContain('Step 1'); + expect(result).not.toContain('Step 2'); + }); + + it('should handle accumulated output where only one step has a summary tag', () => { + const accumulatedOutput = ` +## Step 1: Implementation +Some raw output without summary tags... + +--- + +## Follow-up Session + +## Step 2: Testing + + +## Test Results +- All tests pass + +`; + const result = extractSummary(accumulatedOutput); + expect(result).toBe('## Test Results\n- All tests pass'); + }); + }); }); }); diff --git a/apps/server/tests/unit/services/worktree-resolver.test.ts b/apps/server/tests/unit/services/worktree-resolver.test.ts index 43d49edc8..6ba1396e2 100644 --- a/apps/server/tests/unit/services/worktree-resolver.test.ts +++ b/apps/server/tests/unit/services/worktree-resolver.test.ts @@ -107,6 +107,25 @@ branch refs/heads/feature-y expect(result).toBe(normalizePath('/Users/dev/project/.worktrees/feature-x')); }); + it('should normalize refs/heads and trim when resolving target branch', async () => { + mockExecAsync(async () => ({ stdout: porcelainOutput, stderr: '' })); + + const result = await resolver.findWorktreeForBranch( + '/Users/dev/project', + ' refs/heads/feature-x ' + ); + + expect(result).toBe(normalizePath('/Users/dev/project/.worktrees/feature-x')); + }); + + it('should normalize remote-style target branch names', async () => { + mockExecAsync(async () => ({ stdout: porcelainOutput, stderr: '' })); + + const result = await resolver.findWorktreeForBranch('/Users/dev/project', 'origin/feature-x'); + + expect(result).toBe(normalizePath('/Users/dev/project/.worktrees/feature-x')); + }); + it('should return null when branch not found', async () => { mockExecAsync(async () => ({ stdout: porcelainOutput, stderr: '' })); diff --git a/apps/server/tests/unit/types/pipeline-types.test.ts b/apps/server/tests/unit/types/pipeline-types.test.ts new file mode 100644 index 000000000..d978cc4f5 --- /dev/null +++ b/apps/server/tests/unit/types/pipeline-types.test.ts @@ -0,0 +1,48 @@ +import { describe, it, expect } from 'vitest'; +import { isPipelineStatus } from '@automaker/types'; + +describe('isPipelineStatus', () => { + it('should return true for valid pipeline statuses', () => { + expect(isPipelineStatus('pipeline_step1')).toBe(true); + expect(isPipelineStatus('pipeline_testing')).toBe(true); + expect(isPipelineStatus('pipeline_code_review')).toBe(true); + expect(isPipelineStatus('pipeline_complete')).toBe(true); + }); + + it('should return true for pipeline_ prefix with any non-empty suffix', () => { + expect(isPipelineStatus('pipeline_')).toBe(false); // Empty suffix is invalid + expect(isPipelineStatus('pipeline_123')).toBe(true); + expect(isPipelineStatus('pipeline_step_abc_123')).toBe(true); + }); + + it('should return false for non-pipeline statuses', () => { + expect(isPipelineStatus('in_progress')).toBe(false); + expect(isPipelineStatus('backlog')).toBe(false); + expect(isPipelineStatus('ready')).toBe(false); + expect(isPipelineStatus('interrupted')).toBe(false); + expect(isPipelineStatus('waiting_approval')).toBe(false); + expect(isPipelineStatus('verified')).toBe(false); + expect(isPipelineStatus('completed')).toBe(false); + }); + + it('should return false for null and undefined', () => { + expect(isPipelineStatus(null)).toBe(false); + expect(isPipelineStatus(undefined)).toBe(false); + }); + + it('should return false for empty string', () => { + expect(isPipelineStatus('')).toBe(false); + }); + + it('should return false for partial matches', () => { + expect(isPipelineStatus('pipeline')).toBe(false); + expect(isPipelineStatus('pipelin_step1')).toBe(false); + expect(isPipelineStatus('Pipeline_step1')).toBe(false); + expect(isPipelineStatus('PIPELINE_step1')).toBe(false); + }); + + it('should return false for pipeline prefix embedded in longer string', () => { + expect(isPipelineStatus('not_pipeline_step1')).toBe(false); + expect(isPipelineStatus('my_pipeline_step')).toBe(false); + }); +}); diff --git a/apps/server/tests/unit/ui/agent-output-summary-e2e.test.ts b/apps/server/tests/unit/ui/agent-output-summary-e2e.test.ts new file mode 100644 index 000000000..67187c16a --- /dev/null +++ b/apps/server/tests/unit/ui/agent-output-summary-e2e.test.ts @@ -0,0 +1,563 @@ +/** + * End-to-end integration tests for agent output summary display flow. + * + * These tests validate the complete flow from: + * 1. Server-side summary accumulation (FeatureStateManager.saveFeatureSummary) + * 2. Event emission with accumulated summary (auto_mode_summary event) + * 3. UI-side summary retrieval (feature.summary via API) + * 4. UI-side summary parsing and display (parsePhaseSummaries, extractSummary) + * + * The tests simulate what happens when: + * - A feature goes through multiple pipeline steps + * - Each step produces a summary + * - The server accumulates all summaries + * - The UI displays the accumulated summary + */ + +import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest'; +import { FeatureStateManager } from '@/services/feature-state-manager.js'; +import type { Feature } from '@automaker/types'; +import type { EventEmitter } from '@/lib/events.js'; +import type { FeatureLoader } from '@/services/feature-loader.js'; +import { atomicWriteJson, readJsonWithRecovery } from '@automaker/utils'; +import { getFeatureDir } from '@automaker/platform'; +import { pipelineService } from '@/services/pipeline-service.js'; + +// Mock dependencies +vi.mock('@/lib/secure-fs.js', () => ({ + readFile: vi.fn(), + readdir: vi.fn(), +})); + +vi.mock('@automaker/utils', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + atomicWriteJson: vi.fn(), + readJsonWithRecovery: vi.fn(), + logRecoveryWarning: vi.fn(), + }; +}); + +vi.mock('@automaker/platform', () => ({ + getFeatureDir: vi.fn(), + getFeaturesDir: vi.fn(), +})); + +vi.mock('@/services/notification-service.js', () => ({ + getNotificationService: vi.fn(() => ({ + createNotification: vi.fn(), + })), +})); + +vi.mock('@/services/pipeline-service.js', () => ({ + pipelineService: { + getStepIdFromStatus: vi.fn((status: string) => { + if (status.startsWith('pipeline_')) return status.replace('pipeline_', ''); + return null; + }), + getStep: vi.fn(), + }, +})); + +// ============================================================================ +// UI-side parsing functions (mirrored from apps/ui/src/lib/log-parser.ts) +// ============================================================================ + +function parsePhaseSummaries(summary: string | undefined): Map { + const phaseSummaries = new Map(); + if (!summary || !summary.trim()) return phaseSummaries; + + const sections = summary.split(/\n\n---\n\n/); + for (const section of sections) { + const headerMatch = section.match(/^###\s+(.+?)(?:\n|$)/); + if (headerMatch) { + const phaseName = headerMatch[1].trim().toLowerCase(); + const content = section.substring(headerMatch[0].length).trim(); + phaseSummaries.set(phaseName, content); + } + } + return phaseSummaries; +} + +function extractSummary(rawOutput: string): string | null { + if (!rawOutput || !rawOutput.trim()) return null; + + const regexesToTry: Array<{ + regex: RegExp; + processor: (m: RegExpMatchArray) => string; + }> = [ + { regex: /([\s\S]*?)<\/summary>/gi, processor: (m) => m[1] }, + { regex: /^##\s+Summary[^\n]*\n([\s\S]*?)(?=\n##\s+[^#]|\n🔧|$)/gm, processor: (m) => m[1] }, + ]; + + for (const { regex, processor } of regexesToTry) { + const matches = [...rawOutput.matchAll(regex)]; + if (matches.length > 0) { + const lastMatch = matches[matches.length - 1]; + return processor(lastMatch).trim(); + } + } + return null; +} + +function isAccumulatedSummary(summary: string | undefined): boolean { + if (!summary || !summary.trim()) return false; + return summary.includes('\n\n---\n\n') && (summary.match(/###\s+.+/g)?.length ?? 0) > 0; +} + +/** + * Returns the first summary candidate that contains non-whitespace content. + * Mirrors getFirstNonEmptySummary from apps/ui/src/lib/summary-selection.ts + */ +function getFirstNonEmptySummary(...candidates: (string | null | undefined)[]): string | null { + for (const candidate of candidates) { + if (typeof candidate === 'string' && candidate.trim().length > 0) { + return candidate; + } + } + return null; +} + +// ============================================================================ +// Unit tests for helper functions +// ============================================================================ + +describe('getFirstNonEmptySummary', () => { + it('should return the first non-empty string', () => { + expect(getFirstNonEmptySummary(null, undefined, 'first', 'second')).toBe('first'); + }); + + it('should skip null and undefined candidates', () => { + expect(getFirstNonEmptySummary(null, undefined, 'valid')).toBe('valid'); + }); + + it('should skip whitespace-only strings', () => { + expect(getFirstNonEmptySummary(' ', '\n\t', 'actual content')).toBe('actual content'); + }); + + it('should return null when all candidates are empty', () => { + expect(getFirstNonEmptySummary(null, undefined, '', ' ')).toBeNull(); + }); + + it('should return null when no candidates provided', () => { + expect(getFirstNonEmptySummary()).toBeNull(); + }); + + it('should handle empty string as invalid', () => { + expect(getFirstNonEmptySummary('', 'valid')).toBe('valid'); + }); + + it('should prefer first valid candidate', () => { + expect(getFirstNonEmptySummary('first', 'second', 'third')).toBe('first'); + }); + + it('should handle strings with only spaces as invalid', () => { + expect(getFirstNonEmptySummary(' ', ' \n ', 'valid')).toBe('valid'); + }); + + it('should accept strings with content surrounded by whitespace', () => { + expect(getFirstNonEmptySummary(' content with spaces ')).toBe(' content with spaces '); + }); +}); + +describe('Agent Output Summary E2E Flow', () => { + let manager: FeatureStateManager; + let mockEvents: EventEmitter; + + const baseFeature: Feature = { + id: 'e2e-feature-1', + name: 'E2E Feature', + title: 'E2E Feature Title', + description: 'A feature going through complete pipeline', + status: 'pipeline_implementation', + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + }; + + beforeEach(() => { + vi.clearAllMocks(); + + mockEvents = { + emit: vi.fn(), + subscribe: vi.fn(() => vi.fn()), + }; + + const mockFeatureLoader = { + syncFeatureToAppSpec: vi.fn(), + } as unknown as FeatureLoader; + + manager = new FeatureStateManager(mockEvents, mockFeatureLoader); + + (getFeatureDir as Mock).mockReturnValue('/project/.automaker/features/e2e-feature-1'); + }); + + describe('complete pipeline flow: server accumulation → UI display', () => { + it('should maintain complete summary across all pipeline steps', async () => { + // ===== STEP 1: Implementation ===== + (pipelineService.getStep as Mock).mockResolvedValue({ + name: 'Implementation', + id: 'implementation', + }); + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: { ...baseFeature, status: 'pipeline_implementation', summary: undefined }, + recovered: false, + source: 'main', + }); + + await manager.saveFeatureSummary( + '/project', + 'e2e-feature-1', + '## Changes\n- Created auth module\n- Added user service' + ); + + const step1Feature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature; + const step1Summary = step1Feature.summary; + + // Verify server-side accumulation format + expect(step1Summary).toBe( + '### Implementation\n\n## Changes\n- Created auth module\n- Added user service' + ); + + // Verify UI can parse this summary + const phases1 = parsePhaseSummaries(step1Summary); + expect(phases1.size).toBe(1); + expect(phases1.get('implementation')).toContain('Created auth module'); + + // ===== STEP 2: Code Review ===== + vi.clearAllMocks(); + (getFeatureDir as Mock).mockReturnValue('/project/.automaker/features/e2e-feature-1'); + (pipelineService.getStep as Mock).mockResolvedValue({ + name: 'Code Review', + id: 'code_review', + }); + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: { ...baseFeature, status: 'pipeline_code_review', summary: step1Summary }, + recovered: false, + source: 'main', + }); + + await manager.saveFeatureSummary( + '/project', + 'e2e-feature-1', + '## Review Results\n- Approved with minor suggestions' + ); + + const step2Feature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature; + const step2Summary = step2Feature.summary; + + // Verify accumulation now has both steps + expect(step2Summary).toContain('### Implementation'); + expect(step2Summary).toContain('Created auth module'); + expect(step2Summary).toContain('### Code Review'); + expect(step2Summary).toContain('Approved with minor suggestions'); + expect(step2Summary).toContain('\n\n---\n\n'); // Separator + + // Verify UI can parse accumulated summary + expect(isAccumulatedSummary(step2Summary)).toBe(true); + const phases2 = parsePhaseSummaries(step2Summary); + expect(phases2.size).toBe(2); + expect(phases2.get('implementation')).toContain('Created auth module'); + expect(phases2.get('code review')).toContain('Approved with minor suggestions'); + + // ===== STEP 3: Testing ===== + vi.clearAllMocks(); + (getFeatureDir as Mock).mockReturnValue('/project/.automaker/features/e2e-feature-1'); + (pipelineService.getStep as Mock).mockResolvedValue({ name: 'Testing', id: 'testing' }); + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: { ...baseFeature, status: 'pipeline_testing', summary: step2Summary }, + recovered: false, + source: 'main', + }); + + await manager.saveFeatureSummary( + '/project', + 'e2e-feature-1', + '## Test Results\n- 42 tests pass\n- 98% coverage' + ); + + const finalFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature; + const finalSummary = finalFeature.summary; + + // Verify final accumulation has all three steps + expect(finalSummary).toContain('### Implementation'); + expect(finalSummary).toContain('Created auth module'); + expect(finalSummary).toContain('### Code Review'); + expect(finalSummary).toContain('Approved with minor suggestions'); + expect(finalSummary).toContain('### Testing'); + expect(finalSummary).toContain('42 tests pass'); + + // Verify UI-side parsing of complete pipeline + expect(isAccumulatedSummary(finalSummary)).toBe(true); + const finalPhases = parsePhaseSummaries(finalSummary); + expect(finalPhases.size).toBe(3); + + // Verify chronological order (implementation before testing) + const summaryLines = finalSummary!.split('\n'); + const implIndex = summaryLines.findIndex((l) => l.includes('### Implementation')); + const reviewIndex = summaryLines.findIndex((l) => l.includes('### Code Review')); + const testIndex = summaryLines.findIndex((l) => l.includes('### Testing')); + expect(implIndex).toBeLessThan(reviewIndex); + expect(reviewIndex).toBeLessThan(testIndex); + }); + + it('should emit events with accumulated summaries for real-time UI updates', async () => { + // Step 1 + (pipelineService.getStep as Mock).mockResolvedValue({ + name: 'Implementation', + id: 'implementation', + }); + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: { ...baseFeature, status: 'pipeline_implementation', summary: undefined }, + recovered: false, + source: 'main', + }); + + await manager.saveFeatureSummary('/project', 'e2e-feature-1', 'Step 1 output'); + + // Verify event emission + expect(mockEvents.emit).toHaveBeenCalledWith('auto-mode:event', { + type: 'auto_mode_summary', + featureId: 'e2e-feature-1', + projectPath: '/project', + summary: '### Implementation\n\nStep 1 output', + }); + + // Step 2 + vi.clearAllMocks(); + (getFeatureDir as Mock).mockReturnValue('/project/.automaker/features/e2e-feature-1'); + (pipelineService.getStep as Mock).mockResolvedValue({ name: 'Testing', id: 'testing' }); + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: { + ...baseFeature, + status: 'pipeline_testing', + summary: '### Implementation\n\nStep 1 output', + }, + recovered: false, + source: 'main', + }); + + await manager.saveFeatureSummary('/project', 'e2e-feature-1', 'Step 2 output'); + + // Event should contain FULL accumulated summary + expect(mockEvents.emit).toHaveBeenCalledWith('auto-mode:event', { + type: 'auto_mode_summary', + featureId: 'e2e-feature-1', + projectPath: '/project', + summary: '### Implementation\n\nStep 1 output\n\n---\n\n### Testing\n\nStep 2 output', + }); + }); + }); + + describe('UI display logic: feature.summary vs extractSummary()', () => { + it('should prefer feature.summary (server-accumulated) over extractSummary() (last only)', () => { + // Simulate what the server has accumulated + const featureSummary = [ + '### Implementation', + '', + '## Changes', + '- Created feature', + '', + '---', + '', + '### Testing', + '', + '## Results', + '- All tests pass', + ].join('\n'); + + // Simulate raw agent output (only contains last summary) + const rawOutput = ` +Working on tests... + + +## Results +- All tests pass + +`; + + // UI logic: getFirstNonEmptySummary(feature?.summary, extractSummary(output)) + const displaySummary = getFirstNonEmptySummary(featureSummary, extractSummary(rawOutput)); + + // Should use server-accumulated summary + expect(displaySummary).toBe(featureSummary); + expect(displaySummary).toContain('### Implementation'); + expect(displaySummary).toContain('### Testing'); + + // If server summary was missing, only last summary would be shown + const fallbackSummary = extractSummary(rawOutput); + expect(fallbackSummary).not.toContain('Implementation'); + expect(fallbackSummary).toContain('All tests pass'); + }); + + it('should handle legacy features without server accumulation', () => { + // Legacy features have no feature.summary + const featureSummary = undefined; + + // Raw output contains the summary + const rawOutput = ` + +## Implementation Complete +- Created the feature +- All tests pass + +`; + + // UI logic: getFirstNonEmptySummary(feature?.summary, extractSummary(output)) + const displaySummary = getFirstNonEmptySummary(featureSummary, extractSummary(rawOutput)); + + // Should fall back to client-side extraction + expect(displaySummary).toContain('Implementation Complete'); + expect(displaySummary).toContain('All tests pass'); + }); + }); + + describe('error recovery and edge cases', () => { + it('should gracefully handle pipeline interruption', async () => { + // Step 1 completes + (pipelineService.getStep as Mock).mockResolvedValue({ + name: 'Implementation', + id: 'implementation', + }); + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: { ...baseFeature, status: 'pipeline_implementation', summary: undefined }, + recovered: false, + source: 'main', + }); + + await manager.saveFeatureSummary('/project', 'e2e-feature-1', 'Implementation done'); + + const step1Summary = ((atomicWriteJson as Mock).mock.calls[0][1] as Feature).summary; + + // Pipeline gets interrupted (status changes but summary is preserved) + // When user views the feature later, the summary should still be available + expect(step1Summary).toBe('### Implementation\n\nImplementation done'); + + // UI can still parse the partial pipeline + const phases = parsePhaseSummaries(step1Summary); + expect(phases.size).toBe(1); + expect(phases.get('implementation')).toBe('Implementation done'); + }); + + it('should handle very large accumulated summaries', async () => { + // Generate large content for each step + const generateLargeContent = (stepNum: number) => { + const lines = [`## Step ${stepNum} Changes`]; + for (let i = 0; i < 100; i++) { + lines.push( + `- Change ${i}: This is a detailed description of the change made during step ${stepNum}` + ); + } + return lines.join('\n'); + }; + + // Simulate 5 pipeline steps with large content + let currentSummary: string | undefined = undefined; + const stepNames = ['Planning', 'Implementation', 'Code Review', 'Testing', 'Refinement']; + + for (let i = 0; i < 5; i++) { + vi.clearAllMocks(); + (getFeatureDir as Mock).mockReturnValue('/project/.automaker/features/e2e-feature-1'); + (pipelineService.getStep as Mock).mockResolvedValue({ + name: stepNames[i], + id: stepNames[i].toLowerCase().replace(' ', '_'), + }); + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: { + ...baseFeature, + status: `pipeline_${stepNames[i].toLowerCase().replace(' ', '_')}`, + summary: currentSummary, + }, + recovered: false, + source: 'main', + }); + + await manager.saveFeatureSummary('/project', 'e2e-feature-1', generateLargeContent(i + 1)); + + currentSummary = ((atomicWriteJson as Mock).mock.calls[0][1] as Feature).summary; + } + + // Final summary should be large but still parseable + expect(currentSummary!.length).toBeGreaterThan(5000); + expect(isAccumulatedSummary(currentSummary)).toBe(true); + + const phases = parsePhaseSummaries(currentSummary); + expect(phases.size).toBe(5); + + // Verify all steps are present + for (const stepName of stepNames) { + expect(phases.has(stepName.toLowerCase())).toBe(true); + } + }); + }); + + describe('query invalidation simulation', () => { + it('should trigger UI refetch on auto_mode_summary event', async () => { + // This test documents the expected behavior: + // When saveFeatureSummary is called, it emits auto_mode_summary event + // The UI's use-query-invalidation.ts invalidates the feature query + // This causes a refetch of the feature, getting the updated summary + + (pipelineService.getStep as Mock).mockResolvedValue({ + name: 'Implementation', + id: 'implementation', + }); + (readJsonWithRecovery as Mock).mockResolvedValue({ + data: { ...baseFeature, status: 'pipeline_implementation', summary: undefined }, + recovered: false, + source: 'main', + }); + + await manager.saveFeatureSummary('/project', 'e2e-feature-1', 'Summary content'); + + // Verify event was emitted (triggers React Query invalidation) + expect(mockEvents.emit).toHaveBeenCalledWith( + 'auto-mode:event', + expect.objectContaining({ + type: 'auto_mode_summary', + featureId: 'e2e-feature-1', + summary: expect.any(String), + }) + ); + + // The UI would then: + // 1. Receive the event via WebSocket + // 2. Invalidate the feature query + // 3. Refetch the feature (GET /api/features/:id) + // 4. Display the updated feature.summary + }); + }); +}); + +/** + * KEY E2E FLOW SUMMARY: + * + * 1. PIPELINE EXECUTION: + * - Feature starts with status='pipeline_implementation' + * - Agent runs and produces summary + * - FeatureStateManager.saveFeatureSummary() accumulates with step header + * - Status advances to 'pipeline_testing' + * - Process repeats for each step + * + * 2. SERVER-SIDE ACCUMULATION: + * - First step: `### Implementation\n\n` + * - Second step: `### Implementation\n\n\n\n---\n\n### Testing\n\n` + * - Pattern continues with each step + * + * 3. EVENT EMISSION: + * - auto_mode_summary event contains FULL accumulated summary + * - UI receives event via WebSocket + * - React Query invalidates feature query + * - Feature is refetched with updated summary + * + * 4. UI DISPLAY: + * - AgentOutputModal uses: getFirstNonEmptySummary(feature?.summary, extractSummary(output)) + * - feature.summary is preferred (contains all steps) + * - extractSummary() is fallback (last summary only) + * - parsePhaseSummaries() can split into individual phases for UI + * + * 5. FALLBACK FOR LEGACY: + * - Old features may not have feature.summary + * - UI falls back to extracting from raw output + * - Only last summary is available in this case + */ diff --git a/apps/server/tests/unit/ui/agent-output-summary-priority.test.ts b/apps/server/tests/unit/ui/agent-output-summary-priority.test.ts new file mode 100644 index 000000000..c31f950e4 --- /dev/null +++ b/apps/server/tests/unit/ui/agent-output-summary-priority.test.ts @@ -0,0 +1,403 @@ +/** + * Unit tests for the agent output summary priority logic. + * + * These tests verify the summary display logic used in AgentOutputModal + * where the UI must choose between server-accumulated summaries and + * client-side extracted summaries. + * + * Priority order (from agent-output-modal.tsx): + * 1. feature.summary (server-accumulated, contains all pipeline steps) + * 2. extractSummary(output) (client-side fallback, last summary only) + * + * This priority is crucial for pipeline features where the server-side + * accumulation provides the complete history of all step summaries. + */ + +import { describe, it, expect } from 'vitest'; +// Import the actual extractSummary function to ensure test behavior matches production +import { extractSummary } from '../../../../ui/src/lib/log-parser.ts'; +import { getFirstNonEmptySummary } from '../../../../ui/src/lib/summary-selection.ts'; + +/** + * Simulates the summary priority logic from AgentOutputModal. + * + * Priority: + * 1. feature?.summary (server-accumulated) + * 2. extractSummary(output) (client-side fallback) + */ +function getDisplaySummary( + featureSummary: string | undefined | null, + rawOutput: string +): string | null { + return getFirstNonEmptySummary(featureSummary, extractSummary(rawOutput)); +} + +describe('Agent Output Summary Priority Logic', () => { + describe('priority order: feature.summary over extractSummary', () => { + it('should use feature.summary when available (server-accumulated wins)', () => { + const featureSummary = '### Step 1\n\nFirst step\n\n---\n\n### Step 2\n\nSecond step'; + const rawOutput = ` + +Only the last summary is extracted client-side + +`; + + const result = getDisplaySummary(featureSummary, rawOutput); + + // Server-accumulated summary should be used, not client-side extraction + expect(result).toBe(featureSummary); + expect(result).toContain('### Step 1'); + expect(result).toContain('### Step 2'); + expect(result).not.toContain('Only the last summary'); + }); + + it('should use client-side extractSummary when feature.summary is undefined', () => { + const rawOutput = ` + +This is the only summary + +`; + + const result = getDisplaySummary(undefined, rawOutput); + + expect(result).toBe('This is the only summary'); + }); + + it('should use client-side extractSummary when feature.summary is null', () => { + const rawOutput = ` + +Client-side extracted summary + +`; + + const result = getDisplaySummary(null, rawOutput); + + expect(result).toBe('Client-side extracted summary'); + }); + + it('should use client-side extractSummary when feature.summary is empty string', () => { + const rawOutput = ` + +Fallback content + +`; + + const result = getDisplaySummary('', rawOutput); + + // Empty string is falsy, so fallback is used + expect(result).toBe('Fallback content'); + }); + + it('should use client-side extractSummary when feature.summary is whitespace only', () => { + const rawOutput = ` + +Fallback for whitespace summary + +`; + + const result = getDisplaySummary(' \n ', rawOutput); + + expect(result).toBe('Fallback for whitespace summary'); + }); + + it('should preserve original server summary formatting when non-empty after trim', () => { + const featureSummary = '\n### Implementation\n\n- Added API route\n'; + + const result = getDisplaySummary(featureSummary, ''); + + expect(result).toBe(featureSummary); + expect(result).toContain('### Implementation'); + }); + }); + + describe('pipeline step accumulation scenarios', () => { + it('should display all pipeline steps when using server-accumulated summary', () => { + // This simulates a feature that went through 3 pipeline steps + const featureSummary = [ + '### Implementation', + '', + '## Changes', + '- Created new module', + '- Added tests', + '', + '---', + '', + '### Code Review', + '', + '## Review Results', + '- Approved with minor suggestions', + '', + '---', + '', + '### Testing', + '', + '## Test Results', + '- All 42 tests pass', + '- Coverage: 98%', + ].join('\n'); + + const rawOutput = ` + +Only testing step visible in raw output + +`; + + const result = getDisplaySummary(featureSummary, rawOutput); + + // All pipeline steps should be visible + expect(result).toContain('### Implementation'); + expect(result).toContain('### Code Review'); + expect(result).toContain('### Testing'); + expect(result).toContain('All 42 tests pass'); + }); + + it('should display only last summary when server-side accumulation not available', () => { + // When feature.summary is not available, only the last summary is shown + const rawOutput = ` + +Step 1: Implementation complete + + +--- + + +Step 2: Code review complete + + +--- + + +Step 3: Testing complete + +`; + + const result = getDisplaySummary(undefined, rawOutput); + + // Only the LAST summary should be shown (client-side fallback behavior) + expect(result).toBe('Step 3: Testing complete'); + expect(result).not.toContain('Step 1'); + expect(result).not.toContain('Step 2'); + }); + + it('should handle single-step pipeline (no accumulation needed)', () => { + const featureSummary = '### Implementation\n\nCreated the feature'; + const rawOutput = ''; + + const result = getDisplaySummary(featureSummary, rawOutput); + + expect(result).toBe(featureSummary); + expect(result).not.toContain('---'); // No separator for single step + }); + }); + + describe('edge cases', () => { + it('should return null when both feature.summary and extractSummary are unavailable', () => { + const rawOutput = 'No summary tags here, just regular output.'; + + const result = getDisplaySummary(undefined, rawOutput); + + expect(result).toBeNull(); + }); + + it('should return null when rawOutput is empty and no feature summary', () => { + const result = getDisplaySummary(undefined, ''); + + expect(result).toBeNull(); + }); + + it('should return null when rawOutput is whitespace only', () => { + const result = getDisplaySummary(undefined, ' \n\n '); + + expect(result).toBeNull(); + }); + + it('should use client-side fallback when feature.summary is empty string (falsy)', () => { + // Empty string is falsy in JavaScript, so fallback is correctly used. + // This is the expected behavior - an empty summary has no value to display. + const rawOutput = ` + +Fallback content when server summary is empty + +`; + + // Empty string is falsy, so fallback is used + const result = getDisplaySummary('', rawOutput); + expect(result).toBe('Fallback content when server summary is empty'); + }); + + it('should behave identically when feature is null vs feature.summary is undefined', () => { + // This test verifies that the behavior is consistent whether: + // - The feature object itself is null/undefined + // - The feature object exists but summary property is undefined + const rawOutput = ` + +Client-side extracted summary + +`; + + // Both scenarios should use client-side fallback + const resultWithUndefined = getDisplaySummary(undefined, rawOutput); + const resultWithNull = getDisplaySummary(null, rawOutput); + + expect(resultWithUndefined).toBe('Client-side extracted summary'); + expect(resultWithNull).toBe('Client-side extracted summary'); + expect(resultWithUndefined).toBe(resultWithNull); + }); + }); + + describe('markdown content preservation', () => { + it('should preserve markdown formatting in server-accumulated summary', () => { + const featureSummary = `### Code Review + +## Changes Made +- Fixed **critical bug** in \`parser.ts\` +- Added \`validateInput()\` function + +\`\`\`typescript +const x = 1; +\`\`\` + +| Test | Result | +|------|--------| +| Unit | Pass |`; + + const result = getDisplaySummary(featureSummary, ''); + + expect(result).toContain('**critical bug**'); + expect(result).toContain('`parser.ts`'); + expect(result).toContain('```typescript'); + expect(result).toContain('| Test | Result |'); + }); + + it('should preserve unicode in server-accumulated summary', () => { + const featureSummary = '### Testing\n\n✅ 42 passed\n❌ 0 failed\n🎉 100% coverage'; + + const result = getDisplaySummary(featureSummary, ''); + + expect(result).toContain('✅'); + expect(result).toContain('❌'); + expect(result).toContain('🎉'); + }); + }); + + describe('real-world scenarios', () => { + it('should handle typical pipeline feature with server accumulation', () => { + // Simulates a real pipeline feature that went through Implementation → Testing + const featureSummary = `### Implementation + +## Changes Made +- Created UserProfile component +- Added authentication middleware +- Updated API endpoints + +--- + +### Testing + +## Test Results +- Unit tests: 15 passed +- Integration tests: 8 passed +- E2E tests: 3 passed`; + + const rawOutput = ` +Working on the feature... + + +## Test Results +- Unit tests: 15 passed +- Integration tests: 8 passed +- E2E tests: 3 passed + +`; + + const result = getDisplaySummary(featureSummary, rawOutput); + + // Both steps should be visible + expect(result).toContain('### Implementation'); + expect(result).toContain('### Testing'); + expect(result).toContain('UserProfile component'); + expect(result).toContain('15 passed'); + }); + + it('should handle non-pipeline feature (single summary)', () => { + // Non-pipeline features have a single summary, no accumulation + const featureSummary = '## Implementation Complete\n- Created the feature\n- All tests pass'; + const rawOutput = ''; + + const result = getDisplaySummary(featureSummary, rawOutput); + + expect(result).toBe(featureSummary); + expect(result).not.toContain('###'); // No step headers for non-pipeline + }); + + it('should handle legacy feature without server summary (fallback)', () => { + // Legacy features may not have feature.summary set + const rawOutput = ` + +Legacy implementation from before server-side accumulation + +`; + + const result = getDisplaySummary(undefined, rawOutput); + + expect(result).toBe('Legacy implementation from before server-side accumulation'); + }); + }); + + describe('view mode determination logic', () => { + /** + * Simulates the effectiveViewMode logic from agent-output-modal.tsx line 86 + * Default to 'summary' if summary is available, otherwise 'parsed' + */ + function getEffectiveViewMode( + viewMode: string | null, + summary: string | null + ): 'summary' | 'parsed' { + return (viewMode ?? (summary ? 'summary' : 'parsed')) as 'summary' | 'parsed'; + } + + it('should default to summary view when server summary is available', () => { + const summary = '### Implementation\n\nContent'; + const result = getEffectiveViewMode(null, summary); + expect(result).toBe('summary'); + }); + + it('should default to summary view when client-side extraction succeeds', () => { + const summary = 'Extracted from raw output'; + const result = getEffectiveViewMode(null, summary); + expect(result).toBe('summary'); + }); + + it('should default to parsed view when no summary is available', () => { + const result = getEffectiveViewMode(null, null); + expect(result).toBe('parsed'); + }); + + it('should respect explicit view mode selection over default', () => { + const summary = 'Summary is available'; + expect(getEffectiveViewMode('raw', summary)).toBe('raw'); + expect(getEffectiveViewMode('parsed', summary)).toBe('parsed'); + expect(getEffectiveViewMode('changes', summary)).toBe('changes'); + }); + }); +}); + +/** + * KEY ARCHITECTURE INSIGHT: + * + * The priority order (feature.summary > extractSummary(output)) is essential for + * pipeline features because: + * + * 1. Server-side accumulation (FeatureStateManager.saveFeatureSummary) collects + * ALL step summaries with headers and separators in chronological order. + * + * 2. Client-side extractSummary() only returns the LAST summary tag from raw output, + * losing all previous step summaries. + * + * 3. The UI must prefer feature.summary to display the complete history of all + * pipeline steps to the user. + * + * For non-pipeline features (single execution), both sources contain the same + * summary, so the priority doesn't matter. But for pipeline features, using the + * wrong source would result in incomplete information display. + */ diff --git a/apps/server/tests/unit/ui/log-parser-mixed-format.test.ts b/apps/server/tests/unit/ui/log-parser-mixed-format.test.ts new file mode 100644 index 000000000..37a03e9d4 --- /dev/null +++ b/apps/server/tests/unit/ui/log-parser-mixed-format.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest'; +import { + parseAllPhaseSummaries, + parsePhaseSummaries, + extractPhaseSummary, + extractImplementationSummary, + isAccumulatedSummary, +} from '../../../../ui/src/lib/log-parser.ts'; + +describe('log-parser mixed summary format compatibility', () => { + const mixedSummary = [ + 'Implemented core auth flow and API wiring.', + '', + '---', + '', + '### Code Review', + '', + 'Addressed lint warnings and improved error handling.', + '', + '---', + '', + '### Testing', + '', + 'All tests passing.', + ].join('\n'); + + it('treats leading headerless section as Implementation phase', () => { + const phases = parsePhaseSummaries(mixedSummary); + + expect(phases.get('implementation')).toBe('Implemented core auth flow and API wiring.'); + expect(phases.get('code review')).toBe('Addressed lint warnings and improved error handling.'); + expect(phases.get('testing')).toBe('All tests passing.'); + }); + + it('returns implementation summary from mixed format', () => { + expect(extractImplementationSummary(mixedSummary)).toBe( + 'Implemented core auth flow and API wiring.' + ); + }); + + it('includes Implementation as the first parsed phase entry', () => { + const entries = parseAllPhaseSummaries(mixedSummary); + + expect(entries[0]).toMatchObject({ + phaseName: 'Implementation', + content: 'Implemented core auth flow and API wiring.', + }); + expect(entries.map((entry) => entry.phaseName)).toEqual([ + 'Implementation', + 'Code Review', + 'Testing', + ]); + }); + + it('extracts specific phase summaries from mixed format', () => { + expect(extractPhaseSummary(mixedSummary, 'Implementation')).toBe( + 'Implemented core auth flow and API wiring.' + ); + expect(extractPhaseSummary(mixedSummary, 'Code Review')).toBe( + 'Addressed lint warnings and improved error handling.' + ); + expect(extractPhaseSummary(mixedSummary, 'Testing')).toBe('All tests passing.'); + }); + + it('treats mixed format as accumulated summary', () => { + expect(isAccumulatedSummary(mixedSummary)).toBe(true); + }); +}); diff --git a/apps/server/tests/unit/ui/log-parser-phase-summary.test.ts b/apps/server/tests/unit/ui/log-parser-phase-summary.test.ts new file mode 100644 index 000000000..c919b5a57 --- /dev/null +++ b/apps/server/tests/unit/ui/log-parser-phase-summary.test.ts @@ -0,0 +1,973 @@ +/** + * Unit tests for log-parser phase summary parsing functions. + * + * These functions are used to parse accumulated summaries that contain multiple + * pipeline step summaries separated by `---` and identified by `### StepName` headers. + * + * Functions tested: + * - parsePhaseSummaries: Parses the entire accumulated summary into a Map + * - extractPhaseSummary: Extracts a specific phase's content + * - extractImplementationSummary: Extracts implementation phase content (convenience) + * - isAccumulatedSummary: Checks if a summary is in accumulated format + */ + +import { describe, it, expect } from 'vitest'; + +// Mirror the functions from apps/ui/src/lib/log-parser.ts +// (We can't import directly because it's a UI file) + +/** + * Parses an accumulated summary string into individual phase summaries. + */ +function parsePhaseSummaries(summary: string | undefined): Map { + const phaseSummaries = new Map(); + + if (!summary || !summary.trim()) { + return phaseSummaries; + } + + // Split by the horizontal rule separator + const sections = summary.split(/\n\n---\n\n/); + + for (const section of sections) { + // Match the phase header pattern: ### Phase Name + const headerMatch = section.match(/^###\s+(.+?)(?:\n|$)/); + if (headerMatch) { + const phaseName = headerMatch[1].trim().toLowerCase(); + // Extract content after the header (skip the header line and leading newlines) + const content = section.substring(headerMatch[0].length).trim(); + phaseSummaries.set(phaseName, content); + } + } + + return phaseSummaries; +} + +/** + * Extracts a specific phase summary from an accumulated summary string. + */ +function extractPhaseSummary(summary: string | undefined, phaseName: string): string | null { + const phaseSummaries = parsePhaseSummaries(summary); + const normalizedPhaseName = phaseName.toLowerCase(); + return phaseSummaries.get(normalizedPhaseName) || null; +} + +/** + * Extracts the implementation phase summary from an accumulated summary string. + */ +function extractImplementationSummary(summary: string | undefined): string | null { + if (!summary || !summary.trim()) { + return null; + } + + const phaseSummaries = parsePhaseSummaries(summary); + + // Try exact match first + const implementationContent = phaseSummaries.get('implementation'); + if (implementationContent) { + return implementationContent; + } + + // Fallback: find any phase containing "implement" + for (const [phaseName, content] of phaseSummaries) { + if (phaseName.includes('implement')) { + return content; + } + } + + // If no phase summaries found, the summary might not be in accumulated format + // (legacy or non-pipeline feature). In this case, return the whole summary + // if it looks like a single summary (no phase headers). + if (!summary.includes('### ') && !summary.includes('\n---\n')) { + return summary; + } + + return null; +} + +/** + * Checks if a summary string is in the accumulated multi-phase format. + */ +function isAccumulatedSummary(summary: string | undefined): boolean { + if (!summary || !summary.trim()) { + return false; + } + + // Check for the presence of phase headers with separator + const hasMultiplePhases = + summary.includes('\n\n---\n\n') && summary.match(/###\s+.+/g)?.length > 0; + + return hasMultiplePhases; +} + +/** + * Represents a single phase entry in an accumulated summary. + */ +interface PhaseSummaryEntry { + /** The phase name (e.g., "Implementation", "Testing", "Code Review") */ + phaseName: string; + /** The content of this phase's summary */ + content: string; + /** The original header line (e.g., "### Implementation") */ + header: string; +} + +/** Default phase name used for non-accumulated summaries */ +const DEFAULT_PHASE_NAME = 'Summary'; + +/** + * Parses an accumulated summary into individual phase entries. + * Returns phases in the order they appear in the summary. + */ +function parseAllPhaseSummaries(summary: string | undefined): PhaseSummaryEntry[] { + const entries: PhaseSummaryEntry[] = []; + + if (!summary || !summary.trim()) { + return entries; + } + + // Check if this is an accumulated summary (has phase headers) + if (!summary.includes('### ')) { + // Not an accumulated summary - return as single entry with generic name + return [ + { phaseName: DEFAULT_PHASE_NAME, content: summary, header: `### ${DEFAULT_PHASE_NAME}` }, + ]; + } + + // Split by the horizontal rule separator + const sections = summary.split(/\n\n---\n\n/); + + for (const section of sections) { + // Match the phase header pattern: ### Phase Name + const headerMatch = section.match(/^(###\s+)(.+?)(?:\n|$)/); + if (headerMatch) { + const header = headerMatch[0].trim(); + const phaseName = headerMatch[2].trim(); + // Extract content after the header (skip the header line and leading newlines) + const content = section.substring(headerMatch[0].length).trim(); + entries.push({ phaseName, content, header }); + } + } + + return entries; +} + +describe('parsePhaseSummaries', () => { + describe('basic parsing', () => { + it('should parse single phase summary', () => { + const summary = `### Implementation + +## Changes Made +- Created new module +- Added unit tests`; + + const result = parsePhaseSummaries(summary); + + expect(result.size).toBe(1); + expect(result.get('implementation')).toBe( + '## Changes Made\n- Created new module\n- Added unit tests' + ); + }); + + it('should parse multiple phase summaries', () => { + const summary = `### Implementation + +## Changes Made +- Created new module + +--- + +### Testing + +## Test Results +- All tests pass`; + + const result = parsePhaseSummaries(summary); + + expect(result.size).toBe(2); + expect(result.get('implementation')).toBe('## Changes Made\n- Created new module'); + expect(result.get('testing')).toBe('## Test Results\n- All tests pass'); + }); + + it('should handle three or more phases', () => { + const summary = `### Planning + +Plan created + +--- + +### Implementation + +Code written + +--- + +### Testing + +Tests pass + +--- + +### Refinement + +Code polished`; + + const result = parsePhaseSummaries(summary); + + expect(result.size).toBe(4); + expect(result.get('planning')).toBe('Plan created'); + expect(result.get('implementation')).toBe('Code written'); + expect(result.get('testing')).toBe('Tests pass'); + expect(result.get('refinement')).toBe('Code polished'); + }); + }); + + describe('edge cases', () => { + it('should return empty map for undefined summary', () => { + const result = parsePhaseSummaries(undefined); + expect(result.size).toBe(0); + }); + + it('should return empty map for null summary', () => { + const result = parsePhaseSummaries(null as unknown as string); + expect(result.size).toBe(0); + }); + + it('should return empty map for empty string', () => { + const result = parsePhaseSummaries(''); + expect(result.size).toBe(0); + }); + + it('should return empty map for whitespace-only string', () => { + const result = parsePhaseSummaries(' \n\n '); + expect(result.size).toBe(0); + }); + + it('should handle summary without phase headers', () => { + const summary = 'Just some regular content without headers'; + const result = parsePhaseSummaries(summary); + expect(result.size).toBe(0); + }); + + it('should handle section without header after separator', () => { + const summary = `### Implementation + +Content here + +--- + +This section has no header`; + + const result = parsePhaseSummaries(summary); + + expect(result.size).toBe(1); + expect(result.get('implementation')).toBe('Content here'); + }); + }); + + describe('phase name normalization', () => { + it('should normalize phase names to lowercase', () => { + const summary = `### IMPLEMENTATION + +Content`; + + const result = parsePhaseSummaries(summary); + expect(result.has('implementation')).toBe(true); + expect(result.has('IMPLEMENTATION')).toBe(false); + }); + + it('should handle mixed case phase names', () => { + const summary = `### Code Review + +Content`; + + const result = parsePhaseSummaries(summary); + expect(result.has('code review')).toBe(true); + }); + + it('should preserve spaces in multi-word phase names', () => { + const summary = `### Code Review + +Content`; + + const result = parsePhaseSummaries(summary); + expect(result.get('code review')).toBe('Content'); + }); + }); + + describe('content preservation', () => { + it('should preserve markdown formatting in content', () => { + const summary = `### Implementation + +## Heading +- **Bold text** +- \`code\` +\`\`\`typescript +const x = 1; +\`\`\``; + + const result = parsePhaseSummaries(summary); + const content = result.get('implementation'); + + expect(content).toContain('**Bold text**'); + expect(content).toContain('`code`'); + expect(content).toContain('```typescript'); + }); + + it('should preserve unicode in content', () => { + const summary = `### Testing + +Results: ✅ 42 passed, ❌ 0 failed`; + + const result = parsePhaseSummaries(summary); + expect(result.get('testing')).toContain('✅'); + expect(result.get('testing')).toContain('❌'); + }); + + it('should preserve tables in content', () => { + const summary = `### Testing + +| Test | Result | +|------|--------| +| Unit | Pass |`; + + const result = parsePhaseSummaries(summary); + expect(result.get('testing')).toContain('| Test | Result |'); + }); + + it('should handle empty phase content', () => { + const summary = `### Implementation + +--- + +### Testing + +Content`; + + const result = parsePhaseSummaries(summary); + expect(result.get('implementation')).toBe(''); + expect(result.get('testing')).toBe('Content'); + }); + }); +}); + +describe('extractPhaseSummary', () => { + describe('extraction by phase name', () => { + it('should extract specified phase content', () => { + const summary = `### Implementation + +Implementation content + +--- + +### Testing + +Testing content`; + + expect(extractPhaseSummary(summary, 'Implementation')).toBe('Implementation content'); + expect(extractPhaseSummary(summary, 'Testing')).toBe('Testing content'); + }); + + it('should be case-insensitive for phase name', () => { + const summary = `### Implementation + +Content`; + + expect(extractPhaseSummary(summary, 'implementation')).toBe('Content'); + expect(extractPhaseSummary(summary, 'IMPLEMENTATION')).toBe('Content'); + expect(extractPhaseSummary(summary, 'ImPlEmEnTaTiOn')).toBe('Content'); + }); + + it('should return null for non-existent phase', () => { + const summary = `### Implementation + +Content`; + + expect(extractPhaseSummary(summary, 'NonExistent')).toBeNull(); + }); + }); + + describe('edge cases', () => { + it('should return null for undefined summary', () => { + expect(extractPhaseSummary(undefined, 'Implementation')).toBeNull(); + }); + + it('should return null for empty summary', () => { + expect(extractPhaseSummary('', 'Implementation')).toBeNull(); + }); + + it('should handle whitespace in phase name', () => { + const summary = `### Code Review + +Content`; + + expect(extractPhaseSummary(summary, 'Code Review')).toBe('Content'); + expect(extractPhaseSummary(summary, 'code review')).toBe('Content'); + }); + }); +}); + +describe('extractImplementationSummary', () => { + describe('exact match', () => { + it('should extract implementation phase by exact name', () => { + const summary = `### Implementation + +## Changes Made +- Created feature +- Added tests + +--- + +### Testing + +Tests pass`; + + const result = extractImplementationSummary(summary); + expect(result).toBe('## Changes Made\n- Created feature\n- Added tests'); + }); + + it('should be case-insensitive', () => { + const summary = `### IMPLEMENTATION + +Content`; + + expect(extractImplementationSummary(summary)).toBe('Content'); + }); + }); + + describe('partial match fallback', () => { + it('should find phase containing "implement"', () => { + const summary = `### Feature Implementation + +Content here`; + + const result = extractImplementationSummary(summary); + expect(result).toBe('Content here'); + }); + + it('should find phase containing "implementation"', () => { + const summary = `### Implementation Phase + +Content here`; + + const result = extractImplementationSummary(summary); + expect(result).toBe('Content here'); + }); + }); + + describe('legacy/non-accumulated summary handling', () => { + it('should return full summary if no phase headers present', () => { + const summary = `## Changes Made +- Created feature +- Added tests`; + + const result = extractImplementationSummary(summary); + expect(result).toBe(summary); + }); + + it('should return null if summary has phase headers but no implementation', () => { + const summary = `### Testing + +Tests pass + +--- + +### Review + +Review complete`; + + const result = extractImplementationSummary(summary); + expect(result).toBeNull(); + }); + + it('should not return full summary if it contains phase headers', () => { + const summary = `### Testing + +Tests pass`; + + const result = extractImplementationSummary(summary); + expect(result).toBeNull(); + }); + }); + + describe('edge cases', () => { + it('should return null for undefined summary', () => { + expect(extractImplementationSummary(undefined)).toBeNull(); + }); + + it('should return null for empty string', () => { + expect(extractImplementationSummary('')).toBeNull(); + }); + + it('should return null for whitespace-only string', () => { + expect(extractImplementationSummary(' \n\n ')).toBeNull(); + }); + }); +}); + +describe('isAccumulatedSummary', () => { + describe('accumulated format detection', () => { + it('should return true for accumulated summary with separator and headers', () => { + const summary = `### Implementation + +Content + +--- + +### Testing + +Content`; + + expect(isAccumulatedSummary(summary)).toBe(true); + }); + + it('should return true for accumulated summary with multiple phases', () => { + const summary = `### Phase 1 + +Content 1 + +--- + +### Phase 2 + +Content 2 + +--- + +### Phase 3 + +Content 3`; + + expect(isAccumulatedSummary(summary)).toBe(true); + }); + + it('should return true for accumulated summary with just one phase and separator', () => { + // Even a single phase with a separator suggests it's in accumulated format + const summary = `### Implementation + +Content + +--- + +### Testing + +More content`; + + expect(isAccumulatedSummary(summary)).toBe(true); + }); + }); + + describe('non-accumulated format detection', () => { + it('should return false for summary without separator', () => { + const summary = `### Implementation + +Just content`; + + expect(isAccumulatedSummary(summary)).toBe(false); + }); + + it('should return false for summary with separator but no headers', () => { + const summary = `Content + +--- + +More content`; + + expect(isAccumulatedSummary(summary)).toBe(false); + }); + + it('should return false for simple text summary', () => { + const summary = 'Just a simple summary without any special formatting'; + expect(isAccumulatedSummary(summary)).toBe(false); + }); + + it('should return false for markdown summary without phase headers', () => { + const summary = `## Changes Made +- Created feature +- Added tests`; + expect(isAccumulatedSummary(summary)).toBe(false); + }); + }); + + describe('edge cases', () => { + it('should return false for undefined summary', () => { + expect(isAccumulatedSummary(undefined)).toBe(false); + }); + + it('should return false for null summary', () => { + expect(isAccumulatedSummary(null as unknown as string)).toBe(false); + }); + + it('should return false for empty string', () => { + expect(isAccumulatedSummary('')).toBe(false); + }); + + it('should return false for whitespace-only string', () => { + expect(isAccumulatedSummary(' \n\n ')).toBe(false); + }); + }); +}); + +describe('Integration: Full parsing workflow', () => { + it('should correctly parse typical server-accumulated pipeline summary', () => { + // This simulates what FeatureStateManager.saveFeatureSummary() produces + const summary = [ + '### Implementation', + '', + '## Changes', + '- Added auth module', + '- Created user service', + '', + '---', + '', + '### Code Review', + '', + '## Review Results', + '- Style issues fixed', + '- Added error handling', + '', + '---', + '', + '### Testing', + '', + '## Test Results', + '- 42 tests pass', + '- 98% coverage', + ].join('\n'); + + // Verify isAccumulatedSummary + expect(isAccumulatedSummary(summary)).toBe(true); + + // Verify parsePhaseSummaries + const phases = parsePhaseSummaries(summary); + expect(phases.size).toBe(3); + expect(phases.get('implementation')).toContain('Added auth module'); + expect(phases.get('code review')).toContain('Style issues fixed'); + expect(phases.get('testing')).toContain('42 tests pass'); + + // Verify extractPhaseSummary + expect(extractPhaseSummary(summary, 'Implementation')).toContain('Added auth module'); + expect(extractPhaseSummary(summary, 'Code Review')).toContain('Style issues fixed'); + expect(extractPhaseSummary(summary, 'Testing')).toContain('42 tests pass'); + + // Verify extractImplementationSummary + expect(extractImplementationSummary(summary)).toContain('Added auth module'); + }); + + it('should handle legacy non-pipeline summary correctly', () => { + // Legacy features have simple summaries without accumulation + const summary = `## Implementation Complete +- Created the feature +- All tests pass`; + + // Should NOT be detected as accumulated + expect(isAccumulatedSummary(summary)).toBe(false); + + // parsePhaseSummaries should return empty + const phases = parsePhaseSummaries(summary); + expect(phases.size).toBe(0); + + // extractPhaseSummary should return null + expect(extractPhaseSummary(summary, 'Implementation')).toBeNull(); + + // extractImplementationSummary should return the full summary (legacy handling) + expect(extractImplementationSummary(summary)).toBe(summary); + }); + + it('should handle single-step pipeline summary', () => { + // A single pipeline step still gets the header but no separator + const summary = `### Implementation + +## Changes +- Created the feature`; + + // Should NOT be detected as accumulated (no separator) + expect(isAccumulatedSummary(summary)).toBe(false); + + // parsePhaseSummaries should still extract the single phase + const phases = parsePhaseSummaries(summary); + expect(phases.size).toBe(1); + expect(phases.get('implementation')).toContain('Created the feature'); + }); +}); + +/** + * KEY ARCHITECTURE NOTES: + * + * 1. The accumulated summary format uses: + * - `### PhaseName` for step headers + * - `\n\n---\n\n` as separator between steps + * + * 2. Phase names are normalized to lowercase in the Map for case-insensitive lookup. + * + * 3. Legacy summaries (non-pipeline features) don't have phase headers and should + * be returned as-is by extractImplementationSummary. + * + * 4. isAccumulatedSummary() checks for BOTH separator AND phase headers to be + * confident that the summary is in the accumulated format. + * + * 5. The server-side FeatureStateManager.saveFeatureSummary() is responsible for + * creating summaries in this accumulated format. + */ + +describe('parseAllPhaseSummaries', () => { + describe('basic parsing', () => { + it('should parse single phase summary into array with one entry', () => { + const summary = `### Implementation + +## Changes Made +- Created new module +- Added unit tests`; + + const result = parseAllPhaseSummaries(summary); + + expect(result.length).toBe(1); + expect(result[0].phaseName).toBe('Implementation'); + expect(result[0].content).toBe('## Changes Made\n- Created new module\n- Added unit tests'); + expect(result[0].header).toBe('### Implementation'); + }); + + it('should parse multiple phase summaries in order', () => { + const summary = `### Implementation + +## Changes Made +- Created new module + +--- + +### Testing + +## Test Results +- All tests pass`; + + const result = parseAllPhaseSummaries(summary); + + expect(result.length).toBe(2); + // Verify order is preserved + expect(result[0].phaseName).toBe('Implementation'); + expect(result[0].content).toBe('## Changes Made\n- Created new module'); + expect(result[1].phaseName).toBe('Testing'); + expect(result[1].content).toBe('## Test Results\n- All tests pass'); + }); + + it('should parse three or more phases in correct order', () => { + const summary = `### Planning + +Plan created + +--- + +### Implementation + +Code written + +--- + +### Testing + +Tests pass + +--- + +### Refinement + +Code polished`; + + const result = parseAllPhaseSummaries(summary); + + expect(result.length).toBe(4); + expect(result[0].phaseName).toBe('Planning'); + expect(result[1].phaseName).toBe('Implementation'); + expect(result[2].phaseName).toBe('Testing'); + expect(result[3].phaseName).toBe('Refinement'); + }); + }); + + describe('non-accumulated summary handling', () => { + it('should return single entry for summary without phase headers', () => { + const summary = `## Changes Made +- Created feature +- Added tests`; + + const result = parseAllPhaseSummaries(summary); + + expect(result.length).toBe(1); + expect(result[0].phaseName).toBe('Summary'); + expect(result[0].content).toBe(summary); + }); + + it('should return single entry for simple text summary', () => { + const summary = 'Just a simple summary without any special formatting'; + + const result = parseAllPhaseSummaries(summary); + + expect(result.length).toBe(1); + expect(result[0].phaseName).toBe('Summary'); + expect(result[0].content).toBe(summary); + }); + }); + + describe('edge cases', () => { + it('should return empty array for undefined summary', () => { + const result = parseAllPhaseSummaries(undefined); + expect(result.length).toBe(0); + }); + + it('should return empty array for empty string', () => { + const result = parseAllPhaseSummaries(''); + expect(result.length).toBe(0); + }); + + it('should return empty array for whitespace-only string', () => { + const result = parseAllPhaseSummaries(' \n\n '); + expect(result.length).toBe(0); + }); + + it('should handle section without header after separator', () => { + const summary = `### Implementation + +Content here + +--- + +This section has no header`; + + const result = parseAllPhaseSummaries(summary); + + expect(result.length).toBe(1); + expect(result[0].phaseName).toBe('Implementation'); + }); + }); + + describe('content preservation', () => { + it('should preserve markdown formatting in content', () => { + const summary = `### Implementation + +## Heading +- **Bold text** +- \`code\` +\`\`\`typescript +const x = 1; +\`\`\``; + + const result = parseAllPhaseSummaries(summary); + const content = result[0].content; + + expect(content).toContain('**Bold text**'); + expect(content).toContain('`code`'); + expect(content).toContain('```typescript'); + }); + + it('should preserve unicode in content', () => { + const summary = `### Testing + +Results: ✅ 42 passed, ❌ 0 failed`; + + const result = parseAllPhaseSummaries(summary); + expect(result[0].content).toContain('✅'); + expect(result[0].content).toContain('❌'); + }); + + it('should preserve tables in content', () => { + const summary = `### Testing + +| Test | Result | +|------|--------| +| Unit | Pass |`; + + const result = parseAllPhaseSummaries(summary); + expect(result[0].content).toContain('| Test | Result |'); + }); + + it('should handle empty phase content', () => { + const summary = `### Implementation + +--- + +### Testing + +Content`; + + const result = parseAllPhaseSummaries(summary); + expect(result.length).toBe(2); + expect(result[0].content).toBe(''); + expect(result[1].content).toBe('Content'); + }); + }); + + describe('header preservation', () => { + it('should preserve original header text', () => { + const summary = `### Code Review + +Content`; + + const result = parseAllPhaseSummaries(summary); + expect(result[0].header).toBe('### Code Review'); + }); + + it('should preserve phase name with original casing', () => { + const summary = `### CODE REVIEW + +Content`; + + const result = parseAllPhaseSummaries(summary); + expect(result[0].phaseName).toBe('CODE REVIEW'); + }); + }); + + describe('chronological order preservation', () => { + it('should maintain order: Alpha before Beta before Gamma', () => { + const summary = `### Alpha + +First + +--- + +### Beta + +Second + +--- + +### Gamma + +Third`; + + const result = parseAllPhaseSummaries(summary); + + expect(result.length).toBe(3); + const names = result.map((e) => e.phaseName); + expect(names).toEqual(['Alpha', 'Beta', 'Gamma']); + }); + + it('should preserve typical pipeline order', () => { + const summary = [ + '### Implementation', + '', + '## Changes', + '- Added auth module', + '', + '---', + '', + '### Code Review', + '', + '## Review Results', + '- Style issues fixed', + '', + '---', + '', + '### Testing', + '', + '## Test Results', + '- 42 tests pass', + ].join('\n'); + + const result = parseAllPhaseSummaries(summary); + + expect(result.length).toBe(3); + expect(result[0].phaseName).toBe('Implementation'); + expect(result[1].phaseName).toBe('Code Review'); + expect(result[2].phaseName).toBe('Testing'); + }); + }); +}); diff --git a/apps/server/tests/unit/ui/log-parser-summary.test.ts b/apps/server/tests/unit/ui/log-parser-summary.test.ts new file mode 100644 index 000000000..e5bc0b707 --- /dev/null +++ b/apps/server/tests/unit/ui/log-parser-summary.test.ts @@ -0,0 +1,453 @@ +/** + * Unit tests for the UI's log-parser extractSummary() function. + * + * These tests document the behavior of extractSummary() which is used as a + * CLIENT-SIDE FALLBACK when feature.summary (server-accumulated) is not available. + * + * IMPORTANT: extractSummary() returns only the LAST tag from raw output. + * For pipeline features with multiple steps, the server-side FeatureStateManager + * accumulates all step summaries into feature.summary, which the UI prefers. + * + * The tests below verify that extractSummary() correctly: + * - Returns the LAST summary when multiple exist (mimicking pipeline accumulation) + * - Handles various summary formats ( tags, markdown headers) + * - Returns null when no summary is found + * - Handles edge cases like empty input and malformed tags + */ + +import { describe, it, expect } from 'vitest'; + +// Recreate the extractSummary logic from apps/ui/src/lib/log-parser.ts +// We can't import directly because it's a UI file, so we mirror the logic here + +/** + * Cleans up fragmented streaming text by removing spurious newlines + */ +function cleanFragmentedText(content: string): string { + let cleaned = content.replace(/([a-zA-Z])\n+([a-zA-Z])/g, '$1$2'); + cleaned = cleaned.replace(/<([a-zA-Z]+)\n*([a-zA-Z]*)\n*>/g, '<$1$2>'); + cleaned = cleaned.replace(/<\/([a-zA-Z]+)\n*([a-zA-Z]*)\n*>/g, ''); + return cleaned; +} + +/** + * Extracts summary content from raw log output + * Returns the LAST summary text if found, or null if no summary exists + */ +function extractSummary(rawOutput: string): string | null { + if (!rawOutput || !rawOutput.trim()) { + return null; + } + + const cleanedOutput = cleanFragmentedText(rawOutput); + + const regexesToTry: Array<{ + regex: RegExp; + processor: (m: RegExpMatchArray) => string; + }> = [ + { regex: /([\s\S]*?)<\/summary>/gi, processor: (m) => m[1] }, + { regex: /^##\s+Summary[^\n]*\n([\s\S]*?)(?=\n##\s+[^#]|\n🔧|$)/gm, processor: (m) => m[1] }, + { + regex: /^##\s+(Feature|Changes|Implementation)[^\n]*\n([\s\S]*?)(?=\n##\s+[^#]|\n🔧|$)/gm, + processor: (m) => `## ${m[1]}\n${m[2]}`, + }, + { + regex: /(^|\n)(All tasks completed[\s\S]*?)(?=\n🔧|\n📋|\n⚡|\n❌|$)/g, + processor: (m) => m[2], + }, + { + regex: + /(^|\n)((I've|I have) (successfully |now )?(completed|finished|implemented)[\s\S]*?)(?=\n🔧|\n📋|\n⚡|\n❌|$)/g, + processor: (m) => m[2], + }, + ]; + + for (const { regex, processor } of regexesToTry) { + const matches = [...cleanedOutput.matchAll(regex)]; + if (matches.length > 0) { + const lastMatch = matches[matches.length - 1]; + return cleanFragmentedText(processor(lastMatch)).trim(); + } + } + + return null; +} + +describe('log-parser extractSummary (UI fallback)', () => { + describe('basic summary extraction', () => { + it('should extract summary from tags', () => { + const output = ` +Some agent output... + + +## Changes Made +- Fixed the bug in parser.ts +- Added error handling + + +More output... +`; + const result = extractSummary(output); + expect(result).toBe('## Changes Made\n- Fixed the bug in parser.ts\n- Added error handling'); + }); + + it('should prefer tags over markdown headers', () => { + const output = ` +## Summary + +Markdown summary here. + + +XML summary here. + +`; + const result = extractSummary(output); + expect(result).toBe('XML summary here.'); + }); + }); + + describe('multiple summaries (pipeline accumulation scenario)', () => { + it('should return ONLY the LAST summary tag when multiple exist', () => { + // This is the key behavior for pipeline features: + // extractSummary returns only the LAST, which is why server-side + // accumulation is needed for multi-step pipelines + const output = ` +## Step 1: Code Review + + +- Found 3 issues +- Approved with changes + + +--- + +## Step 2: Testing + + +- All tests pass +- Coverage 95% + +`; + const result = extractSummary(output); + expect(result).toBe('- All tests pass\n- Coverage 95%'); + expect(result).not.toContain('Code Review'); + expect(result).not.toContain('Found 3 issues'); + }); + + it('should return ONLY the LAST summary from three pipeline steps', () => { + const output = ` +Step 1 complete + +--- + +Step 2 complete + +--- + +Step 3 complete - all done! +`; + const result = extractSummary(output); + expect(result).toBe('Step 3 complete - all done!'); + expect(result).not.toContain('Step 1'); + expect(result).not.toContain('Step 2'); + }); + + it('should handle mixed summary formats across pipeline steps', () => { + const output = ` +## Step 1 + + +Implementation done + + +--- + +## Step 2 + +## Summary +Review complete + +--- + +## Step 3 + + +All tests passing + +`; + const result = extractSummary(output); + // The tag format takes priority, and returns the LAST match + expect(result).toBe('All tests passing'); + }); + }); + + describe('priority order of summary patterns', () => { + it('should try patterns in priority order: first, then markdown headers', () => { + // When both tags and markdown headers exist, + // tags should take priority + const output = ` +## Summary + +This markdown summary should be ignored. + + +This XML summary should be used. + +`; + const result = extractSummary(output); + expect(result).toBe('This XML summary should be used.'); + expect(result).not.toContain('ignored'); + }); + + it('should fall back to Feature/Changes/Implementation headers when no tag', () => { + // Note: The regex for these headers requires content before the header + // (^ at start or preceded by newline). Adding some content before. + const output = ` +Agent output here... + +## Feature + +New authentication system with OAuth support. + +## Next +`; + const result = extractSummary(output); + // Should find the Feature header and include it in result + // Note: Due to regex behavior, it captures content until next ## + expect(result).toContain('## Feature'); + }); + + it('should fall back to completion phrases when no structured summary found', () => { + const output = ` +Working on the feature... +Making progress... + +All tasks completed successfully. The feature is ready. + +🔧 Tool: Bash +`; + const result = extractSummary(output); + expect(result).toContain('All tasks completed'); + }); + }); + + describe('edge cases', () => { + it('should return null for empty string', () => { + expect(extractSummary('')).toBeNull(); + }); + + it('should return null for whitespace-only string', () => { + expect(extractSummary(' \n\n ')).toBeNull(); + }); + + it('should return null when no summary pattern found', () => { + expect(extractSummary('Random agent output without any summary patterns')).toBeNull(); + }); + + it('should handle malformed tags gracefully', () => { + const output = ` + +This summary is never closed... +`; + // Without closing tag, the regex won't match + expect(extractSummary(output)).toBeNull(); + }); + + it('should handle empty tags', () => { + const output = ` + +`; + const result = extractSummary(output); + expect(result).toBe(''); // Empty string is valid + }); + + it('should handle tags with only whitespace', () => { + const output = ` + + + +`; + const result = extractSummary(output); + expect(result).toBe(''); // Trimmed to empty string + }); + + it('should handle summary with markdown code blocks', () => { + const output = ` + +## Changes + +\`\`\`typescript +const x = 1; +\`\`\` + +Done! + +`; + const result = extractSummary(output); + expect(result).toContain('```typescript'); + expect(result).toContain('const x = 1;'); + }); + + it('should handle summary with special characters', () => { + const output = ` + +Fixed bug in parser.ts: "quotes" and 'apostrophes' +Special chars: <>&$@#%^* + +`; + const result = extractSummary(output); + expect(result).toContain('"quotes"'); + expect(result).toContain('<>&$@#%^*'); + }); + }); + + describe('fragmented streaming text handling', () => { + it('should handle fragmented tags from streaming', () => { + // Sometimes streaming providers split text like "" + const output = ` + +Fixed the issue + +`; + const result = extractSummary(output); + // The cleanFragmentedText function should normalize this + expect(result).toBe('Fixed the issue'); + }); + + it('should handle fragmented text within summary content', () => { + const output = ` + +Fixed the bug in par +ser.ts + +`; + const result = extractSummary(output); + // cleanFragmentedText should join "par\n\nser" into "parser" + expect(result).toBe('Fixed the bug in parser.ts'); + }); + }); + + describe('completion phrase detection', () => { + it('should extract "All tasks completed" summaries', () => { + const output = ` +Some output... + +All tasks completed successfully. The feature is ready for review. + +🔧 Tool: Bash +`; + const result = extractSummary(output); + expect(result).toContain('All tasks completed'); + }); + + it("should extract I've completed summaries", () => { + const output = ` +Working on feature... + +I've successfully implemented the feature with all requirements met. + +🔧 Tool: Read +`; + const result = extractSummary(output); + expect(result).toContain("I've successfully implemented"); + }); + + it('should extract "I have finished" summaries', () => { + const output = ` +Implementation phase... + +I have finished the implementation. + +📋 Planning +`; + const result = extractSummary(output); + expect(result).toContain('I have finished'); + }); + }); + + describe('real-world pipeline scenarios', () => { + it('should handle typical multi-step pipeline output (returns last only)', () => { + // This test documents WHY server-side accumulation is essential: + // extractSummary only returns the last step's summary + const output = ` +📋 Planning Mode: Full + +🔧 Tool: Read +Input: {"file_path": "src/parser.ts"} + + +## Code Review +- Analyzed parser.ts +- Found potential improvements + + +--- + +## Follow-up Session + +🔧 Tool: Edit +Input: {"file_path": "src/parser.ts"} + + +## Implementation +- Applied suggested improvements +- Updated tests + + +--- + +## Follow-up Session + +🔧 Tool: Bash +Input: {"command": "npm test"} + + +## Testing +- All 42 tests pass +- No regressions detected + +`; + const result = extractSummary(output); + // Only the LAST summary is returned + expect(result).toBe('## Testing\n- All 42 tests pass\n- No regressions detected'); + // Earlier summaries are lost + expect(result).not.toContain('Code Review'); + expect(result).not.toContain('Implementation'); + }); + + it('should handle single-step non-pipeline output', () => { + // For non-pipeline features, extractSummary works correctly + const output = ` +Working on feature... + + +## Implementation Complete +- Created new component +- Added unit tests +- Updated documentation + +`; + const result = extractSummary(output); + expect(result).toContain('Implementation Complete'); + expect(result).toContain('Created new component'); + }); + }); +}); + +/** + * These tests verify the UI fallback behavior for summary extraction. + * + * KEY INSIGHT: The extractSummary() function returns only the LAST summary, + * which is why the server-side FeatureStateManager.saveFeatureSummary() method + * accumulates all step summaries into feature.summary. + * + * The UI's AgentOutputModal component uses this priority: + * 1. feature.summary (server-accumulated, contains all steps) + * 2. extractSummary(output) (client-side fallback, last summary only) + * + * For pipeline features, this ensures all step summaries are displayed. + */ diff --git a/apps/server/tests/unit/ui/phase-summary-parser.test.ts b/apps/server/tests/unit/ui/phase-summary-parser.test.ts new file mode 100644 index 000000000..aa7f0533f --- /dev/null +++ b/apps/server/tests/unit/ui/phase-summary-parser.test.ts @@ -0,0 +1,533 @@ +/** + * Unit tests for the UI's log-parser phase summary parsing functions. + * + * These tests verify the behavior of: + * - parsePhaseSummaries(): Parses accumulated summary into individual phases + * - extractPhaseSummary(): Extracts a specific phase's summary + * - extractImplementationSummary(): Extracts only the implementation phase + * - isAccumulatedSummary(): Checks if summary is in accumulated format + * + * The accumulated summary format uses markdown headers with `###` for phase names + * and `---` as separators between phases. + * + * TODO: These test helper functions are mirrored from apps/ui/src/lib/log-parser.ts + * because server-side tests cannot import from the UI module. If the production + * implementation changes, these tests may pass while production fails. + * Consider adding an integration test that validates the actual UI parsing behavior. + */ + +import { describe, it, expect } from 'vitest'; + +// ============================================================================ +// MIRRORED FUNCTIONS from apps/ui/src/lib/log-parser.ts +// ============================================================================ +// NOTE: These functions are mirrored from the UI implementation because +// server-side tests cannot import from apps/ui/. Keep these in sync with the +// production implementation. The UI implementation includes additional +// handling for getPhaseSections/leadingImplementationSection for backward +// compatibility with mixed formats. + +/** + * Parses an accumulated summary string into individual phase summaries. + */ +function parsePhaseSummaries(summary: string | undefined): Map { + const phaseSummaries = new Map(); + + if (!summary || !summary.trim()) { + return phaseSummaries; + } + + // Split by the horizontal rule separator + const sections = summary.split(/\n\n---\n\n/); + + for (const section of sections) { + // Match the phase header pattern: ### Phase Name + const headerMatch = section.match(/^###\s+(.+?)(?:\n|$)/); + if (headerMatch) { + const phaseName = headerMatch[1].trim().toLowerCase(); + // Extract content after the header (skip the header line and leading newlines) + const content = section.substring(headerMatch[0].length).trim(); + phaseSummaries.set(phaseName, content); + } + } + + return phaseSummaries; +} + +/** + * Extracts a specific phase summary from an accumulated summary string. + */ +function extractPhaseSummary(summary: string | undefined, phaseName: string): string | null { + const phaseSummaries = parsePhaseSummaries(summary); + const normalizedPhaseName = phaseName.toLowerCase(); + return phaseSummaries.get(normalizedPhaseName) || null; +} + +/** + * Gets the implementation phase summary from an accumulated summary string. + */ +function extractImplementationSummary(summary: string | undefined): string | null { + if (!summary || !summary.trim()) { + return null; + } + + const phaseSummaries = parsePhaseSummaries(summary); + + // Try exact match first + const implementationContent = phaseSummaries.get('implementation'); + if (implementationContent) { + return implementationContent; + } + + // Fallback: find any phase containing "implement" + for (const [phaseName, content] of phaseSummaries) { + if (phaseName.includes('implement')) { + return content; + } + } + + // If no phase summaries found, the summary might not be in accumulated format + // (legacy or non-pipeline feature). In this case, return the whole summary + // if it looks like a single summary (no phase headers). + if (!summary.includes('### ') && !summary.includes('\n---\n')) { + return summary; + } + + return null; +} + +/** + * Checks if a summary string is in the accumulated multi-phase format. + */ +function isAccumulatedSummary(summary: string | undefined): boolean { + if (!summary || !summary.trim()) { + return false; + } + + // Check for the presence of phase headers with separator + const hasMultiplePhases = + summary.includes('\n\n---\n\n') && summary.match(/###\s+.+/g)?.length > 0; + + return hasMultiplePhases; +} + +describe('phase summary parser', () => { + describe('parsePhaseSummaries', () => { + it('should parse single phase summary', () => { + const summary = `### Implementation + +Created auth module with login functionality.`; + + const result = parsePhaseSummaries(summary); + + expect(result.size).toBe(1); + expect(result.get('implementation')).toBe('Created auth module with login functionality.'); + }); + + it('should parse multiple phase summaries', () => { + const summary = `### Implementation + +Created auth module. + +--- + +### Testing + +All tests pass. + +--- + +### Code Review + +Approved with minor suggestions.`; + + const result = parsePhaseSummaries(summary); + + expect(result.size).toBe(3); + expect(result.get('implementation')).toBe('Created auth module.'); + expect(result.get('testing')).toBe('All tests pass.'); + expect(result.get('code review')).toBe('Approved with minor suggestions.'); + }); + + it('should handle empty input', () => { + expect(parsePhaseSummaries('').size).toBe(0); + expect(parsePhaseSummaries(undefined).size).toBe(0); + expect(parsePhaseSummaries(' \n\n ').size).toBe(0); + }); + + it('should handle phase names with spaces', () => { + const summary = `### Code Review + +Review findings here.`; + + const result = parsePhaseSummaries(summary); + expect(result.get('code review')).toBe('Review findings here.'); + }); + + it('should normalize phase names to lowercase', () => { + const summary = `### IMPLEMENTATION + +Content here.`; + + const result = parsePhaseSummaries(summary); + expect(result.get('implementation')).toBe('Content here.'); + expect(result.get('IMPLEMENTATION')).toBeUndefined(); + }); + + it('should handle content with markdown', () => { + const summary = `### Implementation + +## Changes Made +- Fixed bug in parser.ts +- Added error handling + +\`\`\`typescript +const x = 1; +\`\`\``; + + const result = parsePhaseSummaries(summary); + expect(result.get('implementation')).toContain('## Changes Made'); + expect(result.get('implementation')).toContain('```typescript'); + }); + + it('should return empty map for non-accumulated format', () => { + // Legacy format without phase headers + const summary = `## Summary + +This is a simple summary without phase headers.`; + + const result = parsePhaseSummaries(summary); + expect(result.size).toBe(0); + }); + }); + + describe('extractPhaseSummary', () => { + it('should extract specific phase by name (case-insensitive)', () => { + const summary = `### Implementation + +Implementation content. + +--- + +### Testing + +Testing content.`; + + expect(extractPhaseSummary(summary, 'implementation')).toBe('Implementation content.'); + expect(extractPhaseSummary(summary, 'IMPLEMENTATION')).toBe('Implementation content.'); + expect(extractPhaseSummary(summary, 'Implementation')).toBe('Implementation content.'); + expect(extractPhaseSummary(summary, 'testing')).toBe('Testing content.'); + }); + + it('should return null for non-existent phase', () => { + const summary = `### Implementation + +Content here.`; + + expect(extractPhaseSummary(summary, 'code review')).toBeNull(); + }); + + it('should return null for empty input', () => { + expect(extractPhaseSummary('', 'implementation')).toBeNull(); + expect(extractPhaseSummary(undefined, 'implementation')).toBeNull(); + }); + }); + + describe('extractImplementationSummary', () => { + it('should extract implementation phase from accumulated summary', () => { + const summary = `### Implementation + +Created auth module. + +--- + +### Testing + +All tests pass. + +--- + +### Code Review + +Approved.`; + + const result = extractImplementationSummary(summary); + expect(result).toBe('Created auth module.'); + expect(result).not.toContain('Testing'); + expect(result).not.toContain('Code Review'); + }); + + it('should return implementation phase even when not first', () => { + const summary = `### Planning + +Plan created. + +--- + +### Implementation + +Implemented the feature. + +--- + +### Review + +Reviewed.`; + + const result = extractImplementationSummary(summary); + expect(result).toBe('Implemented the feature.'); + }); + + it('should handle phase with "implementation" in name', () => { + const summary = `### Feature Implementation + +Built the feature.`; + + const result = extractImplementationSummary(summary); + expect(result).toBe('Built the feature.'); + }); + + it('should return full summary for non-accumulated format (legacy)', () => { + // Non-pipeline features store summary without phase headers + const summary = `## Changes +- Fixed bug +- Added tests`; + + const result = extractImplementationSummary(summary); + expect(result).toBe(summary); + }); + + it('should return null for empty input', () => { + expect(extractImplementationSummary('')).toBeNull(); + expect(extractImplementationSummary(undefined)).toBeNull(); + expect(extractImplementationSummary(' \n\n ')).toBeNull(); + }); + + it('should return null when no implementation phase in accumulated summary', () => { + const summary = `### Testing + +Tests written. + +--- + +### Code Review + +Approved.`; + + const result = extractImplementationSummary(summary); + expect(result).toBeNull(); + }); + }); + + describe('isAccumulatedSummary', () => { + it('should return true for accumulated multi-phase summary', () => { + const summary = `### Implementation + +Content. + +--- + +### Testing + +Content.`; + + expect(isAccumulatedSummary(summary)).toBe(true); + }); + + it('should return false for single phase summary (no separator)', () => { + const summary = `### Implementation + +Content.`; + + expect(isAccumulatedSummary(summary)).toBe(false); + }); + + it('should return false for legacy non-accumulated format', () => { + const summary = `## Summary + +This is a simple summary.`; + + expect(isAccumulatedSummary(summary)).toBe(false); + }); + + it('should return false for empty input', () => { + expect(isAccumulatedSummary('')).toBe(false); + expect(isAccumulatedSummary(undefined)).toBe(false); + expect(isAccumulatedSummary(' \n\n ')).toBe(false); + }); + + it('should return true even for two phases', () => { + const summary = `### Implementation + +Content A. + +--- + +### Code Review + +Content B.`; + + expect(isAccumulatedSummary(summary)).toBe(true); + }); + }); + + describe('acceptance criteria scenarios', () => { + it('AC1: Implementation summary preserved when Testing completes', () => { + // Given a task card completes the Implementation phase, + // when the Testing phase subsequently completes, + // then the Implementation phase summary must remain stored independently + const summary = `### Implementation + +- Created auth module +- Added user service + +--- + +### Testing + +- 42 tests pass +- 98% coverage`; + + const impl = extractImplementationSummary(summary); + const testing = extractPhaseSummary(summary, 'testing'); + + expect(impl).toBe('- Created auth module\n- Added user service'); + expect(testing).toBe('- 42 tests pass\n- 98% coverage'); + expect(impl).not.toContain('Testing'); + expect(testing).not.toContain('auth module'); + }); + + it('AC4: Implementation Summary tab shows only implementation phase', () => { + // Given a task card has completed the Implementation phase + // (regardless of how many subsequent phases have run), + // when the user opens the "Implementation Summary" tab, + // then it must display only the summary produced by the Implementation phase + const summary = `### Implementation + +Implementation phase output here. + +--- + +### Testing + +Testing phase output here. + +--- + +### Code Review + +Code review output here.`; + + const impl = extractImplementationSummary(summary); + + expect(impl).toBe('Implementation phase output here.'); + expect(impl).not.toContain('Testing'); + expect(impl).not.toContain('Code Review'); + }); + + it('AC5: Empty state when implementation not started', () => { + // Given a task card has not yet started the Implementation phase + const summary = `### Planning + +Planning phase complete.`; + + const impl = extractImplementationSummary(summary); + + // Should return null (UI shows "No implementation summary available") + expect(impl).toBeNull(); + }); + + it('AC6: Single phase summary displayed correctly', () => { + // Given a task card where Implementation was the only completed phase + const summary = `### Implementation + +Only implementation was done.`; + + const impl = extractImplementationSummary(summary); + + expect(impl).toBe('Only implementation was done.'); + }); + + it('AC9: Mid-progress shows only completed phases', () => { + // Given a task card is mid-progress + // (e.g., Implementation and Testing complete, Code Review pending) + const summary = `### Implementation + +Implementation done. + +--- + +### Testing + +Testing done.`; + + const phases = parsePhaseSummaries(summary); + + expect(phases.size).toBe(2); + expect(phases.has('implementation')).toBe(true); + expect(phases.has('testing')).toBe(true); + expect(phases.has('code review')).toBe(false); + }); + + it('AC10: All phases in chronological order', () => { + // Given all phases of a task card are complete + const summary = `### Implementation + +First phase content. + +--- + +### Testing + +Second phase content. + +--- + +### Code Review + +Third phase content.`; + + // ParsePhaseSummaries should preserve order + const phases = parsePhaseSummaries(summary); + const phaseNames = [...phases.keys()]; + + expect(phaseNames).toEqual(['implementation', 'testing', 'code review']); + }); + + it('AC17: Retried phase shows only latest', () => { + // Given a phase was retried, when viewing the Summary tab, + // only one entry for the retried phase must appear (the latest retry's summary) + // + // Note: The server-side FeatureStateManager overwrites the phase summary + // when the same phase runs again, so we only have one entry per phase name. + // This test verifies that the parser correctly handles this. + const summary = `### Implementation + +First attempt content. + +--- + +### Testing + +First test run. + +--- + +### Implementation + +Retry content - fixed issues. + +--- + +### Testing + +Retry - all tests now pass.`; + + const phases = parsePhaseSummaries(summary); + + // The parser will have both entries, but Map keeps last value for same key + expect(phases.get('implementation')).toBe('Retry content - fixed issues.'); + expect(phases.get('testing')).toBe('Retry - all tests now pass.'); + }); + }); +}); diff --git a/apps/server/tests/unit/ui/summary-auto-scroll.test.ts b/apps/server/tests/unit/ui/summary-auto-scroll.test.ts new file mode 100644 index 000000000..544e88a05 --- /dev/null +++ b/apps/server/tests/unit/ui/summary-auto-scroll.test.ts @@ -0,0 +1,238 @@ +/** + * Unit tests for the summary auto-scroll detection logic. + * + * These tests verify the behavior of the scroll detection function used in + * AgentOutputModal to determine if auto-scroll should be enabled. + * + * The logic mirrors the handleSummaryScroll function in: + * apps/ui/src/components/views/board-view/dialogs/agent-output-modal.tsx + * + * Auto-scroll behavior: + * - When user is at or near the bottom (< 50px from bottom), auto-scroll is enabled + * - When user scrolls up to view older content, auto-scroll is disabled + * - Scrolling back to bottom re-enables auto-scroll + */ + +import { describe, it, expect } from 'vitest'; + +/** + * Determines if the scroll position is at the bottom of the container. + * This is the core logic from handleSummaryScroll in AgentOutputModal. + * + * @param scrollTop - Current scroll position from top + * @param scrollHeight - Total scrollable height + * @param clientHeight - Visible height of the container + * @param threshold - Distance from bottom to consider "at bottom" (default: 50px) + * @returns true if at bottom, false otherwise + */ +function isScrollAtBottom( + scrollTop: number, + scrollHeight: number, + clientHeight: number, + threshold = 50 +): boolean { + const distanceFromBottom = scrollHeight - scrollTop - clientHeight; + return distanceFromBottom < threshold; +} + +describe('Summary Auto-Scroll Detection Logic', () => { + describe('basic scroll position detection', () => { + it('should return true when scrolled to exact bottom', () => { + // Container: 500px tall, content: 1000px tall + // ScrollTop: 500 (scrolled to bottom) + const result = isScrollAtBottom(500, 1000, 500); + expect(result).toBe(true); + }); + + it('should return true when near bottom (within threshold)', () => { + // 49px from bottom - within 50px threshold + const result = isScrollAtBottom(451, 1000, 500); + expect(result).toBe(true); + }); + + it('should return true when exactly at threshold boundary (49px)', () => { + // 49px from bottom + const result = isScrollAtBottom(451, 1000, 500); + expect(result).toBe(true); + }); + + it('should return false when just outside threshold (51px)', () => { + // 51px from bottom - outside 50px threshold + const result = isScrollAtBottom(449, 1000, 500); + expect(result).toBe(false); + }); + + it('should return false when scrolled to top', () => { + const result = isScrollAtBottom(0, 1000, 500); + expect(result).toBe(false); + }); + + it('should return false when scrolled to middle', () => { + const result = isScrollAtBottom(250, 1000, 500); + expect(result).toBe(false); + }); + }); + + describe('edge cases with small content', () => { + it('should return true when content fits in viewport (no scroll needed)', () => { + // Content is smaller than container - no scrolling possible + const result = isScrollAtBottom(0, 300, 500); + expect(result).toBe(true); + }); + + it('should return true when content exactly fits viewport', () => { + const result = isScrollAtBottom(0, 500, 500); + expect(result).toBe(true); + }); + + it('should return true when content slightly exceeds viewport (within threshold)', () => { + // Content: 540px, Viewport: 500px, can scroll 40px + // At scroll 0, we're 40px from bottom - within threshold + const result = isScrollAtBottom(0, 540, 500); + expect(result).toBe(true); + }); + }); + + describe('large content scenarios', () => { + it('should correctly detect bottom in very long content', () => { + // Simulate accumulated summary from many pipeline steps + // Content: 10000px, Viewport: 500px + const result = isScrollAtBottom(9500, 10000, 500); + expect(result).toBe(true); + }); + + it('should correctly detect non-bottom in very long content', () => { + // User scrolled up to read earlier summaries + const result = isScrollAtBottom(5000, 10000, 500); + expect(result).toBe(false); + }); + + it('should detect when user scrolls up from bottom', () => { + // Started at bottom (scroll: 9500), then scrolled up 100px + const result = isScrollAtBottom(9400, 10000, 500); + expect(result).toBe(false); + }); + }); + + describe('custom threshold values', () => { + it('should work with larger threshold (100px)', () => { + // 75px from bottom - within 100px threshold + const result = isScrollAtBottom(425, 1000, 500, 100); + expect(result).toBe(true); + }); + + it('should work with smaller threshold (10px)', () => { + // 15px from bottom - outside 10px threshold + const result = isScrollAtBottom(485, 1000, 500, 10); + expect(result).toBe(false); + }); + + it('should work with zero threshold (exact match only)', () => { + // At exact bottom - distanceFromBottom = 0, which is NOT < 0 with strict comparison + // This is an edge case: the implementation uses < not <= + const result = isScrollAtBottom(500, 1000, 500, 0); + expect(result).toBe(false); // 0 < 0 is false + + // 1px from bottom - also fails + const result2 = isScrollAtBottom(499, 1000, 500, 0); + expect(result2).toBe(false); + + // For exact match with 0 threshold, we need negative distanceFromBottom + // which happens when scrollTop > scrollHeight - clientHeight (overscroll) + const result3 = isScrollAtBottom(501, 1000, 500, 0); + expect(result3).toBe(true); // -1 < 0 is true + }); + }); + + describe('pipeline summary scrolling scenarios', () => { + it('should enable auto-scroll when new content arrives while at bottom', () => { + // User is at bottom viewing step 2 summary + // Step 3 summary is added, increasing scrollHeight from 1000 to 1500 + // ScrollTop stays at 950 (was at bottom), but now user needs to scroll + + // Before new content: isScrollAtBottom(950, 1000, 500) = true + // After new content: auto-scroll should kick in to scroll to new bottom + + // Simulating the auto-scroll effect setting scrollTop to new bottom + const newScrollTop = 1500 - 500; // scrollHeight - clientHeight + const result = isScrollAtBottom(newScrollTop, 1500, 500); + expect(result).toBe(true); + }); + + it('should not auto-scroll when user is reading earlier summaries', () => { + // User scrolled up to read step 1 summary while step 3 is added + // scrollHeight increases, but scrollTop stays same + // User is now further from bottom + + // User was at scroll position 200 (reading early content) + // New content increases scrollHeight from 1000 to 1500 + // Distance from bottom goes from 300 to 800 + const result = isScrollAtBottom(200, 1500, 500); + expect(result).toBe(false); + }); + + it('should re-enable auto-scroll when user scrolls back to bottom', () => { + // User was reading step 1 (scrollTop: 200) + // User scrolls back to bottom to see latest content + const result = isScrollAtBottom(1450, 1500, 500); + expect(result).toBe(true); + }); + }); + + describe('decimal scroll values', () => { + it('should handle fractional scroll positions', () => { + // Browsers can report fractional scroll values + const result = isScrollAtBottom(499.5, 1000, 500); + expect(result).toBe(true); + }); + + it('should handle fractional scroll heights', () => { + const result = isScrollAtBottom(450.7, 1000.3, 500); + expect(result).toBe(true); + }); + }); + + describe('negative and invalid inputs', () => { + it('should handle negative scrollTop (bounce scroll)', () => { + // iOS can report negative scrollTop during bounce + const result = isScrollAtBottom(-10, 1000, 500); + expect(result).toBe(false); + }); + + it('should handle zero scrollHeight', () => { + // Empty content + const result = isScrollAtBottom(0, 0, 500); + expect(result).toBe(true); + }); + + it('should handle zero clientHeight', () => { + // Hidden container - distanceFromBottom = 1000 - 0 - 0 = 1000 + // This is not < threshold, so returns false + // This edge case represents a broken/invisible container + const result = isScrollAtBottom(0, 1000, 0); + expect(result).toBe(false); + }); + }); + + describe('real-world accumulated summary dimensions', () => { + it('should handle typical 3-step pipeline summary dimensions', () => { + // Approximate: 3 steps x ~800px each = ~2400px + // Viewport: 400px (modal height) + const result = isScrollAtBottom(2000, 2400, 400); + expect(result).toBe(true); + }); + + it('should handle large 10-step pipeline summary dimensions', () => { + // Approximate: 10 steps x ~800px each = ~8000px + // Viewport: 400px + const result = isScrollAtBottom(7600, 8000, 400); + expect(result).toBe(true); + }); + + it('should detect scroll to top of large summary', () => { + // User at top of 10-step summary + const result = isScrollAtBottom(0, 8000, 400); + expect(result).toBe(false); + }); + }); +}); diff --git a/apps/server/tests/unit/ui/summary-normalization.test.ts b/apps/server/tests/unit/ui/summary-normalization.test.ts new file mode 100644 index 000000000..999a4f350 --- /dev/null +++ b/apps/server/tests/unit/ui/summary-normalization.test.ts @@ -0,0 +1,128 @@ +/** + * Unit tests for summary normalization between UI components and parser functions. + * + * These tests verify that: + * - getFirstNonEmptySummary returns string | null + * - parseAllPhaseSummaries and isAccumulatedSummary expect string | undefined + * - The normalization (summary ?? undefined) correctly converts null to undefined + * + * This ensures the UI components properly bridge the type gap between: + * - getFirstNonEmptySummary (returns string | null) + * - parseAllPhaseSummaries (expects string | undefined) + * - isAccumulatedSummary (expects string | undefined) + */ + +import { describe, it, expect } from 'vitest'; +import { parseAllPhaseSummaries, isAccumulatedSummary } from '../../../../ui/src/lib/log-parser.ts'; +import { getFirstNonEmptySummary } from '../../../../ui/src/lib/summary-selection.ts'; + +describe('Summary Normalization', () => { + describe('getFirstNonEmptySummary', () => { + it('should return the first non-empty string', () => { + const result = getFirstNonEmptySummary(null, undefined, 'valid summary', 'another'); + expect(result).toBe('valid summary'); + }); + + it('should return null when all candidates are empty', () => { + const result = getFirstNonEmptySummary(null, undefined, '', ' '); + expect(result).toBeNull(); + }); + + it('should return null when no candidates provided', () => { + const result = getFirstNonEmptySummary(); + expect(result).toBeNull(); + }); + + it('should return null for all null/undefined candidates', () => { + const result = getFirstNonEmptySummary(null, undefined, null); + expect(result).toBeNull(); + }); + + it('should preserve original string formatting (not trim)', () => { + const result = getFirstNonEmptySummary(' summary with spaces '); + expect(result).toBe(' summary with spaces '); + }); + }); + + describe('parseAllPhaseSummaries with normalized input', () => { + it('should handle null converted to undefined via ?? operator', () => { + const summary = getFirstNonEmptySummary(null, undefined); + // This is the normalization: summary ?? undefined + const normalizedSummary = summary ?? undefined; + + // TypeScript should accept this without error + const result = parseAllPhaseSummaries(normalizedSummary); + expect(result).toEqual([]); + }); + + it('should parse accumulated summary when non-null is normalized', () => { + const rawSummary = + '### Implementation\n\nDid some work\n\n---\n\n### Testing\n\nAll tests pass'; + const summary = getFirstNonEmptySummary(null, rawSummary); + const normalizedSummary = summary ?? undefined; + + const result = parseAllPhaseSummaries(normalizedSummary); + expect(result).toHaveLength(2); + expect(result[0].phaseName).toBe('Implementation'); + expect(result[1].phaseName).toBe('Testing'); + }); + }); + + describe('isAccumulatedSummary with normalized input', () => { + it('should return false for null converted to undefined', () => { + const summary = getFirstNonEmptySummary(null, undefined); + const normalizedSummary = summary ?? undefined; + + const result = isAccumulatedSummary(normalizedSummary); + expect(result).toBe(false); + }); + + it('should return true for valid accumulated summary after normalization', () => { + const rawSummary = + '### Implementation\n\nDid some work\n\n---\n\n### Testing\n\nAll tests pass'; + const summary = getFirstNonEmptySummary(rawSummary); + const normalizedSummary = summary ?? undefined; + + const result = isAccumulatedSummary(normalizedSummary); + expect(result).toBe(true); + }); + + it('should return false for single-phase summary after normalization', () => { + const rawSummary = '### Implementation\n\nDid some work'; + const summary = getFirstNonEmptySummary(rawSummary); + const normalizedSummary = summary ?? undefined; + + const result = isAccumulatedSummary(normalizedSummary); + expect(result).toBe(false); + }); + }); + + describe('Type safety verification', () => { + it('should demonstrate that null must be normalized to undefined', () => { + // This test documents the type mismatch that requires normalization + const summary: string | null = getFirstNonEmptySummary(null); + const normalizedSummary: string | undefined = summary ?? undefined; + + // parseAllPhaseSummaries expects string | undefined, not string | null + // The normalization converts null -> undefined, which is compatible + const result = parseAllPhaseSummaries(normalizedSummary); + expect(result).toEqual([]); + }); + + it('should work with the actual usage pattern from components', () => { + // Simulates the actual pattern used in summary-dialog.tsx and agent-output-modal.tsx + const featureSummary: string | null | undefined = null; + const extractedSummary: string | null | undefined = undefined; + + const rawSummary = getFirstNonEmptySummary(featureSummary, extractedSummary); + const normalizedSummary = rawSummary ?? undefined; + + // Both parser functions should work with the normalized value + const phases = parseAllPhaseSummaries(normalizedSummary); + const hasMultiple = isAccumulatedSummary(normalizedSummary); + + expect(phases).toEqual([]); + expect(hasMultiple).toBe(false); + }); + }); +}); diff --git a/apps/server/tests/unit/ui/summary-source-flow.integration.test.ts b/apps/server/tests/unit/ui/summary-source-flow.integration.test.ts new file mode 100644 index 000000000..6f268d4ac --- /dev/null +++ b/apps/server/tests/unit/ui/summary-source-flow.integration.test.ts @@ -0,0 +1,108 @@ +import { describe, it, expect } from 'vitest'; +import { parseAllPhaseSummaries, isAccumulatedSummary } from '../../../../ui/src/lib/log-parser.ts'; +import { getFirstNonEmptySummary } from '../../../../ui/src/lib/summary-selection.ts'; + +/** + * Mirrors summary source priority in agent-info-panel.tsx: + * freshFeature.summary > feature.summary > summaryProp > agentInfo.summary + */ +function getCardEffectiveSummary(params: { + freshFeatureSummary?: string | null; + featureSummary?: string | null; + summaryProp?: string | null; + agentInfoSummary?: string | null; +}): string | undefined | null { + return getFirstNonEmptySummary( + params.freshFeatureSummary, + params.featureSummary, + params.summaryProp, + params.agentInfoSummary + ); +} + +/** + * Mirrors SummaryDialog raw summary selection in summary-dialog.tsx: + * summaryProp > feature.summary > agentInfo.summary + */ +function getDialogRawSummary(params: { + summaryProp?: string | null; + featureSummary?: string | null; + agentInfoSummary?: string | null; +}): string | undefined | null { + return getFirstNonEmptySummary( + params.summaryProp, + params.featureSummary, + params.agentInfoSummary + ); +} + +describe('Summary Source Flow Integration', () => { + it('uses fresh per-feature summary in card and preserves it through summary dialog', () => { + const staleListSummary = '## Old summary from stale list cache'; + const freshAccumulatedSummary = `### Implementation + +Implemented auth + profile flow. + +--- + +### Testing + +- Unit tests: 18 passed +- Integration tests: 6 passed`; + const parsedAgentInfoSummary = 'Fallback summary from parsed agent output'; + + const cardEffectiveSummary = getCardEffectiveSummary({ + freshFeatureSummary: freshAccumulatedSummary, + featureSummary: staleListSummary, + summaryProp: undefined, + agentInfoSummary: parsedAgentInfoSummary, + }); + + expect(cardEffectiveSummary).toBe(freshAccumulatedSummary); + + const dialogRawSummary = getDialogRawSummary({ + summaryProp: cardEffectiveSummary, + featureSummary: staleListSummary, + agentInfoSummary: parsedAgentInfoSummary, + }); + + expect(dialogRawSummary).toBe(freshAccumulatedSummary); + expect(isAccumulatedSummary(dialogRawSummary ?? undefined)).toBe(true); + + const phases = parseAllPhaseSummaries(dialogRawSummary ?? undefined); + expect(phases).toHaveLength(2); + expect(phases[0]?.phaseName).toBe('Implementation'); + expect(phases[1]?.phaseName).toBe('Testing'); + }); + + it('falls back in order when fresher sources are absent', () => { + const cardEffectiveSummary = getCardEffectiveSummary({ + freshFeatureSummary: undefined, + featureSummary: '', + summaryProp: undefined, + agentInfoSummary: 'Agent parsed fallback', + }); + + expect(cardEffectiveSummary).toBe('Agent parsed fallback'); + + const dialogRawSummary = getDialogRawSummary({ + summaryProp: undefined, + featureSummary: undefined, + agentInfoSummary: cardEffectiveSummary, + }); + + expect(dialogRawSummary).toBe('Agent parsed fallback'); + expect(isAccumulatedSummary(dialogRawSummary ?? undefined)).toBe(false); + }); + + it('treats whitespace-only summaries as empty during fallback selection', () => { + const cardEffectiveSummary = getCardEffectiveSummary({ + freshFeatureSummary: ' \n', + featureSummary: '\t', + summaryProp: ' ', + agentInfoSummary: 'Agent parsed fallback', + }); + + expect(cardEffectiveSummary).toBe('Agent parsed fallback'); + }); +}); diff --git a/apps/ui/.gitignore b/apps/ui/.gitignore index 7ea8a3603..b8ed27752 100644 --- a/apps/ui/.gitignore +++ b/apps/ui/.gitignore @@ -38,6 +38,7 @@ yarn-error.log* /playwright-report/ /blob-report/ /playwright/.cache/ +/tests/.auth/ # Electron /release/ diff --git a/apps/ui/package.json b/apps/ui/package.json index 7b2c35f1b..a95bb3eca 100644 --- a/apps/ui/package.json +++ b/apps/ui/package.json @@ -1,6 +1,6 @@ { "name": "@automaker/ui", - "version": "0.15.0", + "version": "1.0.0", "description": "An autonomous AI development studio that helps you build software faster using AI-powered agents", "homepage": "https://github.com/AutoMaker-Org/automaker", "repository": { @@ -9,6 +9,7 @@ }, "author": "AutoMaker Team", "license": "SEE LICENSE IN LICENSE", + "desktopName": "automaker.desktop", "private": true, "engines": { "node": ">=22.0.0 <23.0.0" @@ -144,6 +145,9 @@ "@playwright/test": "1.57.0", "@tailwindcss/vite": "4.1.18", "@tanstack/router-plugin": "1.141.7", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/dagre": "0.7.53", "@types/node": "22.19.3", "@types/react": "19.2.7", @@ -156,6 +160,7 @@ "electron-builder": "26.0.12", "eslint": "9.39.2", "eslint-plugin-react-hooks": "^7.0.1", + "jsdom": "^28.1.0", "tailwindcss": "4.1.18", "tw-animate-css": "1.4.0", "typescript": "5.9.3", @@ -202,6 +207,10 @@ "filter": [ "**/*" ] + }, + { + "from": "public/logo_larger.png", + "to": "logo_larger.png" } ], "mac": { @@ -261,7 +270,12 @@ "maintainer": "webdevcody@gmail.com", "executableName": "automaker", "description": "An autonomous AI development studio that helps you build software faster using AI-powered agents", - "synopsis": "AI-powered autonomous development studio" + "synopsis": "AI-powered autonomous development studio", + "desktop": { + "entry": { + "Icon": "/opt/Automaker/resources/logo_larger.png" + } + } }, "rpm": { "depends": [ @@ -275,7 +289,8 @@ "libuuid" ], "compression": "xz", - "vendor": "AutoMaker Team" + "vendor": "AutoMaker Team", + "afterInstall": "scripts/rpm-after-install.sh" }, "nsis": { "oneClick": false, diff --git a/apps/ui/playwright.config.ts b/apps/ui/playwright.config.ts index 5a56289fa..c1230318c 100644 --- a/apps/ui/playwright.config.ts +++ b/apps/ui/playwright.config.ts @@ -1,28 +1,60 @@ import { defineConfig, devices } from '@playwright/test'; +import path from 'path'; const port = process.env.TEST_PORT || 3107; + +// PATH that includes common git locations so the E2E server can run git (worktree list, etc.) +const pathSeparator = process.platform === 'win32' ? ';' : ':'; +const extraPath = + process.platform === 'win32' + ? [ + process.env.LOCALAPPDATA && `${process.env.LOCALAPPDATA}\\Programs\\Git\\cmd`, + process.env.PROGRAMFILES && `${process.env.PROGRAMFILES}\\Git\\cmd`, + ].filter(Boolean) + : [ + '/opt/homebrew/bin', + '/usr/local/bin', + '/usr/bin', + '/home/linuxbrew/.linuxbrew/bin', + process.env.HOME && `${process.env.HOME}/.local/bin`, + ].filter(Boolean); +const e2eServerPath = [process.env.PATH, ...extraPath].filter(Boolean).join(pathSeparator); const serverPort = process.env.TEST_SERVER_PORT || 3108; +// When true, no webServer is started; you must run UI (port 3107) and server (3108) yourself. const reuseServer = process.env.TEST_REUSE_SERVER === 'true'; -const useExternalBackend = !!process.env.VITE_SERVER_URL; +// Only skip backend startup when explicitly requested for E2E runs. +// VITE_SERVER_URL may be set in user shells for local dev and should not affect tests. +const useExternalBackend = process.env.TEST_USE_EXTERNAL_BACKEND === 'true'; // Always use mock agent for tests (disables rate limiting, uses mock Claude responses) const mockAgent = true; +// Auth state file written by global setup, reused by all tests to skip per-test login +const AUTH_STATE_PATH = path.join(__dirname, 'tests/.auth/storage-state.json'); + export default defineConfig({ testDir: './tests', + // Keep Playwright scoped to E2E specs so Vitest unit files are not executed here. + testMatch: '**/*.spec.ts', + testIgnore: ['**/unit/**'], fullyParallel: true, forbidOnly: !!process.env.CI, - retries: 0, - workers: 1, // Run sequentially to avoid auth conflicts with shared server - reporter: 'html', + retries: process.env.CI ? 2 : 0, + // Use multiple workers for parallelism. CI gets 2 workers (constrained resources), + // local runs use 8 workers for faster test execution. + workers: process.env.CI ? 2 : 8, + reporter: process.env.CI ? 'github' : 'html', timeout: 30000, use: { - baseURL: `http://localhost:${port}`, + baseURL: `http://127.0.0.1:${port}`, trace: 'on-failure', screenshot: 'only-on-failure', serviceWorkers: 'block', + // Reuse auth state from global setup - avoids per-test login overhead + storageState: AUTH_STATE_PATH, }, - // Global setup - authenticate before each test + // Global setup - authenticate once and save state for all workers globalSetup: require.resolve('./tests/global-setup.ts'), + globalTeardown: require.resolve('./tests/global-teardown.ts'), projects: [ { name: 'chromium', @@ -40,13 +72,15 @@ export default defineConfig({ : [ { command: `cd ../server && npm run dev:test`, - url: `http://localhost:${serverPort}/api/health`, + url: `http://127.0.0.1:${serverPort}/api/health`, // Don't reuse existing server to ensure we use the test API key reuseExistingServer: false, timeout: 60000, env: { ...process.env, PORT: String(serverPort), + // Ensure server can find git in CI/minimal env (worktree list, etc.) + PATH: e2eServerPath, // Enable mock agent in CI to avoid real API calls AUTOMAKER_MOCK_AGENT: mockAgent ? 'true' : 'false', // Set a test API key for web mode authentication @@ -59,13 +93,17 @@ export default defineConfig({ ALLOWED_ROOT_DIRECTORY: '', // Simulate containerized environment to skip sandbox confirmation dialogs IS_CONTAINERIZED: 'true', + // Increase Node.js memory limit to prevent OOM during tests + NODE_OPTIONS: [process.env.NODE_OPTIONS, '--max-old-space-size=4096'] + .filter(Boolean) + .join(' '), }, }, ]), // Frontend Vite dev server { command: `npm run dev`, - url: `http://localhost:${port}`, + url: `http://127.0.0.1:${port}`, reuseExistingServer: false, timeout: 120000, env: { @@ -77,6 +115,11 @@ export default defineConfig({ VITE_SKIP_SETUP: 'true', // Always skip electron plugin during tests - prevents duplicate server spawning VITE_SKIP_ELECTRON: 'true', + // Clear VITE_SERVER_URL to force the frontend to use the Vite proxy (/api) + // instead of calling the backend directly. Direct calls bypass the proxy and + // cause cookie domain mismatches (cookies are bound to 127.0.0.1 but + // VITE_SERVER_URL typically uses localhost). + VITE_SERVER_URL: '', }, }, ], diff --git a/apps/ui/scripts/kill-test-servers.mjs b/apps/ui/scripts/kill-test-servers.mjs index dbb8387b7..1dc1e3311 100644 --- a/apps/ui/scripts/kill-test-servers.mjs +++ b/apps/ui/scripts/kill-test-servers.mjs @@ -10,7 +10,9 @@ const execAsync = promisify(exec); const SERVER_PORT = process.env.TEST_SERVER_PORT || 3108; const UI_PORT = process.env.TEST_PORT || 3107; -const USE_EXTERNAL_SERVER = !!process.env.VITE_SERVER_URL; +// Match Playwright config semantics: only explicit opt-in should skip backend startup/cleanup. +// VITE_SERVER_URL may exist in local shells and should not implicitly affect test behavior. +const USE_EXTERNAL_SERVER = process.env.TEST_USE_EXTERNAL_BACKEND === 'true'; console.log(`[KillTestServers] SERVER_PORT ${SERVER_PORT}`); console.log(`[KillTestServers] UI_PORT ${UI_PORT}`); async function killProcessOnPort(port) { diff --git a/apps/ui/scripts/rpm-after-install.sh b/apps/ui/scripts/rpm-after-install.sh new file mode 100644 index 000000000..ee4d95500 --- /dev/null +++ b/apps/ui/scripts/rpm-after-install.sh @@ -0,0 +1,14 @@ +#!/bin/bash +# Set the setuid bit on chrome-sandbox so Electron's sandbox works on systems +# where unprivileged user namespaces are restricted (e.g. hardened kernels). +# On Fedora/RHEL with standard kernel settings this is a safe no-op. +chmod 4755 /opt/Automaker/chrome-sandbox 2>/dev/null || true + +# Refresh the GTK icon cache so GNOME/KDE picks up the newly installed icon +# immediately without requiring a logout. The -f flag forces a rebuild even +# if the cache is up-to-date; -t suppresses the mtime check warning. +gtk-update-icon-cache -f -t /usr/share/icons/hicolor 2>/dev/null || true + +# Rebuild the desktop entry database so the app appears in the app launcher +# straight after install. +update-desktop-database /usr/share/applications 2>/dev/null || true diff --git a/apps/ui/scripts/setup-e2e-fixtures.mjs b/apps/ui/scripts/setup-e2e-fixtures.mjs index 6bfe55bed..3a1becbe0 100644 --- a/apps/ui/scripts/setup-e2e-fixtures.mjs +++ b/apps/ui/scripts/setup-e2e-fixtures.mjs @@ -18,6 +18,8 @@ const __dirname = path.dirname(__filename); const WORKSPACE_ROOT = path.resolve(__dirname, '../../..'); const FIXTURE_PATH = path.join(WORKSPACE_ROOT, 'test/fixtures/projectA'); const SPEC_FILE_PATH = path.join(FIXTURE_PATH, '.automaker/app_spec.txt'); +const CONTEXT_DIR = path.join(FIXTURE_PATH, '.automaker/context'); +const CONTEXT_METADATA_PATH = path.join(CONTEXT_DIR, 'context-metadata.json'); const SERVER_SETTINGS_PATH = path.join(WORKSPACE_ROOT, 'apps/server/data/settings.json'); // Create a shared test workspace directory that will be used as default for project creation const TEST_WORKSPACE_DIR = path.join(os.tmpdir(), 'automaker-e2e-workspace'); @@ -145,6 +147,14 @@ function setupFixtures() { fs.writeFileSync(SPEC_FILE_PATH, SPEC_CONTENT); console.log(`Created fixture file: ${SPEC_FILE_PATH}`); + // Create .automaker/context and context-metadata.json (expected by context view / FS read) + if (!fs.existsSync(CONTEXT_DIR)) { + fs.mkdirSync(CONTEXT_DIR, { recursive: true }); + console.log(`Created directory: ${CONTEXT_DIR}`); + } + fs.writeFileSync(CONTEXT_METADATA_PATH, JSON.stringify({ files: {} }, null, 2)); + console.log(`Created fixture file: ${CONTEXT_METADATA_PATH}`); + // Reset server settings.json to a clean state for E2E tests const settingsDir = path.dirname(SERVER_SETTINGS_PATH); if (!fs.existsSync(settingsDir)) { diff --git a/apps/ui/src/components/dialogs/board-background-modal.tsx b/apps/ui/src/components/dialogs/board-background-modal.tsx index 208d20598..c923e7513 100644 --- a/apps/ui/src/components/dialogs/board-background-modal.tsx +++ b/apps/ui/src/components/dialogs/board-background-modal.tsx @@ -312,7 +312,12 @@ export function BoardBackgroundModal({ open, onOpenChange }: BoardBackgroundModa return ( - + Board Background Settings diff --git a/apps/ui/src/components/dialogs/file-browser-dialog.tsx b/apps/ui/src/components/dialogs/file-browser-dialog.tsx index 61587cf0b..391bfe086 100644 --- a/apps/ui/src/components/dialogs/file-browser-dialog.tsx +++ b/apps/ui/src/components/dialogs/file-browser-dialog.tsx @@ -177,7 +177,7 @@ export function FileBrowserDialog({ onSelect(currentPath); onOpenChange(false); } - }, [currentPath, onSelect, onOpenChange]); + }, [currentPath, onSelect, onOpenChange, addRecentFolder]); // Handle Command/Ctrl+Enter keyboard shortcut to select current folder useEffect(() => { diff --git a/apps/ui/src/components/dialogs/pr-comment-resolution-dialog.tsx b/apps/ui/src/components/dialogs/pr-comment-resolution-dialog.tsx index fd5c01e7e..b64eea987 100644 --- a/apps/ui/src/components/dialogs/pr-comment-resolution-dialog.tsx +++ b/apps/ui/src/components/dialogs/pr-comment-resolution-dialog.tsx @@ -37,7 +37,7 @@ import { Switch } from '@/components/ui/switch'; import { Label } from '@/components/ui/label'; import { Spinner } from '@/components/ui/spinner'; import { Markdown } from '@/components/ui/markdown'; -import { cn, modelSupportsThinking, generateUUID } from '@/lib/utils'; +import { cn, generateUUID, normalizeModelEntry } from '@/lib/utils'; import { useAppStore } from '@/store/app-store'; import { useGitHubPRReviewComments } from '@/hooks/queries'; import { useCreateFeature, useResolveReviewThread } from '@/hooks/mutations'; @@ -45,7 +45,7 @@ import { toast } from 'sonner'; import type { PRReviewComment } from '@/lib/electron'; import type { Feature } from '@/store/app-store'; import type { PhaseModelEntry } from '@automaker/types'; -import { supportsReasoningEffort, normalizeThinkingLevelForModel } from '@automaker/types'; +import { normalizeThinkingLevelForModel } from '@automaker/types'; import { resolveModelString } from '@automaker/model-resolver'; import { PhaseModelSelector } from '@/components/views/settings-view/model-defaults'; @@ -62,6 +62,8 @@ export interface PRCommentResolutionPRInfo { title: string; /** The branch name (headRefName) associated with this PR, used to assign features to the correct worktree */ headRefName?: string; + /** The URL of the PR, used to set prUrl on created features */ + url?: string; } interface PRCommentResolutionDialogProps { @@ -730,14 +732,9 @@ export function PRCommentResolutionDialog({ const selectedComments = comments.filter((c) => selectedIds.has(c.id)); - // Resolve model settings from the current model entry - const selectedModel = resolveModelString(modelEntry.model); - const normalizedThinking = modelSupportsThinking(selectedModel) - ? modelEntry.thinkingLevel || 'none' - : 'none'; - const normalizedReasoning = supportsReasoningEffort(selectedModel) - ? modelEntry.reasoningEffort || 'none' - : 'none'; + // Resolve and normalize model settings + const normalizedEntry = normalizeModelEntry(modelEntry); + const selectedModel = resolveModelString(normalizedEntry.model); setIsCreating(true); setCreationErrors([]); @@ -753,8 +750,13 @@ export function PRCommentResolutionDialog({ steps: [], status: 'backlog', model: selectedModel, - thinkingLevel: normalizedThinking, - reasoningEffort: normalizedReasoning, + thinkingLevel: normalizedEntry.thinkingLevel, + reasoningEffort: normalizedEntry.reasoningEffort, + providerId: normalizedEntry.providerId, + planningMode: 'skip', + requirePlanApproval: false, + dependencies: [], + ...(pr.url ? { prUrl: pr.url } : {}), // Associate feature with the PR's branch so it appears on the correct worktree ...(pr.headRefName ? { branchName: pr.headRefName } : {}), }; @@ -779,8 +781,13 @@ export function PRCommentResolutionDialog({ steps: [], status: 'backlog', model: selectedModel, - thinkingLevel: normalizedThinking, - reasoningEffort: normalizedReasoning, + thinkingLevel: normalizedEntry.thinkingLevel, + reasoningEffort: normalizedEntry.reasoningEffort, + providerId: normalizedEntry.providerId, + planningMode: 'skip', + requirePlanApproval: false, + dependencies: [], + ...(pr.url ? { prUrl: pr.url } : {}), // Associate feature with the PR's branch so it appears on the correct worktree ...(pr.headRefName ? { branchName: pr.headRefName } : {}), }; diff --git a/apps/ui/src/components/layout/project-switcher/components/notification-bell.tsx b/apps/ui/src/components/layout/project-switcher/components/notification-bell.tsx index 8217865df..8145f971e 100644 --- a/apps/ui/src/components/layout/project-switcher/components/notification-bell.tsx +++ b/apps/ui/src/components/layout/project-switcher/components/notification-bell.tsx @@ -3,7 +3,7 @@ */ import { useCallback } from 'react'; -import { Bell, Check, Trash2 } from 'lucide-react'; +import { Bell, Check, Trash2, AlertCircle } from 'lucide-react'; import { useNavigate } from '@tanstack/react-router'; import { useNotificationsStore } from '@/store/notifications-store'; import { useLoadNotifications, useNotificationEvents } from '@/hooks/use-notification-events'; @@ -11,25 +11,7 @@ import { getHttpApiClient } from '@/lib/http-api-client'; import { Button } from '@/components/ui/button'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import type { Notification } from '@automaker/types'; -import { cn } from '@/lib/utils'; - -/** - * Format a date as relative time (e.g., "2 minutes ago", "3 hours ago") - */ -function formatRelativeTime(date: Date): string { - const now = new Date(); - const diffMs = now.getTime() - date.getTime(); - const diffSec = Math.floor(diffMs / 1000); - const diffMin = Math.floor(diffSec / 60); - const diffHour = Math.floor(diffMin / 60); - const diffDay = Math.floor(diffHour / 24); - - if (diffSec < 60) return 'just now'; - if (diffMin < 60) return `${diffMin} minute${diffMin === 1 ? '' : 's'} ago`; - if (diffHour < 24) return `${diffHour} hour${diffHour === 1 ? '' : 's'} ago`; - if (diffDay < 7) return `${diffDay} day${diffDay === 1 ? '' : 's'} ago`; - return date.toLocaleDateString(); -} +import { cn, formatRelativeTime } from '@/lib/utils'; interface NotificationBellProps { projectPath: string | null; @@ -86,7 +68,7 @@ export function NotificationBell({ projectPath }: NotificationBellProps) { // Navigate to the relevant view based on notification type if (notification.featureId) { - navigate({ to: '/board' }); + navigate({ to: '/board', search: { featureId: notification.featureId } }); } }, [handleMarkAsRead, setPopoverOpen, navigate] @@ -105,6 +87,10 @@ export function NotificationBell({ projectPath }: NotificationBellProps) { return ; case 'spec_regeneration_complete': return ; + case 'feature_error': + return ; + case 'auto_mode_error': + return ; default: return ; } diff --git a/apps/ui/src/components/layout/sidebar/components/project-selector-with-options.tsx b/apps/ui/src/components/layout/sidebar/components/project-selector-with-options.tsx index 2c62c6344..a1c6f8c5f 100644 --- a/apps/ui/src/components/layout/sidebar/components/project-selector-with-options.tsx +++ b/apps/ui/src/components/layout/sidebar/components/project-selector-with-options.tsx @@ -17,6 +17,7 @@ import { import { cn } from '@/lib/utils'; import { formatShortcut, type ThemeMode, useAppStore } from '@/store/app-store'; import { initializeProject } from '@/lib/project-init'; +import { toast } from 'sonner'; import type { Project } from '@/lib/electron'; import { DropdownMenu, @@ -88,21 +89,21 @@ export function ProjectSelectorWithOptions({ const clearProjectHistory = useAppStore((s) => s.clearProjectHistory); const shortcuts = useKeyboardShortcutsConfig(); - // Wrap setCurrentProject to ensure .automaker is initialized before switching + // Wrap setCurrentProject to initialize .automaker in background while switching const setCurrentProjectWithInit = useCallback( - async (p: Project) => { + (p: Project) => { if (p.id === currentProject?.id) { return; } - try { - // Ensure .automaker directory structure exists before switching - await initializeProject(p.path); - } catch (error) { + // Fire-and-forget: initialize .automaker directory structure in background + // so the project switch is not blocked by filesystem operations + initializeProject(p.path).catch((error) => { console.error('Failed to initialize project during switch:', error); - // Continue with switch even if initialization fails - - // the project may already be initialized - } - // Defer project switch update to avoid synchronous render cascades. + toast.error('Failed to initialize project .automaker', { + description: error instanceof Error ? error.message : String(error), + }); + }); + // Switch project immediately for instant UI response startTransition(() => { setCurrentProject(p); }); @@ -131,8 +132,8 @@ export function ProjectSelectorWithOptions({ useProjectTheme(); const handleSelectProject = useCallback( - async (p: Project) => { - await setCurrentProjectWithInit(p); + (p: Project) => { + setCurrentProjectWithInit(p); setIsProjectPickerOpen(false); }, [setCurrentProjectWithInit, setIsProjectPickerOpen] diff --git a/apps/ui/src/components/layout/sidebar/hooks/use-navigation.ts b/apps/ui/src/components/layout/sidebar/hooks/use-navigation.ts index 542dfb882..42ab2efbf 100644 --- a/apps/ui/src/components/layout/sidebar/hooks/use-navigation.ts +++ b/apps/ui/src/components/layout/sidebar/hooks/use-navigation.ts @@ -67,7 +67,7 @@ export function useNavigation({ hideContext, hideTerminal, currentProject, - projects, + projects: _projects, projectHistory, navigate, toggleSidebar, @@ -325,7 +325,6 @@ export function useNavigation({ currentProject, navigate, toggleSidebar, - projects.length, handleOpenFolder, projectHistory.length, cyclePrevProject, diff --git a/apps/ui/src/components/session-manager.tsx b/apps/ui/src/components/session-manager.tsx index 49958c956..b7a1dec0e 100644 --- a/apps/ui/src/components/session-manager.tsx +++ b/apps/ui/src/components/session-manager.tsx @@ -195,8 +195,10 @@ export function SessionManager({ if (result.success && result.session?.id) { setNewSessionName(''); setIsCreating(false); - await invalidateSessions(); + // Select the new session immediately before invalidating the cache to avoid + // a race condition where the cache re-render resets the selected session. onSelectSession(result.session.id); + await invalidateSessions(); } }; @@ -210,8 +212,10 @@ export function SessionManager({ const result = await api.sessions.create(sessionName, projectPath, effectiveWorkingDirectory); if (result.success && result.session?.id) { - await invalidateSessions(); + // Select the new session immediately before invalidating the cache to avoid + // a race condition where the cache re-render resets the selected session. onSelectSession(result.session.id); + await invalidateSessions(); } }, [effectiveWorkingDirectory, projectPath, invalidateSessions, onSelectSession]); diff --git a/apps/ui/src/components/shared/model-override-trigger.tsx b/apps/ui/src/components/shared/model-override-trigger.tsx index 2c21ecea8..6071831f7 100644 --- a/apps/ui/src/components/shared/model-override-trigger.tsx +++ b/apps/ui/src/components/shared/model-override-trigger.tsx @@ -48,12 +48,13 @@ export function ModelOverrideTrigger({ const globalDefault = phaseModels[phase]; const normalizedGlobal = normalizeEntry(globalDefault); - // Compare models (and thinking levels if both have them) + // Compare models, thinking levels, and provider IDs const modelsMatch = entry.model === normalizedGlobal.model; const thinkingMatch = (entry.thinkingLevel || 'none') === (normalizedGlobal.thinkingLevel || 'none'); + const providerMatch = entry.providerId === normalizedGlobal.providerId; - if (modelsMatch && thinkingMatch) { + if (modelsMatch && thinkingMatch && providerMatch) { onModelChange(null); // Clear override } else { onModelChange(entry); // Set override diff --git a/apps/ui/src/components/ui/description-image-dropzone.tsx b/apps/ui/src/components/ui/description-image-dropzone.tsx index 7b67fd9b0..de76ffcf6 100644 --- a/apps/ui/src/components/ui/description-image-dropzone.tsx +++ b/apps/ui/src/components/ui/description-image-dropzone.tsx @@ -244,6 +244,7 @@ export function DescriptionImageDropZone({ onTextFilesChange, previewImages, saveImageToTemp, + setPreviewImages, ] ); @@ -309,7 +310,7 @@ export function DescriptionImageDropZone({ return newMap; }); }, - [images, onImagesChange] + [images, onImagesChange, setPreviewImages] ); const removeTextFile = useCallback( diff --git a/apps/ui/src/components/ui/git-diff-panel.tsx b/apps/ui/src/components/ui/git-diff-panel.tsx index 12c4ae05c..fd6735926 100644 --- a/apps/ui/src/components/ui/git-diff-panel.tsx +++ b/apps/ui/src/components/ui/git-diff-panel.tsx @@ -384,7 +384,8 @@ export function GitDiffPanel({ const queryError = useWorktrees ? worktreeError : gitError; // Extract files, diff content, and merge state from the data - const files: FileStatus[] = diffsData?.files ?? []; + // Use useMemo to stabilize the files array reference to prevent unnecessary re-renders + const files = useMemo(() => diffsData?.files ?? [], [diffsData?.files]); const diffContent = diffsData?.diff ?? ''; const mergeState: MergeStateInfo | undefined = diffsData?.mergeState; const error = queryError @@ -584,7 +585,7 @@ export function GitDiffPanel({ () => setStagingInProgress(new Set(allPaths)), () => setStagingInProgress(new Set()) ); - }, [worktreePath, projectPath, useWorktrees, enableStaging, files, executeStagingAction]); + }, [worktreePath, useWorktrees, enableStaging, files, executeStagingAction]); const handleUnstageAll = useCallback(async () => { const stagedFiles = files.filter((f) => { @@ -607,7 +608,7 @@ export function GitDiffPanel({ () => setStagingInProgress(new Set(allPaths)), () => setStagingInProgress(new Set()) ); - }, [worktreePath, projectPath, useWorktrees, enableStaging, files, executeStagingAction]); + }, [worktreePath, useWorktrees, enableStaging, files, executeStagingAction]); // Compute merge summary const mergeSummary = useMemo(() => { diff --git a/apps/ui/src/components/ui/header-actions-panel.tsx b/apps/ui/src/components/ui/header-actions-panel.tsx index 708652b6f..1795dcb52 100644 --- a/apps/ui/src/components/ui/header-actions-panel.tsx +++ b/apps/ui/src/components/ui/header-actions-panel.tsx @@ -98,6 +98,7 @@ export function HeaderActionsPanelTrigger({ onClick={onToggle} className={cn('h-8 w-8 p-0 text-muted-foreground hover:text-foreground lg:hidden', className)} aria-label={isOpen ? 'Close actions menu' : 'Open actions menu'} + data-testid="header-actions-panel-trigger" > {isOpen ? : } diff --git a/apps/ui/src/components/ui/sheet.tsx b/apps/ui/src/components/ui/sheet.tsx index 08e4d70c1..fe58101eb 100644 --- a/apps/ui/src/components/ui/sheet.tsx +++ b/apps/ui/src/components/ui/sheet.tsx @@ -57,6 +57,8 @@ const SheetContent = ({ className, children, side = 'right', ...props }: SheetCo const Close = SheetPrimitive.Close as React.ComponentType<{ className: string; children: React.ReactNode; + 'data-slot'?: string; + style?: React.CSSProperties; }>; return ( @@ -79,7 +81,13 @@ const SheetContent = ({ className, children, side = 'right', ...props }: SheetCo {...props} > {children} - + Close diff --git a/apps/ui/src/components/ui/spinner.tsx b/apps/ui/src/components/ui/spinner.tsx index d515dc7b9..5cf930560 100644 --- a/apps/ui/src/components/ui/spinner.tsx +++ b/apps/ui/src/components/ui/spinner.tsx @@ -37,6 +37,7 @@ export function Spinner({ size = 'md', variant = 'primary', className }: Spinner