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,563 @@
/**
* End-to-end integration tests for agent output summary display flow.
*
* These tests validate the complete flow from:
* 1. Server-side summary accumulation (FeatureStateManager.saveFeatureSummary)
* 2. Event emission with accumulated summary (auto_mode_summary event)
* 3. UI-side summary retrieval (feature.summary via API)
* 4. UI-side summary parsing and display (parsePhaseSummaries, extractSummary)
*
* The tests simulate what happens when:
* - A feature goes through multiple pipeline steps
* - Each step produces a summary
* - The server accumulates all summaries
* - The UI displays the accumulated summary
*/
import { describe, it, expect, beforeEach, vi, type Mock } from 'vitest';
import { FeatureStateManager } from '@/services/feature-state-manager.js';
import type { Feature } from '@automaker/types';
import type { EventEmitter } from '@/lib/events.js';
import type { FeatureLoader } from '@/services/feature-loader.js';
import { atomicWriteJson, readJsonWithRecovery } from '@automaker/utils';
import { getFeatureDir } from '@automaker/platform';
import { pipelineService } from '@/services/pipeline-service.js';
// Mock dependencies
vi.mock('@/lib/secure-fs.js', () => ({
readFile: vi.fn(),
readdir: vi.fn(),
}));
vi.mock('@automaker/utils', async (importOriginal) => {
const actual = await importOriginal<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(),
},
}));
// ============================================================================
// UI-side parsing functions (mirrored from apps/ui/src/lib/log-parser.ts)
// ============================================================================
function parsePhaseSummaries(summary: string | undefined): Map<string, string> {
const phaseSummaries = new Map<string, string>();
if (!summary || !summary.trim()) return phaseSummaries;
const sections = summary.split(/\n\n---\n\n/);
for (const section of sections) {
const headerMatch = section.match(/^###\s+(.+?)(?:\n|$)/);
if (headerMatch) {
const phaseName = headerMatch[1].trim().toLowerCase();
const content = section.substring(headerMatch[0].length).trim();
phaseSummaries.set(phaseName, content);
}
}
return phaseSummaries;
}
function extractSummary(rawOutput: string): string | null {
if (!rawOutput || !rawOutput.trim()) return null;
const regexesToTry: Array<{
regex: RegExp;
processor: (m: RegExpMatchArray) => string;
}> = [
{ regex: /<summary>([\s\S]*?)<\/summary>/gi, processor: (m) => m[1] },
{ regex: /^##\s+Summary[^\n]*\n([\s\S]*?)(?=\n##\s+[^#]|\n🔧|$)/gm, processor: (m) => m[1] },
];
for (const { regex, processor } of regexesToTry) {
const matches = [...rawOutput.matchAll(regex)];
if (matches.length > 0) {
const lastMatch = matches[matches.length - 1];
return processor(lastMatch).trim();
}
}
return null;
}
function isAccumulatedSummary(summary: string | undefined): boolean {
if (!summary || !summary.trim()) return false;
return summary.includes('\n\n---\n\n') && (summary.match(/###\s+.+/g)?.length ?? 0) > 0;
}
/**
* Returns the first summary candidate that contains non-whitespace content.
* Mirrors getFirstNonEmptySummary from apps/ui/src/lib/summary-selection.ts
*/
function getFirstNonEmptySummary(...candidates: (string | null | undefined)[]): string | null {
for (const candidate of candidates) {
if (typeof candidate === 'string' && candidate.trim().length > 0) {
return candidate;
}
}
return null;
}
// ============================================================================
// Unit tests for helper functions
// ============================================================================
describe('getFirstNonEmptySummary', () => {
it('should return the first non-empty string', () => {
expect(getFirstNonEmptySummary(null, undefined, 'first', 'second')).toBe('first');
});
it('should skip null and undefined candidates', () => {
expect(getFirstNonEmptySummary(null, undefined, 'valid')).toBe('valid');
});
it('should skip whitespace-only strings', () => {
expect(getFirstNonEmptySummary(' ', '\n\t', 'actual content')).toBe('actual content');
});
it('should return null when all candidates are empty', () => {
expect(getFirstNonEmptySummary(null, undefined, '', ' ')).toBeNull();
});
it('should return null when no candidates provided', () => {
expect(getFirstNonEmptySummary()).toBeNull();
});
it('should handle empty string as invalid', () => {
expect(getFirstNonEmptySummary('', 'valid')).toBe('valid');
});
it('should prefer first valid candidate', () => {
expect(getFirstNonEmptySummary('first', 'second', 'third')).toBe('first');
});
it('should handle strings with only spaces as invalid', () => {
expect(getFirstNonEmptySummary(' ', ' \n ', 'valid')).toBe('valid');
});
it('should accept strings with content surrounded by whitespace', () => {
expect(getFirstNonEmptySummary(' content with spaces ')).toBe(' content with spaces ');
});
});
describe('Agent Output Summary E2E Flow', () => {
let manager: FeatureStateManager;
let mockEvents: EventEmitter;
const baseFeature: Feature = {
id: 'e2e-feature-1',
name: 'E2E Feature',
title: 'E2E Feature Title',
description: 'A feature going through complete pipeline',
status: 'pipeline_implementation',
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-01T00:00:00Z',
};
beforeEach(() => {
vi.clearAllMocks();
mockEvents = {
emit: vi.fn(),
subscribe: vi.fn(() => vi.fn()),
};
const mockFeatureLoader = {
syncFeatureToAppSpec: vi.fn(),
} as unknown as FeatureLoader;
manager = new FeatureStateManager(mockEvents, mockFeatureLoader);
(getFeatureDir as Mock).mockReturnValue('/project/.automaker/features/e2e-feature-1');
});
describe('complete pipeline flow: server accumulation → UI display', () => {
it('should maintain complete summary across all pipeline steps', async () => {
// ===== STEP 1: Implementation =====
(pipelineService.getStep as Mock).mockResolvedValue({
name: 'Implementation',
id: 'implementation',
});
(readJsonWithRecovery as Mock).mockResolvedValue({
data: { ...baseFeature, status: 'pipeline_implementation', summary: undefined },
recovered: false,
source: 'main',
});
await manager.saveFeatureSummary(
'/project',
'e2e-feature-1',
'## Changes\n- Created auth module\n- Added user service'
);
const step1Feature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature;
const step1Summary = step1Feature.summary;
// Verify server-side accumulation format
expect(step1Summary).toBe(
'### Implementation\n\n## Changes\n- Created auth module\n- Added user service'
);
// Verify UI can parse this summary
const phases1 = parsePhaseSummaries(step1Summary);
expect(phases1.size).toBe(1);
expect(phases1.get('implementation')).toContain('Created auth module');
// ===== STEP 2: Code Review =====
vi.clearAllMocks();
(getFeatureDir as Mock).mockReturnValue('/project/.automaker/features/e2e-feature-1');
(pipelineService.getStep as Mock).mockResolvedValue({
name: 'Code Review',
id: 'code_review',
});
(readJsonWithRecovery as Mock).mockResolvedValue({
data: { ...baseFeature, status: 'pipeline_code_review', summary: step1Summary },
recovered: false,
source: 'main',
});
await manager.saveFeatureSummary(
'/project',
'e2e-feature-1',
'## Review Results\n- Approved with minor suggestions'
);
const step2Feature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature;
const step2Summary = step2Feature.summary;
// Verify accumulation now has both steps
expect(step2Summary).toContain('### Implementation');
expect(step2Summary).toContain('Created auth module');
expect(step2Summary).toContain('### Code Review');
expect(step2Summary).toContain('Approved with minor suggestions');
expect(step2Summary).toContain('\n\n---\n\n'); // Separator
// Verify UI can parse accumulated summary
expect(isAccumulatedSummary(step2Summary)).toBe(true);
const phases2 = parsePhaseSummaries(step2Summary);
expect(phases2.size).toBe(2);
expect(phases2.get('implementation')).toContain('Created auth module');
expect(phases2.get('code review')).toContain('Approved with minor suggestions');
// ===== STEP 3: Testing =====
vi.clearAllMocks();
(getFeatureDir as Mock).mockReturnValue('/project/.automaker/features/e2e-feature-1');
(pipelineService.getStep as Mock).mockResolvedValue({ name: 'Testing', id: 'testing' });
(readJsonWithRecovery as Mock).mockResolvedValue({
data: { ...baseFeature, status: 'pipeline_testing', summary: step2Summary },
recovered: false,
source: 'main',
});
await manager.saveFeatureSummary(
'/project',
'e2e-feature-1',
'## Test Results\n- 42 tests pass\n- 98% coverage'
);
const finalFeature = (atomicWriteJson as Mock).mock.calls[0][1] as Feature;
const finalSummary = finalFeature.summary;
// Verify final accumulation has all three steps
expect(finalSummary).toContain('### Implementation');
expect(finalSummary).toContain('Created auth module');
expect(finalSummary).toContain('### Code Review');
expect(finalSummary).toContain('Approved with minor suggestions');
expect(finalSummary).toContain('### Testing');
expect(finalSummary).toContain('42 tests pass');
// Verify UI-side parsing of complete pipeline
expect(isAccumulatedSummary(finalSummary)).toBe(true);
const finalPhases = parsePhaseSummaries(finalSummary);
expect(finalPhases.size).toBe(3);
// Verify chronological order (implementation before testing)
const summaryLines = finalSummary!.split('\n');
const implIndex = summaryLines.findIndex((l) => l.includes('### Implementation'));
const reviewIndex = summaryLines.findIndex((l) => l.includes('### Code Review'));
const testIndex = summaryLines.findIndex((l) => l.includes('### Testing'));
expect(implIndex).toBeLessThan(reviewIndex);
expect(reviewIndex).toBeLessThan(testIndex);
});
it('should emit events with accumulated summaries for real-time UI updates', async () => {
// Step 1
(pipelineService.getStep as Mock).mockResolvedValue({
name: 'Implementation',
id: 'implementation',
});
(readJsonWithRecovery as Mock).mockResolvedValue({
data: { ...baseFeature, status: 'pipeline_implementation', summary: undefined },
recovered: false,
source: 'main',
});
await manager.saveFeatureSummary('/project', 'e2e-feature-1', 'Step 1 output');
// Verify event emission
expect(mockEvents.emit).toHaveBeenCalledWith('auto-mode:event', {
type: 'auto_mode_summary',
featureId: 'e2e-feature-1',
projectPath: '/project',
summary: '### Implementation\n\nStep 1 output',
});
// Step 2
vi.clearAllMocks();
(getFeatureDir as Mock).mockReturnValue('/project/.automaker/features/e2e-feature-1');
(pipelineService.getStep as Mock).mockResolvedValue({ name: 'Testing', id: 'testing' });
(readJsonWithRecovery as Mock).mockResolvedValue({
data: {
...baseFeature,
status: 'pipeline_testing',
summary: '### Implementation\n\nStep 1 output',
},
recovered: false,
source: 'main',
});
await manager.saveFeatureSummary('/project', 'e2e-feature-1', 'Step 2 output');
// Event should contain FULL accumulated summary
expect(mockEvents.emit).toHaveBeenCalledWith('auto-mode:event', {
type: 'auto_mode_summary',
featureId: 'e2e-feature-1',
projectPath: '/project',
summary: '### Implementation\n\nStep 1 output\n\n---\n\n### Testing\n\nStep 2 output',
});
});
});
describe('UI display logic: feature.summary vs extractSummary()', () => {
it('should prefer feature.summary (server-accumulated) over extractSummary() (last only)', () => {
// Simulate what the server has accumulated
const featureSummary = [
'### Implementation',
'',
'## Changes',
'- Created feature',
'',
'---',
'',
'### Testing',
'',
'## Results',
'- All tests pass',
].join('\n');
// Simulate raw agent output (only contains last summary)
const rawOutput = `
Working on tests...
<summary>
## Results
- All tests pass
</summary>
`;
// UI logic: getFirstNonEmptySummary(feature?.summary, extractSummary(output))
const displaySummary = getFirstNonEmptySummary(featureSummary, extractSummary(rawOutput));
// Should use server-accumulated summary
expect(displaySummary).toBe(featureSummary);
expect(displaySummary).toContain('### Implementation');
expect(displaySummary).toContain('### Testing');
// If server summary was missing, only last summary would be shown
const fallbackSummary = extractSummary(rawOutput);
expect(fallbackSummary).not.toContain('Implementation');
expect(fallbackSummary).toContain('All tests pass');
});
it('should handle legacy features without server accumulation', () => {
// Legacy features have no feature.summary
const featureSummary = undefined;
// Raw output contains the summary
const rawOutput = `
<summary>
## Implementation Complete
- Created the feature
- All tests pass
</summary>
`;
// UI logic: getFirstNonEmptySummary(feature?.summary, extractSummary(output))
const displaySummary = getFirstNonEmptySummary(featureSummary, extractSummary(rawOutput));
// Should fall back to client-side extraction
expect(displaySummary).toContain('Implementation Complete');
expect(displaySummary).toContain('All tests pass');
});
});
describe('error recovery and edge cases', () => {
it('should gracefully handle pipeline interruption', async () => {
// Step 1 completes
(pipelineService.getStep as Mock).mockResolvedValue({
name: 'Implementation',
id: 'implementation',
});
(readJsonWithRecovery as Mock).mockResolvedValue({
data: { ...baseFeature, status: 'pipeline_implementation', summary: undefined },
recovered: false,
source: 'main',
});
await manager.saveFeatureSummary('/project', 'e2e-feature-1', 'Implementation done');
const step1Summary = ((atomicWriteJson as Mock).mock.calls[0][1] as Feature).summary;
// Pipeline gets interrupted (status changes but summary is preserved)
// When user views the feature later, the summary should still be available
expect(step1Summary).toBe('### Implementation\n\nImplementation done');
// UI can still parse the partial pipeline
const phases = parsePhaseSummaries(step1Summary);
expect(phases.size).toBe(1);
expect(phases.get('implementation')).toBe('Implementation done');
});
it('should handle very large accumulated summaries', async () => {
// Generate large content for each step
const generateLargeContent = (stepNum: number) => {
const lines = [`## Step ${stepNum} Changes`];
for (let i = 0; i < 100; i++) {
lines.push(
`- Change ${i}: This is a detailed description of the change made during step ${stepNum}`
);
}
return lines.join('\n');
};
// Simulate 5 pipeline steps with large content
let currentSummary: string | undefined = undefined;
const stepNames = ['Planning', 'Implementation', 'Code Review', 'Testing', 'Refinement'];
for (let i = 0; i < 5; i++) {
vi.clearAllMocks();
(getFeatureDir as Mock).mockReturnValue('/project/.automaker/features/e2e-feature-1');
(pipelineService.getStep as Mock).mockResolvedValue({
name: stepNames[i],
id: stepNames[i].toLowerCase().replace(' ', '_'),
});
(readJsonWithRecovery as Mock).mockResolvedValue({
data: {
...baseFeature,
status: `pipeline_${stepNames[i].toLowerCase().replace(' ', '_')}`,
summary: currentSummary,
},
recovered: false,
source: 'main',
});
await manager.saveFeatureSummary('/project', 'e2e-feature-1', generateLargeContent(i + 1));
currentSummary = ((atomicWriteJson as Mock).mock.calls[0][1] as Feature).summary;
}
// Final summary should be large but still parseable
expect(currentSummary!.length).toBeGreaterThan(5000);
expect(isAccumulatedSummary(currentSummary)).toBe(true);
const phases = parsePhaseSummaries(currentSummary);
expect(phases.size).toBe(5);
// Verify all steps are present
for (const stepName of stepNames) {
expect(phases.has(stepName.toLowerCase())).toBe(true);
}
});
});
describe('query invalidation simulation', () => {
it('should trigger UI refetch on auto_mode_summary event', async () => {
// This test documents the expected behavior:
// When saveFeatureSummary is called, it emits auto_mode_summary event
// The UI's use-query-invalidation.ts invalidates the feature query
// This causes a refetch of the feature, getting the updated summary
(pipelineService.getStep as Mock).mockResolvedValue({
name: 'Implementation',
id: 'implementation',
});
(readJsonWithRecovery as Mock).mockResolvedValue({
data: { ...baseFeature, status: 'pipeline_implementation', summary: undefined },
recovered: false,
source: 'main',
});
await manager.saveFeatureSummary('/project', 'e2e-feature-1', 'Summary content');
// Verify event was emitted (triggers React Query invalidation)
expect(mockEvents.emit).toHaveBeenCalledWith(
'auto-mode:event',
expect.objectContaining({
type: 'auto_mode_summary',
featureId: 'e2e-feature-1',
summary: expect.any(String),
})
);
// The UI would then:
// 1. Receive the event via WebSocket
// 2. Invalidate the feature query
// 3. Refetch the feature (GET /api/features/:id)
// 4. Display the updated feature.summary
});
});
});
/**
* KEY E2E FLOW SUMMARY:
*
* 1. PIPELINE EXECUTION:
* - Feature starts with status='pipeline_implementation'
* - Agent runs and produces summary
* - FeatureStateManager.saveFeatureSummary() accumulates with step header
* - Status advances to 'pipeline_testing'
* - Process repeats for each step
*
* 2. SERVER-SIDE ACCUMULATION:
* - First step: `### Implementation\n\n<content>`
* - Second step: `### Implementation\n\n<content>\n\n---\n\n### Testing\n\n<content>`
* - Pattern continues with each step
*
* 3. EVENT EMISSION:
* - auto_mode_summary event contains FULL accumulated summary
* - UI receives event via WebSocket
* - React Query invalidates feature query
* - Feature is refetched with updated summary
*
* 4. UI DISPLAY:
* - AgentOutputModal uses: getFirstNonEmptySummary(feature?.summary, extractSummary(output))
* - feature.summary is preferred (contains all steps)
* - extractSummary() is fallback (last summary only)
* - parsePhaseSummaries() can split into individual phases for UI
*
* 5. FALLBACK FOR LEGACY:
* - Old features may not have feature.summary
* - UI falls back to extracting from raw output
* - Only last summary is available in this case
*/

View File

@@ -0,0 +1,403 @@
/**
* Unit tests for the agent output summary priority logic.
*
* These tests verify the summary display logic used in AgentOutputModal
* where the UI must choose between server-accumulated summaries and
* client-side extracted summaries.
*
* Priority order (from agent-output-modal.tsx):
* 1. feature.summary (server-accumulated, contains all pipeline steps)
* 2. extractSummary(output) (client-side fallback, last summary only)
*
* This priority is crucial for pipeline features where the server-side
* accumulation provides the complete history of all step summaries.
*/
import { describe, it, expect } from 'vitest';
// Import the actual extractSummary function to ensure test behavior matches production
import { extractSummary } from '../../../../ui/src/lib/log-parser.ts';
import { getFirstNonEmptySummary } from '../../../../ui/src/lib/summary-selection.ts';
/**
* Simulates the summary priority logic from AgentOutputModal.
*
* Priority:
* 1. feature?.summary (server-accumulated)
* 2. extractSummary(output) (client-side fallback)
*/
function getDisplaySummary(
featureSummary: string | undefined | null,
rawOutput: string
): string | null {
return getFirstNonEmptySummary(featureSummary, extractSummary(rawOutput));
}
describe('Agent Output Summary Priority Logic', () => {
describe('priority order: feature.summary over extractSummary', () => {
it('should use feature.summary when available (server-accumulated wins)', () => {
const featureSummary = '### Step 1\n\nFirst step\n\n---\n\n### Step 2\n\nSecond step';
const rawOutput = `
<summary>
Only the last summary is extracted client-side
</summary>
`;
const result = getDisplaySummary(featureSummary, rawOutput);
// Server-accumulated summary should be used, not client-side extraction
expect(result).toBe(featureSummary);
expect(result).toContain('### Step 1');
expect(result).toContain('### Step 2');
expect(result).not.toContain('Only the last summary');
});
it('should use client-side extractSummary when feature.summary is undefined', () => {
const rawOutput = `
<summary>
This is the only summary
</summary>
`;
const result = getDisplaySummary(undefined, rawOutput);
expect(result).toBe('This is the only summary');
});
it('should use client-side extractSummary when feature.summary is null', () => {
const rawOutput = `
<summary>
Client-side extracted summary
</summary>
`;
const result = getDisplaySummary(null, rawOutput);
expect(result).toBe('Client-side extracted summary');
});
it('should use client-side extractSummary when feature.summary is empty string', () => {
const rawOutput = `
<summary>
Fallback content
</summary>
`;
const result = getDisplaySummary('', rawOutput);
// Empty string is falsy, so fallback is used
expect(result).toBe('Fallback content');
});
it('should use client-side extractSummary when feature.summary is whitespace only', () => {
const rawOutput = `
<summary>
Fallback for whitespace summary
</summary>
`;
const result = getDisplaySummary(' \n ', rawOutput);
expect(result).toBe('Fallback for whitespace summary');
});
it('should preserve original server summary formatting when non-empty after trim', () => {
const featureSummary = '\n### Implementation\n\n- Added API route\n';
const result = getDisplaySummary(featureSummary, '');
expect(result).toBe(featureSummary);
expect(result).toContain('### Implementation');
});
});
describe('pipeline step accumulation scenarios', () => {
it('should display all pipeline steps when using server-accumulated summary', () => {
// This simulates a feature that went through 3 pipeline steps
const featureSummary = [
'### Implementation',
'',
'## Changes',
'- Created new module',
'- Added tests',
'',
'---',
'',
'### Code Review',
'',
'## Review Results',
'- Approved with minor suggestions',
'',
'---',
'',
'### Testing',
'',
'## Test Results',
'- All 42 tests pass',
'- Coverage: 98%',
].join('\n');
const rawOutput = `
<summary>
Only testing step visible in raw output
</summary>
`;
const result = getDisplaySummary(featureSummary, rawOutput);
// All pipeline steps should be visible
expect(result).toContain('### Implementation');
expect(result).toContain('### Code Review');
expect(result).toContain('### Testing');
expect(result).toContain('All 42 tests pass');
});
it('should display only last summary when server-side accumulation not available', () => {
// When feature.summary is not available, only the last summary is shown
const rawOutput = `
<summary>
Step 1: Implementation complete
</summary>
---
<summary>
Step 2: Code review complete
</summary>
---
<summary>
Step 3: Testing complete
</summary>
`;
const result = getDisplaySummary(undefined, rawOutput);
// Only the LAST summary should be shown (client-side fallback behavior)
expect(result).toBe('Step 3: Testing complete');
expect(result).not.toContain('Step 1');
expect(result).not.toContain('Step 2');
});
it('should handle single-step pipeline (no accumulation needed)', () => {
const featureSummary = '### Implementation\n\nCreated the feature';
const rawOutput = '';
const result = getDisplaySummary(featureSummary, rawOutput);
expect(result).toBe(featureSummary);
expect(result).not.toContain('---'); // No separator for single step
});
});
describe('edge cases', () => {
it('should return null when both feature.summary and extractSummary are unavailable', () => {
const rawOutput = 'No summary tags here, just regular output.';
const result = getDisplaySummary(undefined, rawOutput);
expect(result).toBeNull();
});
it('should return null when rawOutput is empty and no feature summary', () => {
const result = getDisplaySummary(undefined, '');
expect(result).toBeNull();
});
it('should return null when rawOutput is whitespace only', () => {
const result = getDisplaySummary(undefined, ' \n\n ');
expect(result).toBeNull();
});
it('should use client-side fallback when feature.summary is empty string (falsy)', () => {
// Empty string is falsy in JavaScript, so fallback is correctly used.
// This is the expected behavior - an empty summary has no value to display.
const rawOutput = `
<summary>
Fallback content when server summary is empty
</summary>
`;
// Empty string is falsy, so fallback is used
const result = getDisplaySummary('', rawOutput);
expect(result).toBe('Fallback content when server summary is empty');
});
it('should behave identically when feature is null vs feature.summary is undefined', () => {
// This test verifies that the behavior is consistent whether:
// - The feature object itself is null/undefined
// - The feature object exists but summary property is undefined
const rawOutput = `
<summary>
Client-side extracted summary
</summary>
`;
// Both scenarios should use client-side fallback
const resultWithUndefined = getDisplaySummary(undefined, rawOutput);
const resultWithNull = getDisplaySummary(null, rawOutput);
expect(resultWithUndefined).toBe('Client-side extracted summary');
expect(resultWithNull).toBe('Client-side extracted summary');
expect(resultWithUndefined).toBe(resultWithNull);
});
});
describe('markdown content preservation', () => {
it('should preserve markdown formatting in server-accumulated summary', () => {
const featureSummary = `### Code Review
## Changes Made
- Fixed **critical bug** in \`parser.ts\`
- Added \`validateInput()\` function
\`\`\`typescript
const x = 1;
\`\`\`
| Test | Result |
|------|--------|
| Unit | Pass |`;
const result = getDisplaySummary(featureSummary, '');
expect(result).toContain('**critical bug**');
expect(result).toContain('`parser.ts`');
expect(result).toContain('```typescript');
expect(result).toContain('| Test | Result |');
});
it('should preserve unicode in server-accumulated summary', () => {
const featureSummary = '### Testing\n\n✅ 42 passed\n❌ 0 failed\n🎉 100% coverage';
const result = getDisplaySummary(featureSummary, '');
expect(result).toContain('✅');
expect(result).toContain('❌');
expect(result).toContain('🎉');
});
});
describe('real-world scenarios', () => {
it('should handle typical pipeline feature with server accumulation', () => {
// Simulates a real pipeline feature that went through Implementation → Testing
const featureSummary = `### Implementation
## Changes Made
- Created UserProfile component
- Added authentication middleware
- Updated API endpoints
---
### Testing
## Test Results
- Unit tests: 15 passed
- Integration tests: 8 passed
- E2E tests: 3 passed`;
const rawOutput = `
Working on the feature...
<summary>
## Test Results
- Unit tests: 15 passed
- Integration tests: 8 passed
- E2E tests: 3 passed
</summary>
`;
const result = getDisplaySummary(featureSummary, rawOutput);
// Both steps should be visible
expect(result).toContain('### Implementation');
expect(result).toContain('### Testing');
expect(result).toContain('UserProfile component');
expect(result).toContain('15 passed');
});
it('should handle non-pipeline feature (single summary)', () => {
// Non-pipeline features have a single summary, no accumulation
const featureSummary = '## Implementation Complete\n- Created the feature\n- All tests pass';
const rawOutput = '';
const result = getDisplaySummary(featureSummary, rawOutput);
expect(result).toBe(featureSummary);
expect(result).not.toContain('###'); // No step headers for non-pipeline
});
it('should handle legacy feature without server summary (fallback)', () => {
// Legacy features may not have feature.summary set
const rawOutput = `
<summary>
Legacy implementation from before server-side accumulation
</summary>
`;
const result = getDisplaySummary(undefined, rawOutput);
expect(result).toBe('Legacy implementation from before server-side accumulation');
});
});
describe('view mode determination logic', () => {
/**
* Simulates the effectiveViewMode logic from agent-output-modal.tsx line 86
* Default to 'summary' if summary is available, otherwise 'parsed'
*/
function getEffectiveViewMode(
viewMode: string | null,
summary: string | null
): 'summary' | 'parsed' {
return (viewMode ?? (summary ? 'summary' : 'parsed')) as 'summary' | 'parsed';
}
it('should default to summary view when server summary is available', () => {
const summary = '### Implementation\n\nContent';
const result = getEffectiveViewMode(null, summary);
expect(result).toBe('summary');
});
it('should default to summary view when client-side extraction succeeds', () => {
const summary = 'Extracted from raw output';
const result = getEffectiveViewMode(null, summary);
expect(result).toBe('summary');
});
it('should default to parsed view when no summary is available', () => {
const result = getEffectiveViewMode(null, null);
expect(result).toBe('parsed');
});
it('should respect explicit view mode selection over default', () => {
const summary = 'Summary is available';
expect(getEffectiveViewMode('raw', summary)).toBe('raw');
expect(getEffectiveViewMode('parsed', summary)).toBe('parsed');
expect(getEffectiveViewMode('changes', summary)).toBe('changes');
});
});
});
/**
* KEY ARCHITECTURE INSIGHT:
*
* The priority order (feature.summary > extractSummary(output)) is essential for
* pipeline features because:
*
* 1. Server-side accumulation (FeatureStateManager.saveFeatureSummary) collects
* ALL step summaries with headers and separators in chronological order.
*
* 2. Client-side extractSummary() only returns the LAST summary tag from raw output,
* losing all previous step summaries.
*
* 3. The UI must prefer feature.summary to display the complete history of all
* pipeline steps to the user.
*
* For non-pipeline features (single execution), both sources contain the same
* summary, so the priority doesn't matter. But for pipeline features, using the
* wrong source would result in incomplete information display.
*/

View File

@@ -0,0 +1,68 @@
import { describe, expect, it } from 'vitest';
import {
parseAllPhaseSummaries,
parsePhaseSummaries,
extractPhaseSummary,
extractImplementationSummary,
isAccumulatedSummary,
} from '../../../../ui/src/lib/log-parser.ts';
describe('log-parser mixed summary format compatibility', () => {
const mixedSummary = [
'Implemented core auth flow and API wiring.',
'',
'---',
'',
'### Code Review',
'',
'Addressed lint warnings and improved error handling.',
'',
'---',
'',
'### Testing',
'',
'All tests passing.',
].join('\n');
it('treats leading headerless section as Implementation phase', () => {
const phases = parsePhaseSummaries(mixedSummary);
expect(phases.get('implementation')).toBe('Implemented core auth flow and API wiring.');
expect(phases.get('code review')).toBe('Addressed lint warnings and improved error handling.');
expect(phases.get('testing')).toBe('All tests passing.');
});
it('returns implementation summary from mixed format', () => {
expect(extractImplementationSummary(mixedSummary)).toBe(
'Implemented core auth flow and API wiring.'
);
});
it('includes Implementation as the first parsed phase entry', () => {
const entries = parseAllPhaseSummaries(mixedSummary);
expect(entries[0]).toMatchObject({
phaseName: 'Implementation',
content: 'Implemented core auth flow and API wiring.',
});
expect(entries.map((entry) => entry.phaseName)).toEqual([
'Implementation',
'Code Review',
'Testing',
]);
});
it('extracts specific phase summaries from mixed format', () => {
expect(extractPhaseSummary(mixedSummary, 'Implementation')).toBe(
'Implemented core auth flow and API wiring.'
);
expect(extractPhaseSummary(mixedSummary, 'Code Review')).toBe(
'Addressed lint warnings and improved error handling.'
);
expect(extractPhaseSummary(mixedSummary, 'Testing')).toBe('All tests passing.');
});
it('treats mixed format as accumulated summary', () => {
expect(isAccumulatedSummary(mixedSummary)).toBe(true);
});
});

View File

@@ -0,0 +1,973 @@
/**
* Unit tests for log-parser phase summary parsing functions.
*
* These functions are used to parse accumulated summaries that contain multiple
* pipeline step summaries separated by `---` and identified by `### StepName` headers.
*
* Functions tested:
* - parsePhaseSummaries: Parses the entire accumulated summary into a Map
* - extractPhaseSummary: Extracts a specific phase's content
* - extractImplementationSummary: Extracts implementation phase content (convenience)
* - isAccumulatedSummary: Checks if a summary is in accumulated format
*/
import { describe, it, expect } from 'vitest';
// Mirror the functions from apps/ui/src/lib/log-parser.ts
// (We can't import directly because it's a UI file)
/**
* Parses an accumulated summary string into individual phase summaries.
*/
function parsePhaseSummaries(summary: string | undefined): Map<string, string> {
const phaseSummaries = new Map<string, string>();
if (!summary || !summary.trim()) {
return phaseSummaries;
}
// Split by the horizontal rule separator
const sections = summary.split(/\n\n---\n\n/);
for (const section of sections) {
// Match the phase header pattern: ### Phase Name
const headerMatch = section.match(/^###\s+(.+?)(?:\n|$)/);
if (headerMatch) {
const phaseName = headerMatch[1].trim().toLowerCase();
// Extract content after the header (skip the header line and leading newlines)
const content = section.substring(headerMatch[0].length).trim();
phaseSummaries.set(phaseName, content);
}
}
return phaseSummaries;
}
/**
* Extracts a specific phase summary from an accumulated summary string.
*/
function extractPhaseSummary(summary: string | undefined, phaseName: string): string | null {
const phaseSummaries = parsePhaseSummaries(summary);
const normalizedPhaseName = phaseName.toLowerCase();
return phaseSummaries.get(normalizedPhaseName) || null;
}
/**
* Extracts the implementation phase summary from an accumulated summary string.
*/
function extractImplementationSummary(summary: string | undefined): string | null {
if (!summary || !summary.trim()) {
return null;
}
const phaseSummaries = parsePhaseSummaries(summary);
// Try exact match first
const implementationContent = phaseSummaries.get('implementation');
if (implementationContent) {
return implementationContent;
}
// Fallback: find any phase containing "implement"
for (const [phaseName, content] of phaseSummaries) {
if (phaseName.includes('implement')) {
return content;
}
}
// If no phase summaries found, the summary might not be in accumulated format
// (legacy or non-pipeline feature). In this case, return the whole summary
// if it looks like a single summary (no phase headers).
if (!summary.includes('### ') && !summary.includes('\n---\n')) {
return summary;
}
return null;
}
/**
* Checks if a summary string is in the accumulated multi-phase format.
*/
function isAccumulatedSummary(summary: string | undefined): boolean {
if (!summary || !summary.trim()) {
return false;
}
// Check for the presence of phase headers with separator
const hasMultiplePhases =
summary.includes('\n\n---\n\n') && summary.match(/###\s+.+/g)?.length > 0;
return hasMultiplePhases;
}
/**
* Represents a single phase entry in an accumulated summary.
*/
interface PhaseSummaryEntry {
/** The phase name (e.g., "Implementation", "Testing", "Code Review") */
phaseName: string;
/** The content of this phase's summary */
content: string;
/** The original header line (e.g., "### Implementation") */
header: string;
}
/** Default phase name used for non-accumulated summaries */
const DEFAULT_PHASE_NAME = 'Summary';
/**
* Parses an accumulated summary into individual phase entries.
* Returns phases in the order they appear in the summary.
*/
function parseAllPhaseSummaries(summary: string | undefined): PhaseSummaryEntry[] {
const entries: PhaseSummaryEntry[] = [];
if (!summary || !summary.trim()) {
return entries;
}
// Check if this is an accumulated summary (has phase headers)
if (!summary.includes('### ')) {
// Not an accumulated summary - return as single entry with generic name
return [
{ phaseName: DEFAULT_PHASE_NAME, content: summary, header: `### ${DEFAULT_PHASE_NAME}` },
];
}
// Split by the horizontal rule separator
const sections = summary.split(/\n\n---\n\n/);
for (const section of sections) {
// Match the phase header pattern: ### Phase Name
const headerMatch = section.match(/^(###\s+)(.+?)(?:\n|$)/);
if (headerMatch) {
const header = headerMatch[0].trim();
const phaseName = headerMatch[2].trim();
// Extract content after the header (skip the header line and leading newlines)
const content = section.substring(headerMatch[0].length).trim();
entries.push({ phaseName, content, header });
}
}
return entries;
}
describe('parsePhaseSummaries', () => {
describe('basic parsing', () => {
it('should parse single phase summary', () => {
const summary = `### Implementation
## Changes Made
- Created new module
- Added unit tests`;
const result = parsePhaseSummaries(summary);
expect(result.size).toBe(1);
expect(result.get('implementation')).toBe(
'## Changes Made\n- Created new module\n- Added unit tests'
);
});
it('should parse multiple phase summaries', () => {
const summary = `### Implementation
## Changes Made
- Created new module
---
### Testing
## Test Results
- All tests pass`;
const result = parsePhaseSummaries(summary);
expect(result.size).toBe(2);
expect(result.get('implementation')).toBe('## Changes Made\n- Created new module');
expect(result.get('testing')).toBe('## Test Results\n- All tests pass');
});
it('should handle three or more phases', () => {
const summary = `### Planning
Plan created
---
### Implementation
Code written
---
### Testing
Tests pass
---
### Refinement
Code polished`;
const result = parsePhaseSummaries(summary);
expect(result.size).toBe(4);
expect(result.get('planning')).toBe('Plan created');
expect(result.get('implementation')).toBe('Code written');
expect(result.get('testing')).toBe('Tests pass');
expect(result.get('refinement')).toBe('Code polished');
});
});
describe('edge cases', () => {
it('should return empty map for undefined summary', () => {
const result = parsePhaseSummaries(undefined);
expect(result.size).toBe(0);
});
it('should return empty map for null summary', () => {
const result = parsePhaseSummaries(null as unknown as string);
expect(result.size).toBe(0);
});
it('should return empty map for empty string', () => {
const result = parsePhaseSummaries('');
expect(result.size).toBe(0);
});
it('should return empty map for whitespace-only string', () => {
const result = parsePhaseSummaries(' \n\n ');
expect(result.size).toBe(0);
});
it('should handle summary without phase headers', () => {
const summary = 'Just some regular content without headers';
const result = parsePhaseSummaries(summary);
expect(result.size).toBe(0);
});
it('should handle section without header after separator', () => {
const summary = `### Implementation
Content here
---
This section has no header`;
const result = parsePhaseSummaries(summary);
expect(result.size).toBe(1);
expect(result.get('implementation')).toBe('Content here');
});
});
describe('phase name normalization', () => {
it('should normalize phase names to lowercase', () => {
const summary = `### IMPLEMENTATION
Content`;
const result = parsePhaseSummaries(summary);
expect(result.has('implementation')).toBe(true);
expect(result.has('IMPLEMENTATION')).toBe(false);
});
it('should handle mixed case phase names', () => {
const summary = `### Code Review
Content`;
const result = parsePhaseSummaries(summary);
expect(result.has('code review')).toBe(true);
});
it('should preserve spaces in multi-word phase names', () => {
const summary = `### Code Review
Content`;
const result = parsePhaseSummaries(summary);
expect(result.get('code review')).toBe('Content');
});
});
describe('content preservation', () => {
it('should preserve markdown formatting in content', () => {
const summary = `### Implementation
## Heading
- **Bold text**
- \`code\`
\`\`\`typescript
const x = 1;
\`\`\``;
const result = parsePhaseSummaries(summary);
const content = result.get('implementation');
expect(content).toContain('**Bold text**');
expect(content).toContain('`code`');
expect(content).toContain('```typescript');
});
it('should preserve unicode in content', () => {
const summary = `### Testing
Results: ✅ 42 passed, ❌ 0 failed`;
const result = parsePhaseSummaries(summary);
expect(result.get('testing')).toContain('✅');
expect(result.get('testing')).toContain('❌');
});
it('should preserve tables in content', () => {
const summary = `### Testing
| Test | Result |
|------|--------|
| Unit | Pass |`;
const result = parsePhaseSummaries(summary);
expect(result.get('testing')).toContain('| Test | Result |');
});
it('should handle empty phase content', () => {
const summary = `### Implementation
---
### Testing
Content`;
const result = parsePhaseSummaries(summary);
expect(result.get('implementation')).toBe('');
expect(result.get('testing')).toBe('Content');
});
});
});
describe('extractPhaseSummary', () => {
describe('extraction by phase name', () => {
it('should extract specified phase content', () => {
const summary = `### Implementation
Implementation content
---
### Testing
Testing content`;
expect(extractPhaseSummary(summary, 'Implementation')).toBe('Implementation content');
expect(extractPhaseSummary(summary, 'Testing')).toBe('Testing content');
});
it('should be case-insensitive for phase name', () => {
const summary = `### Implementation
Content`;
expect(extractPhaseSummary(summary, 'implementation')).toBe('Content');
expect(extractPhaseSummary(summary, 'IMPLEMENTATION')).toBe('Content');
expect(extractPhaseSummary(summary, 'ImPlEmEnTaTiOn')).toBe('Content');
});
it('should return null for non-existent phase', () => {
const summary = `### Implementation
Content`;
expect(extractPhaseSummary(summary, 'NonExistent')).toBeNull();
});
});
describe('edge cases', () => {
it('should return null for undefined summary', () => {
expect(extractPhaseSummary(undefined, 'Implementation')).toBeNull();
});
it('should return null for empty summary', () => {
expect(extractPhaseSummary('', 'Implementation')).toBeNull();
});
it('should handle whitespace in phase name', () => {
const summary = `### Code Review
Content`;
expect(extractPhaseSummary(summary, 'Code Review')).toBe('Content');
expect(extractPhaseSummary(summary, 'code review')).toBe('Content');
});
});
});
describe('extractImplementationSummary', () => {
describe('exact match', () => {
it('should extract implementation phase by exact name', () => {
const summary = `### Implementation
## Changes Made
- Created feature
- Added tests
---
### Testing
Tests pass`;
const result = extractImplementationSummary(summary);
expect(result).toBe('## Changes Made\n- Created feature\n- Added tests');
});
it('should be case-insensitive', () => {
const summary = `### IMPLEMENTATION
Content`;
expect(extractImplementationSummary(summary)).toBe('Content');
});
});
describe('partial match fallback', () => {
it('should find phase containing "implement"', () => {
const summary = `### Feature Implementation
Content here`;
const result = extractImplementationSummary(summary);
expect(result).toBe('Content here');
});
it('should find phase containing "implementation"', () => {
const summary = `### Implementation Phase
Content here`;
const result = extractImplementationSummary(summary);
expect(result).toBe('Content here');
});
});
describe('legacy/non-accumulated summary handling', () => {
it('should return full summary if no phase headers present', () => {
const summary = `## Changes Made
- Created feature
- Added tests`;
const result = extractImplementationSummary(summary);
expect(result).toBe(summary);
});
it('should return null if summary has phase headers but no implementation', () => {
const summary = `### Testing
Tests pass
---
### Review
Review complete`;
const result = extractImplementationSummary(summary);
expect(result).toBeNull();
});
it('should not return full summary if it contains phase headers', () => {
const summary = `### Testing
Tests pass`;
const result = extractImplementationSummary(summary);
expect(result).toBeNull();
});
});
describe('edge cases', () => {
it('should return null for undefined summary', () => {
expect(extractImplementationSummary(undefined)).toBeNull();
});
it('should return null for empty string', () => {
expect(extractImplementationSummary('')).toBeNull();
});
it('should return null for whitespace-only string', () => {
expect(extractImplementationSummary(' \n\n ')).toBeNull();
});
});
});
describe('isAccumulatedSummary', () => {
describe('accumulated format detection', () => {
it('should return true for accumulated summary with separator and headers', () => {
const summary = `### Implementation
Content
---
### Testing
Content`;
expect(isAccumulatedSummary(summary)).toBe(true);
});
it('should return true for accumulated summary with multiple phases', () => {
const summary = `### Phase 1
Content 1
---
### Phase 2
Content 2
---
### Phase 3
Content 3`;
expect(isAccumulatedSummary(summary)).toBe(true);
});
it('should return true for accumulated summary with just one phase and separator', () => {
// Even a single phase with a separator suggests it's in accumulated format
const summary = `### Implementation
Content
---
### Testing
More content`;
expect(isAccumulatedSummary(summary)).toBe(true);
});
});
describe('non-accumulated format detection', () => {
it('should return false for summary without separator', () => {
const summary = `### Implementation
Just content`;
expect(isAccumulatedSummary(summary)).toBe(false);
});
it('should return false for summary with separator but no headers', () => {
const summary = `Content
---
More content`;
expect(isAccumulatedSummary(summary)).toBe(false);
});
it('should return false for simple text summary', () => {
const summary = 'Just a simple summary without any special formatting';
expect(isAccumulatedSummary(summary)).toBe(false);
});
it('should return false for markdown summary without phase headers', () => {
const summary = `## Changes Made
- Created feature
- Added tests`;
expect(isAccumulatedSummary(summary)).toBe(false);
});
});
describe('edge cases', () => {
it('should return false for undefined summary', () => {
expect(isAccumulatedSummary(undefined)).toBe(false);
});
it('should return false for null summary', () => {
expect(isAccumulatedSummary(null as unknown as string)).toBe(false);
});
it('should return false for empty string', () => {
expect(isAccumulatedSummary('')).toBe(false);
});
it('should return false for whitespace-only string', () => {
expect(isAccumulatedSummary(' \n\n ')).toBe(false);
});
});
});
describe('Integration: Full parsing workflow', () => {
it('should correctly parse typical server-accumulated pipeline summary', () => {
// This simulates what FeatureStateManager.saveFeatureSummary() produces
const summary = [
'### Implementation',
'',
'## Changes',
'- Added auth module',
'- Created user service',
'',
'---',
'',
'### Code Review',
'',
'## Review Results',
'- Style issues fixed',
'- Added error handling',
'',
'---',
'',
'### Testing',
'',
'## Test Results',
'- 42 tests pass',
'- 98% coverage',
].join('\n');
// Verify isAccumulatedSummary
expect(isAccumulatedSummary(summary)).toBe(true);
// Verify parsePhaseSummaries
const phases = parsePhaseSummaries(summary);
expect(phases.size).toBe(3);
expect(phases.get('implementation')).toContain('Added auth module');
expect(phases.get('code review')).toContain('Style issues fixed');
expect(phases.get('testing')).toContain('42 tests pass');
// Verify extractPhaseSummary
expect(extractPhaseSummary(summary, 'Implementation')).toContain('Added auth module');
expect(extractPhaseSummary(summary, 'Code Review')).toContain('Style issues fixed');
expect(extractPhaseSummary(summary, 'Testing')).toContain('42 tests pass');
// Verify extractImplementationSummary
expect(extractImplementationSummary(summary)).toContain('Added auth module');
});
it('should handle legacy non-pipeline summary correctly', () => {
// Legacy features have simple summaries without accumulation
const summary = `## Implementation Complete
- Created the feature
- All tests pass`;
// Should NOT be detected as accumulated
expect(isAccumulatedSummary(summary)).toBe(false);
// parsePhaseSummaries should return empty
const phases = parsePhaseSummaries(summary);
expect(phases.size).toBe(0);
// extractPhaseSummary should return null
expect(extractPhaseSummary(summary, 'Implementation')).toBeNull();
// extractImplementationSummary should return the full summary (legacy handling)
expect(extractImplementationSummary(summary)).toBe(summary);
});
it('should handle single-step pipeline summary', () => {
// A single pipeline step still gets the header but no separator
const summary = `### Implementation
## Changes
- Created the feature`;
// Should NOT be detected as accumulated (no separator)
expect(isAccumulatedSummary(summary)).toBe(false);
// parsePhaseSummaries should still extract the single phase
const phases = parsePhaseSummaries(summary);
expect(phases.size).toBe(1);
expect(phases.get('implementation')).toContain('Created the feature');
});
});
/**
* KEY ARCHITECTURE NOTES:
*
* 1. The accumulated summary format uses:
* - `### PhaseName` for step headers
* - `\n\n---\n\n` as separator between steps
*
* 2. Phase names are normalized to lowercase in the Map for case-insensitive lookup.
*
* 3. Legacy summaries (non-pipeline features) don't have phase headers and should
* be returned as-is by extractImplementationSummary.
*
* 4. isAccumulatedSummary() checks for BOTH separator AND phase headers to be
* confident that the summary is in the accumulated format.
*
* 5. The server-side FeatureStateManager.saveFeatureSummary() is responsible for
* creating summaries in this accumulated format.
*/
describe('parseAllPhaseSummaries', () => {
describe('basic parsing', () => {
it('should parse single phase summary into array with one entry', () => {
const summary = `### Implementation
## Changes Made
- Created new module
- Added unit tests`;
const result = parseAllPhaseSummaries(summary);
expect(result.length).toBe(1);
expect(result[0].phaseName).toBe('Implementation');
expect(result[0].content).toBe('## Changes Made\n- Created new module\n- Added unit tests');
expect(result[0].header).toBe('### Implementation');
});
it('should parse multiple phase summaries in order', () => {
const summary = `### Implementation
## Changes Made
- Created new module
---
### Testing
## Test Results
- All tests pass`;
const result = parseAllPhaseSummaries(summary);
expect(result.length).toBe(2);
// Verify order is preserved
expect(result[0].phaseName).toBe('Implementation');
expect(result[0].content).toBe('## Changes Made\n- Created new module');
expect(result[1].phaseName).toBe('Testing');
expect(result[1].content).toBe('## Test Results\n- All tests pass');
});
it('should parse three or more phases in correct order', () => {
const summary = `### Planning
Plan created
---
### Implementation
Code written
---
### Testing
Tests pass
---
### Refinement
Code polished`;
const result = parseAllPhaseSummaries(summary);
expect(result.length).toBe(4);
expect(result[0].phaseName).toBe('Planning');
expect(result[1].phaseName).toBe('Implementation');
expect(result[2].phaseName).toBe('Testing');
expect(result[3].phaseName).toBe('Refinement');
});
});
describe('non-accumulated summary handling', () => {
it('should return single entry for summary without phase headers', () => {
const summary = `## Changes Made
- Created feature
- Added tests`;
const result = parseAllPhaseSummaries(summary);
expect(result.length).toBe(1);
expect(result[0].phaseName).toBe('Summary');
expect(result[0].content).toBe(summary);
});
it('should return single entry for simple text summary', () => {
const summary = 'Just a simple summary without any special formatting';
const result = parseAllPhaseSummaries(summary);
expect(result.length).toBe(1);
expect(result[0].phaseName).toBe('Summary');
expect(result[0].content).toBe(summary);
});
});
describe('edge cases', () => {
it('should return empty array for undefined summary', () => {
const result = parseAllPhaseSummaries(undefined);
expect(result.length).toBe(0);
});
it('should return empty array for empty string', () => {
const result = parseAllPhaseSummaries('');
expect(result.length).toBe(0);
});
it('should return empty array for whitespace-only string', () => {
const result = parseAllPhaseSummaries(' \n\n ');
expect(result.length).toBe(0);
});
it('should handle section without header after separator', () => {
const summary = `### Implementation
Content here
---
This section has no header`;
const result = parseAllPhaseSummaries(summary);
expect(result.length).toBe(1);
expect(result[0].phaseName).toBe('Implementation');
});
});
describe('content preservation', () => {
it('should preserve markdown formatting in content', () => {
const summary = `### Implementation
## Heading
- **Bold text**
- \`code\`
\`\`\`typescript
const x = 1;
\`\`\``;
const result = parseAllPhaseSummaries(summary);
const content = result[0].content;
expect(content).toContain('**Bold text**');
expect(content).toContain('`code`');
expect(content).toContain('```typescript');
});
it('should preserve unicode in content', () => {
const summary = `### Testing
Results: ✅ 42 passed, ❌ 0 failed`;
const result = parseAllPhaseSummaries(summary);
expect(result[0].content).toContain('✅');
expect(result[0].content).toContain('❌');
});
it('should preserve tables in content', () => {
const summary = `### Testing
| Test | Result |
|------|--------|
| Unit | Pass |`;
const result = parseAllPhaseSummaries(summary);
expect(result[0].content).toContain('| Test | Result |');
});
it('should handle empty phase content', () => {
const summary = `### Implementation
---
### Testing
Content`;
const result = parseAllPhaseSummaries(summary);
expect(result.length).toBe(2);
expect(result[0].content).toBe('');
expect(result[1].content).toBe('Content');
});
});
describe('header preservation', () => {
it('should preserve original header text', () => {
const summary = `### Code Review
Content`;
const result = parseAllPhaseSummaries(summary);
expect(result[0].header).toBe('### Code Review');
});
it('should preserve phase name with original casing', () => {
const summary = `### CODE REVIEW
Content`;
const result = parseAllPhaseSummaries(summary);
expect(result[0].phaseName).toBe('CODE REVIEW');
});
});
describe('chronological order preservation', () => {
it('should maintain order: Alpha before Beta before Gamma', () => {
const summary = `### Alpha
First
---
### Beta
Second
---
### Gamma
Third`;
const result = parseAllPhaseSummaries(summary);
expect(result.length).toBe(3);
const names = result.map((e) => e.phaseName);
expect(names).toEqual(['Alpha', 'Beta', 'Gamma']);
});
it('should preserve typical pipeline order', () => {
const summary = [
'### Implementation',
'',
'## Changes',
'- Added auth module',
'',
'---',
'',
'### Code Review',
'',
'## Review Results',
'- Style issues fixed',
'',
'---',
'',
'### Testing',
'',
'## Test Results',
'- 42 tests pass',
].join('\n');
const result = parseAllPhaseSummaries(summary);
expect(result.length).toBe(3);
expect(result[0].phaseName).toBe('Implementation');
expect(result[1].phaseName).toBe('Code Review');
expect(result[2].phaseName).toBe('Testing');
});
});
});

View File

@@ -0,0 +1,453 @@
/**
* Unit tests for the UI's log-parser extractSummary() function.
*
* These tests document the behavior of extractSummary() which is used as a
* CLIENT-SIDE FALLBACK when feature.summary (server-accumulated) is not available.
*
* IMPORTANT: extractSummary() returns only the LAST <summary> tag from raw output.
* For pipeline features with multiple steps, the server-side FeatureStateManager
* accumulates all step summaries into feature.summary, which the UI prefers.
*
* The tests below verify that extractSummary() correctly:
* - Returns the LAST summary when multiple exist (mimicking pipeline accumulation)
* - Handles various summary formats (<summary> tags, markdown headers)
* - Returns null when no summary is found
* - Handles edge cases like empty input and malformed tags
*/
import { describe, it, expect } from 'vitest';
// Recreate the extractSummary logic from apps/ui/src/lib/log-parser.ts
// We can't import directly because it's a UI file, so we mirror the logic here
/**
* Cleans up fragmented streaming text by removing spurious newlines
*/
function cleanFragmentedText(content: string): string {
let cleaned = content.replace(/([a-zA-Z])\n+([a-zA-Z])/g, '$1$2');
cleaned = cleaned.replace(/<([a-zA-Z]+)\n*([a-zA-Z]*)\n*>/g, '<$1$2>');
cleaned = cleaned.replace(/<\/([a-zA-Z]+)\n*([a-zA-Z]*)\n*>/g, '</$1$2>');
return cleaned;
}
/**
* Extracts summary content from raw log output
* Returns the LAST summary text if found, or null if no summary exists
*/
function extractSummary(rawOutput: string): string | null {
if (!rawOutput || !rawOutput.trim()) {
return null;
}
const cleanedOutput = cleanFragmentedText(rawOutput);
const regexesToTry: Array<{
regex: RegExp;
processor: (m: RegExpMatchArray) => string;
}> = [
{ regex: /<summary>([\s\S]*?)<\/summary>/gi, processor: (m) => m[1] },
{ regex: /^##\s+Summary[^\n]*\n([\s\S]*?)(?=\n##\s+[^#]|\n🔧|$)/gm, processor: (m) => m[1] },
{
regex: /^##\s+(Feature|Changes|Implementation)[^\n]*\n([\s\S]*?)(?=\n##\s+[^#]|\n🔧|$)/gm,
processor: (m) => `## ${m[1]}\n${m[2]}`,
},
{
regex: /(^|\n)(All tasks completed[\s\S]*?)(?=\n🔧|\n📋|\n⚡|\n❌|$)/g,
processor: (m) => m[2],
},
{
regex:
/(^|\n)((I've|I have) (successfully |now )?(completed|finished|implemented)[\s\S]*?)(?=\n🔧|\n📋|\n⚡|\n❌|$)/g,
processor: (m) => m[2],
},
];
for (const { regex, processor } of regexesToTry) {
const matches = [...cleanedOutput.matchAll(regex)];
if (matches.length > 0) {
const lastMatch = matches[matches.length - 1];
return cleanFragmentedText(processor(lastMatch)).trim();
}
}
return null;
}
describe('log-parser extractSummary (UI fallback)', () => {
describe('basic summary extraction', () => {
it('should extract summary from <summary> tags', () => {
const output = `
Some agent output...
<summary>
## Changes Made
- Fixed the bug in parser.ts
- Added error handling
</summary>
More output...
`;
const result = extractSummary(output);
expect(result).toBe('## Changes Made\n- Fixed the bug in parser.ts\n- Added error handling');
});
it('should prefer <summary> tags over markdown headers', () => {
const output = `
## Summary
Markdown summary here.
<summary>
XML summary here.
</summary>
`;
const result = extractSummary(output);
expect(result).toBe('XML summary here.');
});
});
describe('multiple summaries (pipeline accumulation scenario)', () => {
it('should return ONLY the LAST summary tag when multiple exist', () => {
// This is the key behavior for pipeline features:
// extractSummary returns only the LAST, which is why server-side
// accumulation is needed for multi-step pipelines
const output = `
## Step 1: Code Review
<summary>
- Found 3 issues
- Approved with changes
</summary>
---
## Step 2: Testing
<summary>
- All tests pass
- Coverage 95%
</summary>
`;
const result = extractSummary(output);
expect(result).toBe('- All tests pass\n- Coverage 95%');
expect(result).not.toContain('Code Review');
expect(result).not.toContain('Found 3 issues');
});
it('should return ONLY the LAST summary from three pipeline steps', () => {
const output = `
<summary>Step 1 complete</summary>
---
<summary>Step 2 complete</summary>
---
<summary>Step 3 complete - all done!</summary>
`;
const result = extractSummary(output);
expect(result).toBe('Step 3 complete - all done!');
expect(result).not.toContain('Step 1');
expect(result).not.toContain('Step 2');
});
it('should handle mixed summary formats across pipeline steps', () => {
const output = `
## Step 1
<summary>
Implementation done
</summary>
---
## Step 2
## Summary
Review complete
---
## Step 3
<summary>
All tests passing
</summary>
`;
const result = extractSummary(output);
// The <summary> tag format takes priority, and returns the LAST match
expect(result).toBe('All tests passing');
});
});
describe('priority order of summary patterns', () => {
it('should try patterns in priority order: <summary> first, then markdown headers', () => {
// When both <summary> tags and markdown headers exist,
// <summary> tags should take priority
const output = `
## Summary
This markdown summary should be ignored.
<summary>
This XML summary should be used.
</summary>
`;
const result = extractSummary(output);
expect(result).toBe('This XML summary should be used.');
expect(result).not.toContain('ignored');
});
it('should fall back to Feature/Changes/Implementation headers when no <summary> tag', () => {
// Note: The regex for these headers requires content before the header
// (^ at start or preceded by newline). Adding some content before.
const output = `
Agent output here...
## Feature
New authentication system with OAuth support.
## Next
`;
const result = extractSummary(output);
// Should find the Feature header and include it in result
// Note: Due to regex behavior, it captures content until next ##
expect(result).toContain('## Feature');
});
it('should fall back to completion phrases when no structured summary found', () => {
const output = `
Working on the feature...
Making progress...
All tasks completed successfully. The feature is ready.
🔧 Tool: Bash
`;
const result = extractSummary(output);
expect(result).toContain('All tasks completed');
});
});
describe('edge cases', () => {
it('should return null for empty string', () => {
expect(extractSummary('')).toBeNull();
});
it('should return null for whitespace-only string', () => {
expect(extractSummary(' \n\n ')).toBeNull();
});
it('should return null when no summary pattern found', () => {
expect(extractSummary('Random agent output without any summary patterns')).toBeNull();
});
it('should handle malformed <summary> tags gracefully', () => {
const output = `
<summary>
This summary is never closed...
`;
// Without closing tag, the regex won't match
expect(extractSummary(output)).toBeNull();
});
it('should handle empty <summary> tags', () => {
const output = `
<summary></summary>
`;
const result = extractSummary(output);
expect(result).toBe(''); // Empty string is valid
});
it('should handle <summary> tags with only whitespace', () => {
const output = `
<summary>
</summary>
`;
const result = extractSummary(output);
expect(result).toBe(''); // Trimmed to empty string
});
it('should handle summary with markdown code blocks', () => {
const output = `
<summary>
## Changes
\`\`\`typescript
const x = 1;
\`\`\`
Done!
</summary>
`;
const result = extractSummary(output);
expect(result).toContain('```typescript');
expect(result).toContain('const x = 1;');
});
it('should handle summary with special characters', () => {
const output = `
<summary>
Fixed bug in parser.ts: "quotes" and 'apostrophes'
Special chars: <>&$@#%^*
</summary>
`;
const result = extractSummary(output);
expect(result).toContain('"quotes"');
expect(result).toContain('<>&$@#%^*');
});
});
describe('fragmented streaming text handling', () => {
it('should handle fragmented <summary> tags from streaming', () => {
// Sometimes streaming providers split text like "<sum\n\nmary>"
const output = `
<sum
mary>
Fixed the issue
</sum
mary>
`;
const result = extractSummary(output);
// The cleanFragmentedText function should normalize this
expect(result).toBe('Fixed the issue');
});
it('should handle fragmented text within summary content', () => {
const output = `
<summary>
Fixed the bug in par
ser.ts
</summary>
`;
const result = extractSummary(output);
// cleanFragmentedText should join "par\n\nser" into "parser"
expect(result).toBe('Fixed the bug in parser.ts');
});
});
describe('completion phrase detection', () => {
it('should extract "All tasks completed" summaries', () => {
const output = `
Some output...
All tasks completed successfully. The feature is ready for review.
🔧 Tool: Bash
`;
const result = extractSummary(output);
expect(result).toContain('All tasks completed');
});
it("should extract I've completed summaries", () => {
const output = `
Working on feature...
I've successfully implemented the feature with all requirements met.
🔧 Tool: Read
`;
const result = extractSummary(output);
expect(result).toContain("I've successfully implemented");
});
it('should extract "I have finished" summaries', () => {
const output = `
Implementation phase...
I have finished the implementation.
📋 Planning
`;
const result = extractSummary(output);
expect(result).toContain('I have finished');
});
});
describe('real-world pipeline scenarios', () => {
it('should handle typical multi-step pipeline output (returns last only)', () => {
// This test documents WHY server-side accumulation is essential:
// extractSummary only returns the last step's summary
const output = `
📋 Planning Mode: Full
🔧 Tool: Read
Input: {"file_path": "src/parser.ts"}
<summary>
## Code Review
- Analyzed parser.ts
- Found potential improvements
</summary>
---
## Follow-up Session
🔧 Tool: Edit
Input: {"file_path": "src/parser.ts"}
<summary>
## Implementation
- Applied suggested improvements
- Updated tests
</summary>
---
## Follow-up Session
🔧 Tool: Bash
Input: {"command": "npm test"}
<summary>
## Testing
- All 42 tests pass
- No regressions detected
</summary>
`;
const result = extractSummary(output);
// Only the LAST summary is returned
expect(result).toBe('## Testing\n- All 42 tests pass\n- No regressions detected');
// Earlier summaries are lost
expect(result).not.toContain('Code Review');
expect(result).not.toContain('Implementation');
});
it('should handle single-step non-pipeline output', () => {
// For non-pipeline features, extractSummary works correctly
const output = `
Working on feature...
<summary>
## Implementation Complete
- Created new component
- Added unit tests
- Updated documentation
</summary>
`;
const result = extractSummary(output);
expect(result).toContain('Implementation Complete');
expect(result).toContain('Created new component');
});
});
});
/**
* These tests verify the UI fallback behavior for summary extraction.
*
* KEY INSIGHT: The extractSummary() function returns only the LAST summary,
* which is why the server-side FeatureStateManager.saveFeatureSummary() method
* accumulates all step summaries into feature.summary.
*
* The UI's AgentOutputModal component uses this priority:
* 1. feature.summary (server-accumulated, contains all steps)
* 2. extractSummary(output) (client-side fallback, last summary only)
*
* For pipeline features, this ensures all step summaries are displayed.
*/

View File

@@ -0,0 +1,533 @@
/**
* Unit tests for the UI's log-parser phase summary parsing functions.
*
* These tests verify the behavior of:
* - parsePhaseSummaries(): Parses accumulated summary into individual phases
* - extractPhaseSummary(): Extracts a specific phase's summary
* - extractImplementationSummary(): Extracts only the implementation phase
* - isAccumulatedSummary(): Checks if summary is in accumulated format
*
* The accumulated summary format uses markdown headers with `###` for phase names
* and `---` as separators between phases.
*
* TODO: These test helper functions are mirrored from apps/ui/src/lib/log-parser.ts
* because server-side tests cannot import from the UI module. If the production
* implementation changes, these tests may pass while production fails.
* Consider adding an integration test that validates the actual UI parsing behavior.
*/
import { describe, it, expect } from 'vitest';
// ============================================================================
// MIRRORED FUNCTIONS from apps/ui/src/lib/log-parser.ts
// ============================================================================
// NOTE: These functions are mirrored from the UI implementation because
// server-side tests cannot import from apps/ui/. Keep these in sync with the
// production implementation. The UI implementation includes additional
// handling for getPhaseSections/leadingImplementationSection for backward
// compatibility with mixed formats.
/**
* Parses an accumulated summary string into individual phase summaries.
*/
function parsePhaseSummaries(summary: string | undefined): Map<string, string> {
const phaseSummaries = new Map<string, string>();
if (!summary || !summary.trim()) {
return phaseSummaries;
}
// Split by the horizontal rule separator
const sections = summary.split(/\n\n---\n\n/);
for (const section of sections) {
// Match the phase header pattern: ### Phase Name
const headerMatch = section.match(/^###\s+(.+?)(?:\n|$)/);
if (headerMatch) {
const phaseName = headerMatch[1].trim().toLowerCase();
// Extract content after the header (skip the header line and leading newlines)
const content = section.substring(headerMatch[0].length).trim();
phaseSummaries.set(phaseName, content);
}
}
return phaseSummaries;
}
/**
* Extracts a specific phase summary from an accumulated summary string.
*/
function extractPhaseSummary(summary: string | undefined, phaseName: string): string | null {
const phaseSummaries = parsePhaseSummaries(summary);
const normalizedPhaseName = phaseName.toLowerCase();
return phaseSummaries.get(normalizedPhaseName) || null;
}
/**
* Gets the implementation phase summary from an accumulated summary string.
*/
function extractImplementationSummary(summary: string | undefined): string | null {
if (!summary || !summary.trim()) {
return null;
}
const phaseSummaries = parsePhaseSummaries(summary);
// Try exact match first
const implementationContent = phaseSummaries.get('implementation');
if (implementationContent) {
return implementationContent;
}
// Fallback: find any phase containing "implement"
for (const [phaseName, content] of phaseSummaries) {
if (phaseName.includes('implement')) {
return content;
}
}
// If no phase summaries found, the summary might not be in accumulated format
// (legacy or non-pipeline feature). In this case, return the whole summary
// if it looks like a single summary (no phase headers).
if (!summary.includes('### ') && !summary.includes('\n---\n')) {
return summary;
}
return null;
}
/**
* Checks if a summary string is in the accumulated multi-phase format.
*/
function isAccumulatedSummary(summary: string | undefined): boolean {
if (!summary || !summary.trim()) {
return false;
}
// Check for the presence of phase headers with separator
const hasMultiplePhases =
summary.includes('\n\n---\n\n') && summary.match(/###\s+.+/g)?.length > 0;
return hasMultiplePhases;
}
describe('phase summary parser', () => {
describe('parsePhaseSummaries', () => {
it('should parse single phase summary', () => {
const summary = `### Implementation
Created auth module with login functionality.`;
const result = parsePhaseSummaries(summary);
expect(result.size).toBe(1);
expect(result.get('implementation')).toBe('Created auth module with login functionality.');
});
it('should parse multiple phase summaries', () => {
const summary = `### Implementation
Created auth module.
---
### Testing
All tests pass.
---
### Code Review
Approved with minor suggestions.`;
const result = parsePhaseSummaries(summary);
expect(result.size).toBe(3);
expect(result.get('implementation')).toBe('Created auth module.');
expect(result.get('testing')).toBe('All tests pass.');
expect(result.get('code review')).toBe('Approved with minor suggestions.');
});
it('should handle empty input', () => {
expect(parsePhaseSummaries('').size).toBe(0);
expect(parsePhaseSummaries(undefined).size).toBe(0);
expect(parsePhaseSummaries(' \n\n ').size).toBe(0);
});
it('should handle phase names with spaces', () => {
const summary = `### Code Review
Review findings here.`;
const result = parsePhaseSummaries(summary);
expect(result.get('code review')).toBe('Review findings here.');
});
it('should normalize phase names to lowercase', () => {
const summary = `### IMPLEMENTATION
Content here.`;
const result = parsePhaseSummaries(summary);
expect(result.get('implementation')).toBe('Content here.');
expect(result.get('IMPLEMENTATION')).toBeUndefined();
});
it('should handle content with markdown', () => {
const summary = `### Implementation
## Changes Made
- Fixed bug in parser.ts
- Added error handling
\`\`\`typescript
const x = 1;
\`\`\``;
const result = parsePhaseSummaries(summary);
expect(result.get('implementation')).toContain('## Changes Made');
expect(result.get('implementation')).toContain('```typescript');
});
it('should return empty map for non-accumulated format', () => {
// Legacy format without phase headers
const summary = `## Summary
This is a simple summary without phase headers.`;
const result = parsePhaseSummaries(summary);
expect(result.size).toBe(0);
});
});
describe('extractPhaseSummary', () => {
it('should extract specific phase by name (case-insensitive)', () => {
const summary = `### Implementation
Implementation content.
---
### Testing
Testing content.`;
expect(extractPhaseSummary(summary, 'implementation')).toBe('Implementation content.');
expect(extractPhaseSummary(summary, 'IMPLEMENTATION')).toBe('Implementation content.');
expect(extractPhaseSummary(summary, 'Implementation')).toBe('Implementation content.');
expect(extractPhaseSummary(summary, 'testing')).toBe('Testing content.');
});
it('should return null for non-existent phase', () => {
const summary = `### Implementation
Content here.`;
expect(extractPhaseSummary(summary, 'code review')).toBeNull();
});
it('should return null for empty input', () => {
expect(extractPhaseSummary('', 'implementation')).toBeNull();
expect(extractPhaseSummary(undefined, 'implementation')).toBeNull();
});
});
describe('extractImplementationSummary', () => {
it('should extract implementation phase from accumulated summary', () => {
const summary = `### Implementation
Created auth module.
---
### Testing
All tests pass.
---
### Code Review
Approved.`;
const result = extractImplementationSummary(summary);
expect(result).toBe('Created auth module.');
expect(result).not.toContain('Testing');
expect(result).not.toContain('Code Review');
});
it('should return implementation phase even when not first', () => {
const summary = `### Planning
Plan created.
---
### Implementation
Implemented the feature.
---
### Review
Reviewed.`;
const result = extractImplementationSummary(summary);
expect(result).toBe('Implemented the feature.');
});
it('should handle phase with "implementation" in name', () => {
const summary = `### Feature Implementation
Built the feature.`;
const result = extractImplementationSummary(summary);
expect(result).toBe('Built the feature.');
});
it('should return full summary for non-accumulated format (legacy)', () => {
// Non-pipeline features store summary without phase headers
const summary = `## Changes
- Fixed bug
- Added tests`;
const result = extractImplementationSummary(summary);
expect(result).toBe(summary);
});
it('should return null for empty input', () => {
expect(extractImplementationSummary('')).toBeNull();
expect(extractImplementationSummary(undefined)).toBeNull();
expect(extractImplementationSummary(' \n\n ')).toBeNull();
});
it('should return null when no implementation phase in accumulated summary', () => {
const summary = `### Testing
Tests written.
---
### Code Review
Approved.`;
const result = extractImplementationSummary(summary);
expect(result).toBeNull();
});
});
describe('isAccumulatedSummary', () => {
it('should return true for accumulated multi-phase summary', () => {
const summary = `### Implementation
Content.
---
### Testing
Content.`;
expect(isAccumulatedSummary(summary)).toBe(true);
});
it('should return false for single phase summary (no separator)', () => {
const summary = `### Implementation
Content.`;
expect(isAccumulatedSummary(summary)).toBe(false);
});
it('should return false for legacy non-accumulated format', () => {
const summary = `## Summary
This is a simple summary.`;
expect(isAccumulatedSummary(summary)).toBe(false);
});
it('should return false for empty input', () => {
expect(isAccumulatedSummary('')).toBe(false);
expect(isAccumulatedSummary(undefined)).toBe(false);
expect(isAccumulatedSummary(' \n\n ')).toBe(false);
});
it('should return true even for two phases', () => {
const summary = `### Implementation
Content A.
---
### Code Review
Content B.`;
expect(isAccumulatedSummary(summary)).toBe(true);
});
});
describe('acceptance criteria scenarios', () => {
it('AC1: Implementation summary preserved when Testing completes', () => {
// Given a task card completes the Implementation phase,
// when the Testing phase subsequently completes,
// then the Implementation phase summary must remain stored independently
const summary = `### Implementation
- Created auth module
- Added user service
---
### Testing
- 42 tests pass
- 98% coverage`;
const impl = extractImplementationSummary(summary);
const testing = extractPhaseSummary(summary, 'testing');
expect(impl).toBe('- Created auth module\n- Added user service');
expect(testing).toBe('- 42 tests pass\n- 98% coverage');
expect(impl).not.toContain('Testing');
expect(testing).not.toContain('auth module');
});
it('AC4: Implementation Summary tab shows only implementation phase', () => {
// Given a task card has completed the Implementation phase
// (regardless of how many subsequent phases have run),
// when the user opens the "Implementation Summary" tab,
// then it must display only the summary produced by the Implementation phase
const summary = `### Implementation
Implementation phase output here.
---
### Testing
Testing phase output here.
---
### Code Review
Code review output here.`;
const impl = extractImplementationSummary(summary);
expect(impl).toBe('Implementation phase output here.');
expect(impl).not.toContain('Testing');
expect(impl).not.toContain('Code Review');
});
it('AC5: Empty state when implementation not started', () => {
// Given a task card has not yet started the Implementation phase
const summary = `### Planning
Planning phase complete.`;
const impl = extractImplementationSummary(summary);
// Should return null (UI shows "No implementation summary available")
expect(impl).toBeNull();
});
it('AC6: Single phase summary displayed correctly', () => {
// Given a task card where Implementation was the only completed phase
const summary = `### Implementation
Only implementation was done.`;
const impl = extractImplementationSummary(summary);
expect(impl).toBe('Only implementation was done.');
});
it('AC9: Mid-progress shows only completed phases', () => {
// Given a task card is mid-progress
// (e.g., Implementation and Testing complete, Code Review pending)
const summary = `### Implementation
Implementation done.
---
### Testing
Testing done.`;
const phases = parsePhaseSummaries(summary);
expect(phases.size).toBe(2);
expect(phases.has('implementation')).toBe(true);
expect(phases.has('testing')).toBe(true);
expect(phases.has('code review')).toBe(false);
});
it('AC10: All phases in chronological order', () => {
// Given all phases of a task card are complete
const summary = `### Implementation
First phase content.
---
### Testing
Second phase content.
---
### Code Review
Third phase content.`;
// ParsePhaseSummaries should preserve order
const phases = parsePhaseSummaries(summary);
const phaseNames = [...phases.keys()];
expect(phaseNames).toEqual(['implementation', 'testing', 'code review']);
});
it('AC17: Retried phase shows only latest', () => {
// Given a phase was retried, when viewing the Summary tab,
// only one entry for the retried phase must appear (the latest retry's summary)
//
// Note: The server-side FeatureStateManager overwrites the phase summary
// when the same phase runs again, so we only have one entry per phase name.
// This test verifies that the parser correctly handles this.
const summary = `### Implementation
First attempt content.
---
### Testing
First test run.
---
### Implementation
Retry content - fixed issues.
---
### Testing
Retry - all tests now pass.`;
const phases = parsePhaseSummaries(summary);
// The parser will have both entries, but Map keeps last value for same key
expect(phases.get('implementation')).toBe('Retry content - fixed issues.');
expect(phases.get('testing')).toBe('Retry - all tests now pass.');
});
});
});

View File

@@ -0,0 +1,238 @@
/**
* Unit tests for the summary auto-scroll detection logic.
*
* These tests verify the behavior of the scroll detection function used in
* AgentOutputModal to determine if auto-scroll should be enabled.
*
* The logic mirrors the handleSummaryScroll function in:
* apps/ui/src/components/views/board-view/dialogs/agent-output-modal.tsx
*
* Auto-scroll behavior:
* - When user is at or near the bottom (< 50px from bottom), auto-scroll is enabled
* - When user scrolls up to view older content, auto-scroll is disabled
* - Scrolling back to bottom re-enables auto-scroll
*/
import { describe, it, expect } from 'vitest';
/**
* Determines if the scroll position is at the bottom of the container.
* This is the core logic from handleSummaryScroll in AgentOutputModal.
*
* @param scrollTop - Current scroll position from top
* @param scrollHeight - Total scrollable height
* @param clientHeight - Visible height of the container
* @param threshold - Distance from bottom to consider "at bottom" (default: 50px)
* @returns true if at bottom, false otherwise
*/
function isScrollAtBottom(
scrollTop: number,
scrollHeight: number,
clientHeight: number,
threshold = 50
): boolean {
const distanceFromBottom = scrollHeight - scrollTop - clientHeight;
return distanceFromBottom < threshold;
}
describe('Summary Auto-Scroll Detection Logic', () => {
describe('basic scroll position detection', () => {
it('should return true when scrolled to exact bottom', () => {
// Container: 500px tall, content: 1000px tall
// ScrollTop: 500 (scrolled to bottom)
const result = isScrollAtBottom(500, 1000, 500);
expect(result).toBe(true);
});
it('should return true when near bottom (within threshold)', () => {
// 49px from bottom - within 50px threshold
const result = isScrollAtBottom(451, 1000, 500);
expect(result).toBe(true);
});
it('should return true when exactly at threshold boundary (49px)', () => {
// 49px from bottom
const result = isScrollAtBottom(451, 1000, 500);
expect(result).toBe(true);
});
it('should return false when just outside threshold (51px)', () => {
// 51px from bottom - outside 50px threshold
const result = isScrollAtBottom(449, 1000, 500);
expect(result).toBe(false);
});
it('should return false when scrolled to top', () => {
const result = isScrollAtBottom(0, 1000, 500);
expect(result).toBe(false);
});
it('should return false when scrolled to middle', () => {
const result = isScrollAtBottom(250, 1000, 500);
expect(result).toBe(false);
});
});
describe('edge cases with small content', () => {
it('should return true when content fits in viewport (no scroll needed)', () => {
// Content is smaller than container - no scrolling possible
const result = isScrollAtBottom(0, 300, 500);
expect(result).toBe(true);
});
it('should return true when content exactly fits viewport', () => {
const result = isScrollAtBottom(0, 500, 500);
expect(result).toBe(true);
});
it('should return true when content slightly exceeds viewport (within threshold)', () => {
// Content: 540px, Viewport: 500px, can scroll 40px
// At scroll 0, we're 40px from bottom - within threshold
const result = isScrollAtBottom(0, 540, 500);
expect(result).toBe(true);
});
});
describe('large content scenarios', () => {
it('should correctly detect bottom in very long content', () => {
// Simulate accumulated summary from many pipeline steps
// Content: 10000px, Viewport: 500px
const result = isScrollAtBottom(9500, 10000, 500);
expect(result).toBe(true);
});
it('should correctly detect non-bottom in very long content', () => {
// User scrolled up to read earlier summaries
const result = isScrollAtBottom(5000, 10000, 500);
expect(result).toBe(false);
});
it('should detect when user scrolls up from bottom', () => {
// Started at bottom (scroll: 9500), then scrolled up 100px
const result = isScrollAtBottom(9400, 10000, 500);
expect(result).toBe(false);
});
});
describe('custom threshold values', () => {
it('should work with larger threshold (100px)', () => {
// 75px from bottom - within 100px threshold
const result = isScrollAtBottom(425, 1000, 500, 100);
expect(result).toBe(true);
});
it('should work with smaller threshold (10px)', () => {
// 15px from bottom - outside 10px threshold
const result = isScrollAtBottom(485, 1000, 500, 10);
expect(result).toBe(false);
});
it('should work with zero threshold (exact match only)', () => {
// At exact bottom - distanceFromBottom = 0, which is NOT < 0 with strict comparison
// This is an edge case: the implementation uses < not <=
const result = isScrollAtBottom(500, 1000, 500, 0);
expect(result).toBe(false); // 0 < 0 is false
// 1px from bottom - also fails
const result2 = isScrollAtBottom(499, 1000, 500, 0);
expect(result2).toBe(false);
// For exact match with 0 threshold, we need negative distanceFromBottom
// which happens when scrollTop > scrollHeight - clientHeight (overscroll)
const result3 = isScrollAtBottom(501, 1000, 500, 0);
expect(result3).toBe(true); // -1 < 0 is true
});
});
describe('pipeline summary scrolling scenarios', () => {
it('should enable auto-scroll when new content arrives while at bottom', () => {
// User is at bottom viewing step 2 summary
// Step 3 summary is added, increasing scrollHeight from 1000 to 1500
// ScrollTop stays at 950 (was at bottom), but now user needs to scroll
// Before new content: isScrollAtBottom(950, 1000, 500) = true
// After new content: auto-scroll should kick in to scroll to new bottom
// Simulating the auto-scroll effect setting scrollTop to new bottom
const newScrollTop = 1500 - 500; // scrollHeight - clientHeight
const result = isScrollAtBottom(newScrollTop, 1500, 500);
expect(result).toBe(true);
});
it('should not auto-scroll when user is reading earlier summaries', () => {
// User scrolled up to read step 1 summary while step 3 is added
// scrollHeight increases, but scrollTop stays same
// User is now further from bottom
// User was at scroll position 200 (reading early content)
// New content increases scrollHeight from 1000 to 1500
// Distance from bottom goes from 300 to 800
const result = isScrollAtBottom(200, 1500, 500);
expect(result).toBe(false);
});
it('should re-enable auto-scroll when user scrolls back to bottom', () => {
// User was reading step 1 (scrollTop: 200)
// User scrolls back to bottom to see latest content
const result = isScrollAtBottom(1450, 1500, 500);
expect(result).toBe(true);
});
});
describe('decimal scroll values', () => {
it('should handle fractional scroll positions', () => {
// Browsers can report fractional scroll values
const result = isScrollAtBottom(499.5, 1000, 500);
expect(result).toBe(true);
});
it('should handle fractional scroll heights', () => {
const result = isScrollAtBottom(450.7, 1000.3, 500);
expect(result).toBe(true);
});
});
describe('negative and invalid inputs', () => {
it('should handle negative scrollTop (bounce scroll)', () => {
// iOS can report negative scrollTop during bounce
const result = isScrollAtBottom(-10, 1000, 500);
expect(result).toBe(false);
});
it('should handle zero scrollHeight', () => {
// Empty content
const result = isScrollAtBottom(0, 0, 500);
expect(result).toBe(true);
});
it('should handle zero clientHeight', () => {
// Hidden container - distanceFromBottom = 1000 - 0 - 0 = 1000
// This is not < threshold, so returns false
// This edge case represents a broken/invisible container
const result = isScrollAtBottom(0, 1000, 0);
expect(result).toBe(false);
});
});
describe('real-world accumulated summary dimensions', () => {
it('should handle typical 3-step pipeline summary dimensions', () => {
// Approximate: 3 steps x ~800px each = ~2400px
// Viewport: 400px (modal height)
const result = isScrollAtBottom(2000, 2400, 400);
expect(result).toBe(true);
});
it('should handle large 10-step pipeline summary dimensions', () => {
// Approximate: 10 steps x ~800px each = ~8000px
// Viewport: 400px
const result = isScrollAtBottom(7600, 8000, 400);
expect(result).toBe(true);
});
it('should detect scroll to top of large summary', () => {
// User at top of 10-step summary
const result = isScrollAtBottom(0, 8000, 400);
expect(result).toBe(false);
});
});
});

View File

@@ -0,0 +1,128 @@
/**
* Unit tests for summary normalization between UI components and parser functions.
*
* These tests verify that:
* - getFirstNonEmptySummary returns string | null
* - parseAllPhaseSummaries and isAccumulatedSummary expect string | undefined
* - The normalization (summary ?? undefined) correctly converts null to undefined
*
* This ensures the UI components properly bridge the type gap between:
* - getFirstNonEmptySummary (returns string | null)
* - parseAllPhaseSummaries (expects string | undefined)
* - isAccumulatedSummary (expects string | undefined)
*/
import { describe, it, expect } from 'vitest';
import { parseAllPhaseSummaries, isAccumulatedSummary } from '../../../../ui/src/lib/log-parser.ts';
import { getFirstNonEmptySummary } from '../../../../ui/src/lib/summary-selection.ts';
describe('Summary Normalization', () => {
describe('getFirstNonEmptySummary', () => {
it('should return the first non-empty string', () => {
const result = getFirstNonEmptySummary(null, undefined, 'valid summary', 'another');
expect(result).toBe('valid summary');
});
it('should return null when all candidates are empty', () => {
const result = getFirstNonEmptySummary(null, undefined, '', ' ');
expect(result).toBeNull();
});
it('should return null when no candidates provided', () => {
const result = getFirstNonEmptySummary();
expect(result).toBeNull();
});
it('should return null for all null/undefined candidates', () => {
const result = getFirstNonEmptySummary(null, undefined, null);
expect(result).toBeNull();
});
it('should preserve original string formatting (not trim)', () => {
const result = getFirstNonEmptySummary(' summary with spaces ');
expect(result).toBe(' summary with spaces ');
});
});
describe('parseAllPhaseSummaries with normalized input', () => {
it('should handle null converted to undefined via ?? operator', () => {
const summary = getFirstNonEmptySummary(null, undefined);
// This is the normalization: summary ?? undefined
const normalizedSummary = summary ?? undefined;
// TypeScript should accept this without error
const result = parseAllPhaseSummaries(normalizedSummary);
expect(result).toEqual([]);
});
it('should parse accumulated summary when non-null is normalized', () => {
const rawSummary =
'### Implementation\n\nDid some work\n\n---\n\n### Testing\n\nAll tests pass';
const summary = getFirstNonEmptySummary(null, rawSummary);
const normalizedSummary = summary ?? undefined;
const result = parseAllPhaseSummaries(normalizedSummary);
expect(result).toHaveLength(2);
expect(result[0].phaseName).toBe('Implementation');
expect(result[1].phaseName).toBe('Testing');
});
});
describe('isAccumulatedSummary with normalized input', () => {
it('should return false for null converted to undefined', () => {
const summary = getFirstNonEmptySummary(null, undefined);
const normalizedSummary = summary ?? undefined;
const result = isAccumulatedSummary(normalizedSummary);
expect(result).toBe(false);
});
it('should return true for valid accumulated summary after normalization', () => {
const rawSummary =
'### Implementation\n\nDid some work\n\n---\n\n### Testing\n\nAll tests pass';
const summary = getFirstNonEmptySummary(rawSummary);
const normalizedSummary = summary ?? undefined;
const result = isAccumulatedSummary(normalizedSummary);
expect(result).toBe(true);
});
it('should return false for single-phase summary after normalization', () => {
const rawSummary = '### Implementation\n\nDid some work';
const summary = getFirstNonEmptySummary(rawSummary);
const normalizedSummary = summary ?? undefined;
const result = isAccumulatedSummary(normalizedSummary);
expect(result).toBe(false);
});
});
describe('Type safety verification', () => {
it('should demonstrate that null must be normalized to undefined', () => {
// This test documents the type mismatch that requires normalization
const summary: string | null = getFirstNonEmptySummary(null);
const normalizedSummary: string | undefined = summary ?? undefined;
// parseAllPhaseSummaries expects string | undefined, not string | null
// The normalization converts null -> undefined, which is compatible
const result = parseAllPhaseSummaries(normalizedSummary);
expect(result).toEqual([]);
});
it('should work with the actual usage pattern from components', () => {
// Simulates the actual pattern used in summary-dialog.tsx and agent-output-modal.tsx
const featureSummary: string | null | undefined = null;
const extractedSummary: string | null | undefined = undefined;
const rawSummary = getFirstNonEmptySummary(featureSummary, extractedSummary);
const normalizedSummary = rawSummary ?? undefined;
// Both parser functions should work with the normalized value
const phases = parseAllPhaseSummaries(normalizedSummary);
const hasMultiple = isAccumulatedSummary(normalizedSummary);
expect(phases).toEqual([]);
expect(hasMultiple).toBe(false);
});
});
});

View File

@@ -0,0 +1,108 @@
import { describe, it, expect } from 'vitest';
import { parseAllPhaseSummaries, isAccumulatedSummary } from '../../../../ui/src/lib/log-parser.ts';
import { getFirstNonEmptySummary } from '../../../../ui/src/lib/summary-selection.ts';
/**
* Mirrors summary source priority in agent-info-panel.tsx:
* freshFeature.summary > feature.summary > summaryProp > agentInfo.summary
*/
function getCardEffectiveSummary(params: {
freshFeatureSummary?: string | null;
featureSummary?: string | null;
summaryProp?: string | null;
agentInfoSummary?: string | null;
}): string | undefined | null {
return getFirstNonEmptySummary(
params.freshFeatureSummary,
params.featureSummary,
params.summaryProp,
params.agentInfoSummary
);
}
/**
* Mirrors SummaryDialog raw summary selection in summary-dialog.tsx:
* summaryProp > feature.summary > agentInfo.summary
*/
function getDialogRawSummary(params: {
summaryProp?: string | null;
featureSummary?: string | null;
agentInfoSummary?: string | null;
}): string | undefined | null {
return getFirstNonEmptySummary(
params.summaryProp,
params.featureSummary,
params.agentInfoSummary
);
}
describe('Summary Source Flow Integration', () => {
it('uses fresh per-feature summary in card and preserves it through summary dialog', () => {
const staleListSummary = '## Old summary from stale list cache';
const freshAccumulatedSummary = `### Implementation
Implemented auth + profile flow.
---
### Testing
- Unit tests: 18 passed
- Integration tests: 6 passed`;
const parsedAgentInfoSummary = 'Fallback summary from parsed agent output';
const cardEffectiveSummary = getCardEffectiveSummary({
freshFeatureSummary: freshAccumulatedSummary,
featureSummary: staleListSummary,
summaryProp: undefined,
agentInfoSummary: parsedAgentInfoSummary,
});
expect(cardEffectiveSummary).toBe(freshAccumulatedSummary);
const dialogRawSummary = getDialogRawSummary({
summaryProp: cardEffectiveSummary,
featureSummary: staleListSummary,
agentInfoSummary: parsedAgentInfoSummary,
});
expect(dialogRawSummary).toBe(freshAccumulatedSummary);
expect(isAccumulatedSummary(dialogRawSummary ?? undefined)).toBe(true);
const phases = parseAllPhaseSummaries(dialogRawSummary ?? undefined);
expect(phases).toHaveLength(2);
expect(phases[0]?.phaseName).toBe('Implementation');
expect(phases[1]?.phaseName).toBe('Testing');
});
it('falls back in order when fresher sources are absent', () => {
const cardEffectiveSummary = getCardEffectiveSummary({
freshFeatureSummary: undefined,
featureSummary: '',
summaryProp: undefined,
agentInfoSummary: 'Agent parsed fallback',
});
expect(cardEffectiveSummary).toBe('Agent parsed fallback');
const dialogRawSummary = getDialogRawSummary({
summaryProp: undefined,
featureSummary: undefined,
agentInfoSummary: cardEffectiveSummary,
});
expect(dialogRawSummary).toBe('Agent parsed fallback');
expect(isAccumulatedSummary(dialogRawSummary ?? undefined)).toBe(false);
});
it('treats whitespace-only summaries as empty during fallback selection', () => {
const cardEffectiveSummary = getCardEffectiveSummary({
freshFeatureSummary: ' \n',
featureSummary: '\t',
summaryProp: ' ',
agentInfoSummary: 'Agent parsed fallback',
});
expect(cardEffectiveSummary).toBe('Agent parsed fallback');
});
});