mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-03-19 22:53:08 +00:00
Fix agent output summary for pipeline steps (#812)
* Changes from fix/agent-output-summary-for-pipeline-steps * feat: Optimize pipeline summary extraction and fix regex vulnerability * fix: Use fallback summary for pipeline steps when extraction fails * fix: Strip follow-up session scaffold from pipeline step fallback summaries
This commit is contained in:
446
apps/server/tests/unit/services/agent-executor-summary.test.ts
Normal file
446
apps/server/tests/unit/services/agent-executor-summary.test.ts
Normal file
@@ -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<typeof import('@automaker/utils')>();
|
||||
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.
|
||||
<summary>Old summary</summary>`;
|
||||
const newWork = `New implementation work.
|
||||
<summary>New summary</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.
|
||||
<summary>Old summary</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.
|
||||
<summary>Proper summary from extraction</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,
|
||||
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')
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1235,4 +1235,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\n<summary>Extracted summary here</summary>\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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
127
apps/server/tests/unit/services/auto-mode-facade.test.ts
Normal file
127
apps/server/tests/unit/services/auto-mode-facade.test.ts
Normal file
@@ -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<Feature>[] = [
|
||||
{ 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<Feature>[] = [
|
||||
{ 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<Feature>[] = [
|
||||
{ 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<Feature> = {
|
||||
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<Feature>[] = [
|
||||
{ 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<Feature>[] = [
|
||||
{ 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<Feature> = { id: '1', status, branchName: 'main' };
|
||||
const result = AutoModeServiceFacade.isFeatureEligibleForAutoMode(
|
||||
feature as Feature,
|
||||
'main',
|
||||
'main'
|
||||
);
|
||||
expect(result).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1439,6 +1439,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\n<summary>New summary</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\n<summary>New summary</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', () => {
|
||||
|
||||
@@ -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;
|
||||
@@ -341,9 +356,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 +370,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 +396,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 +455,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 +667,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 +1080,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,
|
||||
|
||||
@@ -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('<summary>');
|
||||
expect(prompt).toContain('## Summary: Code Review');
|
||||
expect(prompt).toContain('</summary>');
|
||||
expect(prompt).toContain('The <summary> and </summary> tags MUST be on their own lines.');
|
||||
});
|
||||
});
|
||||
@@ -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<typeof import('@automaker/utils')>();
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 <summary> 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 <summary> tag. extractSummary takes only the LAST match,
|
||||
// losing all previous steps' summaries.
|
||||
const accumulatedOutput = `
|
||||
## Step 1: Code Review
|
||||
|
||||
Some review output...
|
||||
|
||||
<summary>
|
||||
## Code Review Summary
|
||||
- Found 3 issues
|
||||
- Suggested 2 improvements
|
||||
</summary>
|
||||
|
||||
---
|
||||
|
||||
## Follow-up Session
|
||||
|
||||
## Step 2: Testing
|
||||
|
||||
Running tests...
|
||||
|
||||
<summary>
|
||||
## Testing Summary
|
||||
- All 15 tests pass
|
||||
- Coverage at 92%
|
||||
</summary>
|
||||
`;
|
||||
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 = `
|
||||
<summary>Step 1: Implementation complete</summary>
|
||||
|
||||
---
|
||||
|
||||
## Follow-up Session
|
||||
|
||||
<summary>Step 2: Code review findings</summary>
|
||||
|
||||
---
|
||||
|
||||
## Follow-up Session
|
||||
|
||||
<summary>Step 3: All tests passing</summary>
|
||||
`;
|
||||
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
|
||||
|
||||
<summary>
|
||||
## Test Results
|
||||
- All tests pass
|
||||
</summary>
|
||||
`;
|
||||
const result = extractSummary(accumulatedOutput);
|
||||
expect(result).toBe('## Test Results\n- All tests pass');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user