From 0a1c2cd53c3b510069bb9690ff6307ea6d8ab44b Mon Sep 17 00:00:00 2001 From: Shirone Date: Tue, 27 Jan 2026 18:50:20 +0100 Subject: [PATCH] test(05-02): add ExecutionService unit tests - Add 45 unit tests for execution lifecycle coordination - Test constructor, executeFeature, stopFeature, buildFeaturePrompt - Test approved plan handling, error handling, worktree resolution - Test auto-mode integration, planning mode, summary extraction Co-Authored-By: Claude Opus 4.5 --- .../unit/services/execution-service.test.ts | 1050 +++++++++++++++++ 1 file changed, 1050 insertions(+) create mode 100644 apps/server/tests/unit/services/execution-service.test.ts diff --git a/apps/server/tests/unit/services/execution-service.test.ts b/apps/server/tests/unit/services/execution-service.test.ts new file mode 100644 index 00000000..91445c5f --- /dev/null +++ b/apps/server/tests/unit/services/execution-service.test.ts @@ -0,0 +1,1050 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import type { Feature } from '@automaker/types'; +import { + ExecutionService, + type RunAgentFn, + type ExecutePipelineFn, + type UpdateFeatureStatusFn, + type LoadFeatureFn, + type GetPlanningPromptPrefixFn, + type SaveFeatureSummaryFn, + type RecordLearningsFn, + type ContextExistsFn, + type ResumeFeatureFn, + type TrackFailureFn, + type SignalPauseFn, + type RecordSuccessFn, +} from '../../../src/services/execution-service.js'; +import type { TypedEventBus } from '../../../src/services/typed-event-bus.js'; +import type { + ConcurrencyManager, + RunningFeature, +} from '../../../src/services/concurrency-manager.js'; +import type { WorktreeResolver } from '../../../src/services/worktree-resolver.js'; +import type { SettingsService } from '../../../src/services/settings-service.js'; +import { pipelineService } from '../../../src/services/pipeline-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'; +import { extractSummary } from '../../../src/services/spec-parser.js'; +import { resolveModelString } from '@automaker/model-resolver'; + +// Mock pipelineService +vi.mock('../../../src/services/pipeline-service.js', () => ({ + pipelineService: { + getPipelineConfig: vi.fn(), + isPipelineStatus: vi.fn(), + getStepIdFromStatus: vi.fn(), + }, +})); + +// Mock secureFs +vi.mock('../../../src/lib/secure-fs.js', () => ({ + readFile: vi.fn(), + writeFile: vi.fn(), + mkdir: 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', + continuationAfterApprovalTemplate: + '{{userFeedback}}\n\nApproved plan:\n{{approvedPlan}}\n\nProceed.', + }, + }), + getAutoLoadClaudeMdSetting: vi.fn().mockResolvedValue(true), + filterClaudeMdFromContext: vi.fn().mockReturnValue('context prompt'), +})); + +// Mock sdk-options +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' }, +})); + +// Mock provider-factory +vi.mock('../../../src/providers/provider-factory.js', () => ({ + ProviderFactory: { + getProviderNameForModel: vi.fn().mockReturnValue('anthropic'), + }, +})); + +// Mock spec-parser +vi.mock('../../../src/services/spec-parser.js', () => ({ + extractSummary: vi.fn().mockReturnValue('Test summary'), +})); + +// Mock @automaker/utils +vi.mock('@automaker/utils', () => ({ + createLogger: vi.fn().mockReturnValue({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }), + classifyError: vi.fn((error: unknown) => { + const err = error as Error | null; + if (err?.name === 'AbortError' || err?.message?.includes('abort')) { + return { isAbort: true, type: 'abort', message: 'Aborted' }; + } + return { isAbort: false, type: 'unknown', message: err?.message || 'Unknown error' }; + }), + loadContextFiles: vi.fn(), + recordMemoryUsage: vi.fn().mockResolvedValue(undefined), +})); + +describe('execution-service.ts', () => { + // Mock dependencies + let mockEventBus: TypedEventBus; + let mockConcurrencyManager: ConcurrencyManager; + let mockWorktreeResolver: WorktreeResolver; + let mockSettingsService: SettingsService | null; + + // Callback mocks + let mockRunAgentFn: RunAgentFn; + let mockExecutePipelineFn: ExecutePipelineFn; + let mockUpdateFeatureStatusFn: UpdateFeatureStatusFn; + let mockLoadFeatureFn: LoadFeatureFn; + let mockGetPlanningPromptPrefixFn: GetPlanningPromptPrefixFn; + let mockSaveFeatureSummaryFn: SaveFeatureSummaryFn; + let mockRecordLearningsFn: RecordLearningsFn; + let mockContextExistsFn: ContextExistsFn; + let mockResumeFeatureFn: ResumeFeatureFn; + let mockTrackFailureFn: TrackFailureFn; + let mockSignalPauseFn: SignalPauseFn; + let mockRecordSuccessFn: RecordSuccessFn; + let mockSaveExecutionStateFn: vi.Mock; + let mockLoadContextFilesFn: vi.Mock; + + let service: ExecutionService; + + // Test data + const testFeature: Feature = { + id: 'feature-1', + title: 'Test Feature', + category: 'test', + description: 'Test description', + status: 'backlog', + branchName: 'feature/test-1', + }; + + const createRunningFeature = (featureId: string): RunningFeature => ({ + featureId, + projectPath: '/test/project', + worktreePath: null, + branchName: null, + abortController: new AbortController(), + isAutoMode: false, + startTime: Date.now(), + leaseCount: 1, + }); + + beforeEach(() => { + vi.clearAllMocks(); + + mockEventBus = { + emitAutoModeEvent: vi.fn(), + } as unknown as TypedEventBus; + + mockConcurrencyManager = { + acquire: vi.fn().mockImplementation(({ featureId }) => createRunningFeature(featureId)), + release: vi.fn(), + getRunningFeature: vi.fn(), + isRunning: vi.fn(), + } as unknown as ConcurrencyManager; + + mockWorktreeResolver = { + findWorktreeForBranch: vi.fn().mockResolvedValue('/test/worktree'), + } as unknown as WorktreeResolver; + + mockSettingsService = null; + + mockRunAgentFn = vi.fn().mockResolvedValue(undefined); + mockExecutePipelineFn = vi.fn().mockResolvedValue(undefined); + mockUpdateFeatureStatusFn = vi.fn().mockResolvedValue(undefined); + mockLoadFeatureFn = vi.fn().mockResolvedValue(testFeature); + mockGetPlanningPromptPrefixFn = vi.fn().mockResolvedValue(''); + mockSaveFeatureSummaryFn = vi.fn().mockResolvedValue(undefined); + mockRecordLearningsFn = vi.fn().mockResolvedValue(undefined); + mockContextExistsFn = vi.fn().mockResolvedValue(false); + mockResumeFeatureFn = vi.fn().mockResolvedValue(undefined); + mockTrackFailureFn = vi.fn().mockReturnValue(false); + mockSignalPauseFn = vi.fn(); + mockRecordSuccessFn = vi.fn(); + mockSaveExecutionStateFn = vi.fn().mockResolvedValue(undefined); + mockLoadContextFilesFn = vi.fn().mockResolvedValue({ + formattedPrompt: 'test context', + memoryFiles: [], + }); + + // Default mocks for secureFs + vi.mocked(secureFs.readFile).mockResolvedValue('Agent output content'); + vi.mocked(secureFs.access).mockResolvedValue(undefined); + + // Re-setup platform mocks + vi.mocked(getFeatureDir).mockImplementation( + (projectPath: string, featureId: string) => `${projectPath}/.automaker/features/${featureId}` + ); + + // Default pipeline config (no steps) + vi.mocked(pipelineService.getPipelineConfig).mockResolvedValue({ version: 1, steps: [] }); + + // Re-setup settings helpers mocks (vi.clearAllMocks clears implementations) + vi.mocked(getPromptCustomization).mockResolvedValue({ + taskExecution: { + implementationInstructions: 'test instructions', + playwrightVerificationInstructions: 'test playwright', + continuationAfterApprovalTemplate: + '{{userFeedback}}\n\nApproved plan:\n{{approvedPlan}}\n\nProceed.', + }, + } as Awaited>); + vi.mocked(getAutoLoadClaudeMdSetting).mockResolvedValue(true); + vi.mocked(filterClaudeMdFromContext).mockReturnValue('context prompt'); + + // Re-setup spec-parser mock + vi.mocked(extractSummary).mockReturnValue('Test summary'); + + // Re-setup model-resolver mock + vi.mocked(resolveModelString).mockReturnValue('claude-sonnet-4'); + + service = new ExecutionService( + mockEventBus, + mockConcurrencyManager, + mockWorktreeResolver, + mockSettingsService, + mockRunAgentFn, + mockExecutePipelineFn, + mockUpdateFeatureStatusFn, + mockLoadFeatureFn, + mockGetPlanningPromptPrefixFn, + mockSaveFeatureSummaryFn, + mockRecordLearningsFn, + mockContextExistsFn, + mockResumeFeatureFn, + mockTrackFailureFn, + mockSignalPauseFn, + mockRecordSuccessFn, + mockSaveExecutionStateFn, + mockLoadContextFilesFn + ); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + describe('constructor', () => { + it('creates service with all dependencies', () => { + expect(service).toBeInstanceOf(ExecutionService); + }); + + it('accepts null settingsService', () => { + const svc = new ExecutionService( + mockEventBus, + mockConcurrencyManager, + mockWorktreeResolver, + null, + mockRunAgentFn, + mockExecutePipelineFn, + mockUpdateFeatureStatusFn, + mockLoadFeatureFn, + mockGetPlanningPromptPrefixFn, + mockSaveFeatureSummaryFn, + mockRecordLearningsFn, + mockContextExistsFn, + mockResumeFeatureFn, + mockTrackFailureFn, + mockSignalPauseFn, + mockRecordSuccessFn, + mockSaveExecutionStateFn, + mockLoadContextFilesFn + ); + expect(svc).toBeInstanceOf(ExecutionService); + }); + }); + + describe('buildFeaturePrompt', () => { + const taskPrompts = { + implementationInstructions: 'impl instructions', + playwrightVerificationInstructions: 'playwright instructions', + }; + + it('includes feature title and description', () => { + const prompt = service.buildFeaturePrompt(testFeature, taskPrompts); + expect(prompt).toContain('**Feature ID:** feature-1'); + expect(prompt).toContain('Test description'); + }); + + it('includes specification when present', () => { + const featureWithSpec: Feature = { + ...testFeature, + spec: 'Detailed specification here', + }; + const prompt = service.buildFeaturePrompt(featureWithSpec, taskPrompts); + expect(prompt).toContain('**Specification:**'); + expect(prompt).toContain('Detailed specification here'); + }); + + it('includes acceptance criteria from task prompts', () => { + const prompt = service.buildFeaturePrompt(testFeature, taskPrompts); + expect(prompt).toContain('impl instructions'); + }); + + it('adds playwright instructions when skipTests is false', () => { + const featureWithTests: Feature = { ...testFeature, skipTests: false }; + const prompt = service.buildFeaturePrompt(featureWithTests, taskPrompts); + expect(prompt).toContain('playwright instructions'); + }); + + it('omits playwright instructions when skipTests is true', () => { + const featureWithoutTests: Feature = { ...testFeature, skipTests: true }; + const prompt = service.buildFeaturePrompt(featureWithoutTests, taskPrompts); + expect(prompt).not.toContain('playwright instructions'); + }); + + it('includes images note when imagePaths present', () => { + const featureWithImages: Feature = { + ...testFeature, + imagePaths: ['/path/to/image.png', { path: '/path/to/image2.jpg', mimeType: 'image/jpeg' }], + }; + const prompt = service.buildFeaturePrompt(featureWithImages, taskPrompts); + expect(prompt).toContain('Context Images Attached:'); + expect(prompt).toContain('2 image(s)'); + }); + + it('extracts title from first line of description', () => { + const featureWithLongDesc: Feature = { + ...testFeature, + description: 'First line title\nRest of description', + }; + const prompt = service.buildFeaturePrompt(featureWithLongDesc, taskPrompts); + expect(prompt).toContain('**Title:** First line title'); + }); + + it('truncates long titles to 60 characters', () => { + const longDescription = 'A'.repeat(100); + const featureWithLongTitle: Feature = { + ...testFeature, + description: longDescription, + }; + const prompt = service.buildFeaturePrompt(featureWithLongTitle, taskPrompts); + expect(prompt).toContain('**Title:** ' + 'A'.repeat(57) + '...'); + }); + }); + + describe('executeFeature', () => { + it('throws if feature not found', async () => { + mockLoadFeatureFn = vi.fn().mockResolvedValue(null); + const svc = new ExecutionService( + mockEventBus, + mockConcurrencyManager, + mockWorktreeResolver, + mockSettingsService, + mockRunAgentFn, + mockExecutePipelineFn, + mockUpdateFeatureStatusFn, + mockLoadFeatureFn, + mockGetPlanningPromptPrefixFn, + mockSaveFeatureSummaryFn, + mockRecordLearningsFn, + mockContextExistsFn, + mockResumeFeatureFn, + mockTrackFailureFn, + mockSignalPauseFn, + mockRecordSuccessFn, + mockSaveExecutionStateFn, + mockLoadContextFilesFn + ); + + await svc.executeFeature('/test/project', 'nonexistent'); + + // Error event should be emitted + expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith( + 'auto_mode_error', + expect.objectContaining({ featureId: 'nonexistent' }) + ); + }); + + it('acquires running feature slot', async () => { + await service.executeFeature('/test/project', 'feature-1'); + + expect(mockConcurrencyManager.acquire).toHaveBeenCalledWith( + expect.objectContaining({ + featureId: 'feature-1', + projectPath: '/test/project', + }) + ); + }); + + it('updates status to in_progress before starting', async () => { + await service.executeFeature('/test/project', 'feature-1'); + + expect(mockUpdateFeatureStatusFn).toHaveBeenCalledWith( + '/test/project', + 'feature-1', + 'in_progress' + ); + }); + + it('emits feature_start event after status update', async () => { + await service.executeFeature('/test/project', 'feature-1'); + + expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith( + 'auto_mode_feature_start', + expect.objectContaining({ + featureId: 'feature-1', + projectPath: '/test/project', + }) + ); + + // Verify order: status update happens before event + const statusCallIndex = mockUpdateFeatureStatusFn.mock.invocationCallOrder[0]; + const eventCallIndex = mockEventBus.emitAutoModeEvent.mock.invocationCallOrder[0]; + expect(statusCallIndex).toBeLessThan(eventCallIndex); + }); + + it('runs agent with correct prompt', async () => { + await service.executeFeature('/test/project', 'feature-1'); + + expect(mockRunAgentFn).toHaveBeenCalled(); + 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[3]).toBeInstanceOf(AbortController); + expect(callArgs[4]).toBe('/test/project'); + // Model (index 6) should be resolved + expect(callArgs[6]).toBe('claude-sonnet-4'); + }); + + 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({ + version: 1, + steps: pipelineSteps as any, + }); + + await service.executeFeature('/test/project', 'feature-1'); + + // Agent runs first + expect(mockRunAgentFn).toHaveBeenCalled(); + // Then pipeline executes + expect(mockExecutePipelineFn).toHaveBeenCalledWith( + expect.objectContaining({ + projectPath: '/test/project', + featureId: 'feature-1', + steps: pipelineSteps, + }) + ); + }); + + it('updates status to verified on completion', async () => { + await service.executeFeature('/test/project', 'feature-1'); + + expect(mockUpdateFeatureStatusFn).toHaveBeenCalledWith( + '/test/project', + 'feature-1', + 'verified' + ); + }); + + it('updates status to waiting_approval when skipTests is true', async () => { + mockLoadFeatureFn = vi.fn().mockResolvedValue({ ...testFeature, skipTests: true }); + const svc = new ExecutionService( + mockEventBus, + mockConcurrencyManager, + mockWorktreeResolver, + mockSettingsService, + mockRunAgentFn, + mockExecutePipelineFn, + mockUpdateFeatureStatusFn, + mockLoadFeatureFn, + mockGetPlanningPromptPrefixFn, + mockSaveFeatureSummaryFn, + mockRecordLearningsFn, + mockContextExistsFn, + mockResumeFeatureFn, + mockTrackFailureFn, + mockSignalPauseFn, + mockRecordSuccessFn, + mockSaveExecutionStateFn, + mockLoadContextFilesFn + ); + + await svc.executeFeature('/test/project', 'feature-1'); + + expect(mockUpdateFeatureStatusFn).toHaveBeenCalledWith( + '/test/project', + 'feature-1', + 'waiting_approval' + ); + }); + + it('records success on completion', async () => { + await service.executeFeature('/test/project', 'feature-1'); + + expect(mockRecordSuccessFn).toHaveBeenCalled(); + }); + + it('releases running feature in finally block', async () => { + await service.executeFeature('/test/project', 'feature-1'); + + expect(mockConcurrencyManager.release).toHaveBeenCalledWith('feature-1', undefined); + }); + + it('redirects to resumeFeature when context exists', async () => { + mockContextExistsFn = vi.fn().mockResolvedValue(true); + const svc = new ExecutionService( + mockEventBus, + mockConcurrencyManager, + mockWorktreeResolver, + mockSettingsService, + mockRunAgentFn, + mockExecutePipelineFn, + mockUpdateFeatureStatusFn, + mockLoadFeatureFn, + mockGetPlanningPromptPrefixFn, + mockSaveFeatureSummaryFn, + mockRecordLearningsFn, + mockContextExistsFn, + mockResumeFeatureFn, + mockTrackFailureFn, + mockSignalPauseFn, + mockRecordSuccessFn, + mockSaveExecutionStateFn, + mockLoadContextFilesFn + ); + + await svc.executeFeature('/test/project', 'feature-1', true); + + expect(mockResumeFeatureFn).toHaveBeenCalledWith('/test/project', 'feature-1', true, true); + // Should not run agent + expect(mockRunAgentFn).not.toHaveBeenCalled(); + }); + + it('emits feature_complete event on success', async () => { + await service.executeFeature('/test/project', 'feature-1'); + + expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith( + 'auto_mode_feature_complete', + expect.objectContaining({ + featureId: 'feature-1', + passes: true, + }) + ); + }); + }); + + describe('executeFeature - approved plan handling', () => { + it('builds continuation prompt for approved plan', async () => { + const featureWithApprovedPlan: Feature = { + ...testFeature, + planSpec: { status: 'approved', content: 'The approved plan content' }, + }; + mockLoadFeatureFn = vi.fn().mockResolvedValue(featureWithApprovedPlan); + + const svc = new ExecutionService( + mockEventBus, + mockConcurrencyManager, + mockWorktreeResolver, + mockSettingsService, + mockRunAgentFn, + mockExecutePipelineFn, + mockUpdateFeatureStatusFn, + mockLoadFeatureFn, + mockGetPlanningPromptPrefixFn, + mockSaveFeatureSummaryFn, + mockRecordLearningsFn, + mockContextExistsFn, + mockResumeFeatureFn, + mockTrackFailureFn, + mockSignalPauseFn, + mockRecordSuccessFn, + mockSaveExecutionStateFn, + mockLoadContextFilesFn + ); + + await svc.executeFeature('/test/project', 'feature-1'); + + // Agent should be called with continuation prompt + expect(mockRunAgentFn).toHaveBeenCalled(); + const callArgs = mockRunAgentFn.mock.calls[0]; + expect(callArgs[1]).toBe('feature-1'); + expect(callArgs[2]).toContain('The approved plan content'); + }); + + it('recursively calls executeFeature with continuation', async () => { + const featureWithApprovedPlan: Feature = { + ...testFeature, + planSpec: { status: 'approved', content: 'Plan' }, + }; + mockLoadFeatureFn = vi.fn().mockResolvedValue(featureWithApprovedPlan); + + const svc = new ExecutionService( + mockEventBus, + mockConcurrencyManager, + mockWorktreeResolver, + mockSettingsService, + mockRunAgentFn, + mockExecutePipelineFn, + mockUpdateFeatureStatusFn, + mockLoadFeatureFn, + mockGetPlanningPromptPrefixFn, + mockSaveFeatureSummaryFn, + mockRecordLearningsFn, + mockContextExistsFn, + mockResumeFeatureFn, + mockTrackFailureFn, + mockSignalPauseFn, + mockRecordSuccessFn, + mockSaveExecutionStateFn, + mockLoadContextFilesFn + ); + + await svc.executeFeature('/test/project', 'feature-1'); + + // acquire should be called twice - once for initial, once for recursive + expect(mockConcurrencyManager.acquire).toHaveBeenCalledTimes(2); + // Second call should have allowReuse: true + expect(mockConcurrencyManager.acquire).toHaveBeenLastCalledWith( + expect.objectContaining({ allowReuse: true }) + ); + }); + + it('skips contextExists check when continuation prompt provided', async () => { + // Feature has context AND approved plan, but continuation prompt is provided + const featureWithApprovedPlan: Feature = { + ...testFeature, + planSpec: { status: 'approved', content: 'Plan' }, + }; + mockLoadFeatureFn = vi.fn().mockResolvedValue(featureWithApprovedPlan); + mockContextExistsFn = vi.fn().mockResolvedValue(true); + + const svc = new ExecutionService( + mockEventBus, + mockConcurrencyManager, + mockWorktreeResolver, + mockSettingsService, + mockRunAgentFn, + mockExecutePipelineFn, + mockUpdateFeatureStatusFn, + mockLoadFeatureFn, + mockGetPlanningPromptPrefixFn, + mockSaveFeatureSummaryFn, + mockRecordLearningsFn, + mockContextExistsFn, + mockResumeFeatureFn, + mockTrackFailureFn, + mockSignalPauseFn, + mockRecordSuccessFn, + mockSaveExecutionStateFn, + mockLoadContextFilesFn + ); + + await svc.executeFeature('/test/project', 'feature-1'); + + // resumeFeature should NOT be called even though context exists + // because we're going through approved plan flow + expect(mockResumeFeatureFn).not.toHaveBeenCalled(); + }); + }); + + describe('executeFeature - error handling', () => { + it('classifies and emits error event', async () => { + const testError = new Error('Test error'); + mockRunAgentFn = vi.fn().mockRejectedValue(testError); + const svc = new ExecutionService( + mockEventBus, + mockConcurrencyManager, + mockWorktreeResolver, + mockSettingsService, + mockRunAgentFn, + mockExecutePipelineFn, + mockUpdateFeatureStatusFn, + mockLoadFeatureFn, + mockGetPlanningPromptPrefixFn, + mockSaveFeatureSummaryFn, + mockRecordLearningsFn, + mockContextExistsFn, + mockResumeFeatureFn, + mockTrackFailureFn, + mockSignalPauseFn, + mockRecordSuccessFn, + mockSaveExecutionStateFn, + mockLoadContextFilesFn + ); + + await svc.executeFeature('/test/project', 'feature-1'); + + expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith( + 'auto_mode_error', + expect.objectContaining({ + featureId: 'feature-1', + error: 'Test error', + }) + ); + }); + + it('updates status to backlog on error', async () => { + const testError = new Error('Test error'); + mockRunAgentFn = vi.fn().mockRejectedValue(testError); + const svc = new ExecutionService( + mockEventBus, + mockConcurrencyManager, + mockWorktreeResolver, + mockSettingsService, + mockRunAgentFn, + mockExecutePipelineFn, + mockUpdateFeatureStatusFn, + mockLoadFeatureFn, + mockGetPlanningPromptPrefixFn, + mockSaveFeatureSummaryFn, + mockRecordLearningsFn, + mockContextExistsFn, + mockResumeFeatureFn, + mockTrackFailureFn, + mockSignalPauseFn, + mockRecordSuccessFn, + mockSaveExecutionStateFn, + mockLoadContextFilesFn + ); + + await svc.executeFeature('/test/project', 'feature-1'); + + expect(mockUpdateFeatureStatusFn).toHaveBeenCalledWith( + '/test/project', + 'feature-1', + 'backlog' + ); + }); + + it('tracks failure and checks pause', async () => { + const testError = new Error('Rate limit error'); + mockRunAgentFn = vi.fn().mockRejectedValue(testError); + const svc = new ExecutionService( + mockEventBus, + mockConcurrencyManager, + mockWorktreeResolver, + mockSettingsService, + mockRunAgentFn, + mockExecutePipelineFn, + mockUpdateFeatureStatusFn, + mockLoadFeatureFn, + mockGetPlanningPromptPrefixFn, + mockSaveFeatureSummaryFn, + mockRecordLearningsFn, + mockContextExistsFn, + mockResumeFeatureFn, + mockTrackFailureFn, + mockSignalPauseFn, + mockRecordSuccessFn, + mockSaveExecutionStateFn, + mockLoadContextFilesFn + ); + + await svc.executeFeature('/test/project', 'feature-1'); + + expect(mockTrackFailureFn).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Rate limit error', + }) + ); + }); + + it('signals pause when threshold reached', async () => { + const testError = new Error('Quota exceeded'); + mockRunAgentFn = vi.fn().mockRejectedValue(testError); + mockTrackFailureFn = vi.fn().mockReturnValue(true); // threshold reached + + const svc = new ExecutionService( + mockEventBus, + mockConcurrencyManager, + mockWorktreeResolver, + mockSettingsService, + mockRunAgentFn, + mockExecutePipelineFn, + mockUpdateFeatureStatusFn, + mockLoadFeatureFn, + mockGetPlanningPromptPrefixFn, + mockSaveFeatureSummaryFn, + mockRecordLearningsFn, + mockContextExistsFn, + mockResumeFeatureFn, + mockTrackFailureFn, + mockSignalPauseFn, + mockRecordSuccessFn, + mockSaveExecutionStateFn, + mockLoadContextFilesFn + ); + + await svc.executeFeature('/test/project', 'feature-1'); + + expect(mockSignalPauseFn).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Quota exceeded', + }) + ); + }); + + it('handles abort signal without error event', async () => { + const abortError = new Error('abort'); + abortError.name = 'AbortError'; + mockRunAgentFn = vi.fn().mockRejectedValue(abortError); + + const svc = new ExecutionService( + mockEventBus, + mockConcurrencyManager, + mockWorktreeResolver, + mockSettingsService, + mockRunAgentFn, + mockExecutePipelineFn, + mockUpdateFeatureStatusFn, + mockLoadFeatureFn, + mockGetPlanningPromptPrefixFn, + mockSaveFeatureSummaryFn, + mockRecordLearningsFn, + mockContextExistsFn, + mockResumeFeatureFn, + mockTrackFailureFn, + mockSignalPauseFn, + mockRecordSuccessFn, + mockSaveExecutionStateFn, + mockLoadContextFilesFn + ); + + await svc.executeFeature('/test/project', 'feature-1'); + + // Should emit feature_complete with stopped by user + expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith( + 'auto_mode_feature_complete', + expect.objectContaining({ + featureId: 'feature-1', + passes: false, + message: 'Feature stopped by user', + }) + ); + + // Should NOT emit error event + const errorCalls = vi + .mocked(mockEventBus.emitAutoModeEvent) + .mock.calls.filter((call) => call[0] === 'auto_mode_error'); + expect(errorCalls.length).toBe(0); + }); + + it('releases running feature even on error', async () => { + const testError = new Error('Test error'); + mockRunAgentFn = vi.fn().mockRejectedValue(testError); + const svc = new ExecutionService( + mockEventBus, + mockConcurrencyManager, + mockWorktreeResolver, + mockSettingsService, + mockRunAgentFn, + mockExecutePipelineFn, + mockUpdateFeatureStatusFn, + mockLoadFeatureFn, + mockGetPlanningPromptPrefixFn, + mockSaveFeatureSummaryFn, + mockRecordLearningsFn, + mockContextExistsFn, + mockResumeFeatureFn, + mockTrackFailureFn, + mockSignalPauseFn, + mockRecordSuccessFn, + mockSaveExecutionStateFn, + mockLoadContextFilesFn + ); + + await svc.executeFeature('/test/project', 'feature-1'); + + expect(mockConcurrencyManager.release).toHaveBeenCalledWith('feature-1', undefined); + }); + }); + + describe('stopFeature', () => { + it('returns false if feature not running', async () => { + vi.mocked(mockConcurrencyManager.getRunningFeature).mockReturnValue(undefined); + + const result = await service.stopFeature('feature-1'); + + expect(result).toBe(false); + }); + + it('aborts running feature', async () => { + const runningFeature = createRunningFeature('feature-1'); + const abortSpy = vi.spyOn(runningFeature.abortController, 'abort'); + vi.mocked(mockConcurrencyManager.getRunningFeature).mockReturnValue(runningFeature); + + const result = await service.stopFeature('feature-1'); + + expect(result).toBe(true); + expect(abortSpy).toHaveBeenCalled(); + }); + + it('releases running feature with force', async () => { + const runningFeature = createRunningFeature('feature-1'); + vi.mocked(mockConcurrencyManager.getRunningFeature).mockReturnValue(runningFeature); + + await service.stopFeature('feature-1'); + + expect(mockConcurrencyManager.release).toHaveBeenCalledWith('feature-1', { force: true }); + }); + }); + + describe('worktree resolution', () => { + it('uses worktree when useWorktrees is true and branch exists', async () => { + await service.executeFeature('/test/project', 'feature-1', true); + + expect(mockWorktreeResolver.findWorktreeForBranch).toHaveBeenCalledWith( + '/test/project', + 'feature/test-1' + ); + }); + + it('falls back to project path when worktree not found', 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 end with /test/project + expect(callArgs[0]).toMatch(/\/test\/project$/); + }); + + it('skips worktree resolution when useWorktrees is false', async () => { + await service.executeFeature('/test/project', 'feature-1', false); + + expect(mockWorktreeResolver.findWorktreeForBranch).not.toHaveBeenCalled(); + }); + }); + + describe('auto-mode integration', () => { + it('saves execution state when isAutoMode is true', async () => { + await service.executeFeature('/test/project', 'feature-1', false, true); + + expect(mockSaveExecutionStateFn).toHaveBeenCalledWith('/test/project'); + }); + + it('saves execution state after completion in auto-mode', async () => { + await service.executeFeature('/test/project', 'feature-1', false, true); + + // Should be called twice: once at start, once at end + expect(mockSaveExecutionStateFn).toHaveBeenCalledTimes(2); + }); + + it('does not save execution state when isAutoMode is false', async () => { + await service.executeFeature('/test/project', 'feature-1', false, false); + + expect(mockSaveExecutionStateFn).not.toHaveBeenCalled(); + }); + }); + + describe('planning mode', () => { + it('calls getPlanningPromptPrefix for features', async () => { + await service.executeFeature('/test/project', 'feature-1'); + + expect(mockGetPlanningPromptPrefixFn).toHaveBeenCalledWith(testFeature); + }); + + it('emits planning_started event when planning mode is not skip', async () => { + const featureWithPlanning: Feature = { + ...testFeature, + planningMode: 'lite', + }; + mockLoadFeatureFn = vi.fn().mockResolvedValue(featureWithPlanning); + const svc = new ExecutionService( + mockEventBus, + mockConcurrencyManager, + mockWorktreeResolver, + mockSettingsService, + mockRunAgentFn, + mockExecutePipelineFn, + mockUpdateFeatureStatusFn, + mockLoadFeatureFn, + mockGetPlanningPromptPrefixFn, + mockSaveFeatureSummaryFn, + mockRecordLearningsFn, + mockContextExistsFn, + mockResumeFeatureFn, + mockTrackFailureFn, + mockSignalPauseFn, + mockRecordSuccessFn, + mockSaveExecutionStateFn, + mockLoadContextFilesFn + ); + + await svc.executeFeature('/test/project', 'feature-1'); + + expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith( + 'planning_started', + expect.objectContaining({ + featureId: 'feature-1', + mode: 'lite', + }) + ); + }); + }); + + describe('summary extraction', () => { + it('extracts and saves summary from agent output', async () => { + vi.mocked(secureFs.readFile).mockResolvedValue('Agent output with summary'); + + await service.executeFeature('/test/project', 'feature-1'); + + expect(mockSaveFeatureSummaryFn).toHaveBeenCalledWith( + '/test/project', + 'feature-1', + 'Test summary' + ); + }); + + it('records learnings from agent output', async () => { + vi.mocked(secureFs.readFile).mockResolvedValue('Agent output'); + + await service.executeFeature('/test/project', 'feature-1'); + + expect(mockRecordLearningsFn).toHaveBeenCalledWith( + '/test/project', + testFeature, + 'Agent output' + ); + }); + + it('handles missing agent output gracefully', async () => { + vi.mocked(secureFs.readFile).mockRejectedValue(new Error('ENOENT')); + + // Should not throw + await service.executeFeature('/test/project', 'feature-1'); + + // Feature should still complete successfully + expect(mockEventBus.emitAutoModeEvent).toHaveBeenCalledWith( + 'auto_mode_feature_complete', + expect.objectContaining({ passes: true }) + ); + }); + }); +});