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:
gsxdsm
2026-02-25 22:13:38 -08:00
committed by GitHub
parent 70c9fd77f6
commit 9747faf1b9
37 changed files with 7164 additions and 163 deletions

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

View File

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

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

View File

@@ -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', () => {

View File

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

View File

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

View File

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

View File

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