FABRIC/src/sessionDigest.test.ts
jeda f6a7d09294 feat(bd-1c5): Implement AI session digest generation
Implement comprehensive session digest generation module that aggregates:
- Beads completed with duration tracking
- Files modified with tool and worker attribution
- Errors encountered with categorization
- Time spent per worker
- Token usage and estimated costs

Features:
- SessionDigestGenerator class for digest creation
- Markdown formatting with tables and summaries
- CLI integration via 'fabric digest' command
- Support for time range and worker filtering
- Comprehensive test coverage (14 tests, all passing)

CLI Usage:
  fabric digest -f <logfile> [options]
  Options: --output, --worker, --since, --until, --max-files, --max-errors

All tests passing: 616/616

Co-Authored-By: Claude <noreply@anthropic.com>
2026-03-04 03:28:04 +00:00

566 lines
15 KiB
TypeScript

/**
* Session Digest Tests
*/
import { describe, test, expect, beforeEach } from 'vitest';
import { SessionDigestGenerator, formatDigestAsMarkdown } from './sessionDigest.js';
import { InMemoryEventStore } from './store.js';
import { LogEvent, SessionDigest } from './types.js';
import { CostTracker } from './tui/utils/costTracking.js';
describe('SessionDigestGenerator', () => {
let store: InMemoryEventStore;
let generator: SessionDigestGenerator;
let costTracker: CostTracker;
beforeEach(() => {
store = new InMemoryEventStore();
costTracker = new CostTracker();
generator = new SessionDigestGenerator(store, costTracker);
});
describe('generateDigest', () => {
test('generates empty digest for no events', () => {
const digest = generator.generateDigest();
expect(digest.beadsCompleted).toHaveLength(0);
expect(digest.filesModified).toHaveLength(0);
expect(digest.errors).toHaveLength(0);
expect(digest.workers).toHaveLength(0);
expect(digest.stats.totalEvents).toBe(0);
expect(digest.stats.totalWorkers).toBe(0);
});
test('extracts bead completions from events', () => {
const now = Date.now();
// Add events showing bead work
const events: LogEvent[] = [
{
ts: now,
worker: 'w-abc',
level: 'info',
msg: 'Started working on bead',
bead: 'bd-123',
},
{
ts: now + 1000,
worker: 'w-abc',
level: 'info',
msg: 'Bead completed successfully',
bead: 'bd-123',
},
{
ts: now + 2000,
worker: 'w-xyz',
level: 'info',
msg: 'Task finished',
bead: 'bd-456',
},
];
events.forEach(e => store.add(e));
const digest = generator.generateDigest();
expect(digest.beadsCompleted.length).toBeGreaterThanOrEqual(1);
const bd123 = digest.beadsCompleted.find(b => b.beadId === 'bd-123');
expect(bd123).toBeDefined();
expect(bd123?.workerId).toBe('w-abc');
expect(bd123?.durationMs).toBeGreaterThan(0);
});
test('extracts file modifications', () => {
const now = Date.now();
const events: LogEvent[] = [
{
ts: now,
worker: 'w-abc',
level: 'info',
msg: 'Editing file',
tool: 'Edit',
path: '/src/file1.ts',
},
{
ts: now + 1000,
worker: 'w-abc',
level: 'info',
msg: 'Editing file again',
tool: 'Edit',
path: '/src/file1.ts',
},
{
ts: now + 2000,
worker: 'w-xyz',
level: 'info',
msg: 'Writing file',
tool: 'Write',
path: '/src/file2.ts',
},
{
ts: now + 3000,
worker: 'w-xyz',
level: 'info',
msg: 'Reading file',
tool: 'Read',
path: '/src/file3.ts',
},
];
events.forEach(e => store.add(e));
const digest = generator.generateDigest();
expect(digest.filesModified).toHaveLength(2);
const file1 = digest.filesModified.find(f => f.path === '/src/file1.ts');
expect(file1).toBeDefined();
expect(file1?.modifications).toBe(2);
expect(file1?.workers).toContain('w-abc');
expect(file1?.tools).toContain('Edit');
const file2 = digest.filesModified.find(f => f.path === '/src/file2.ts');
expect(file2).toBeDefined();
expect(file2?.modifications).toBe(1);
expect(file2?.workers).toContain('w-xyz');
// file3.ts should not be included (Read is not a modification)
const file3 = digest.filesModified.find(f => f.path === '/src/file3.ts');
expect(file3).toBeUndefined();
});
test('extracts errors from events', () => {
const now = Date.now();
const events: LogEvent[] = [
{
ts: now,
worker: 'w-abc',
level: 'error',
msg: 'ECONNREFUSED connection failed',
error: 'Network error',
},
{
ts: now + 1000,
worker: 'w-xyz',
level: 'error',
msg: 'File not found',
error: 'ENOENT: no such file',
},
{
ts: now + 2000,
worker: 'w-abc',
level: 'warn',
msg: 'Warning message',
},
];
events.forEach(e => store.add(e));
const digest = generator.generateDigest({ includeErrors: true });
expect(digest.errors).toHaveLength(2);
const networkError = digest.errors.find(e => e.category === 'network');
expect(networkError).toBeDefined();
expect(networkError?.workerId).toBe('w-abc');
const notFoundError = digest.errors.find(e => e.category === 'not_found');
expect(notFoundError).toBeDefined();
expect(notFoundError?.workerId).toBe('w-xyz');
});
test('generates worker summaries', () => {
const now = Date.now();
const events: LogEvent[] = [
{
ts: now,
worker: 'w-abc',
level: 'info',
msg: 'Event 1',
bead: 'bd-123',
path: '/src/file1.ts',
},
{
ts: now + 1000,
worker: 'w-abc',
level: 'info',
msg: 'Event 2',
bead: 'bd-123',
path: '/src/file2.ts',
},
{
ts: now + 2000,
worker: 'w-xyz',
level: 'error',
msg: 'Error occurred',
error: 'Test error',
},
];
events.forEach(e => store.add(e));
const digest = generator.generateDigest();
expect(digest.workers).toHaveLength(2);
const workerAbc = digest.workers.find(w => w.workerId === 'w-abc');
expect(workerAbc).toBeDefined();
expect(workerAbc?.totalEvents).toBe(2);
expect(workerAbc?.beadsCompleted).toBe(1);
expect(workerAbc?.filesModified).toBe(2);
expect(workerAbc?.errorsEncountered).toBe(0);
expect(workerAbc?.activeTimeMs).toBe(1000);
const workerXyz = digest.workers.find(w => w.workerId === 'w-xyz');
expect(workerXyz).toBeDefined();
expect(workerXyz?.totalEvents).toBe(1);
expect(workerXyz?.errorsEncountered).toBe(1);
});
test('calculates statistics correctly', () => {
const now = Date.now();
const events: LogEvent[] = [
{
ts: now,
worker: 'w-abc',
level: 'info',
msg: 'Working',
bead: 'bd-123',
tool: 'Edit',
path: '/file1.ts',
},
{
ts: now + 1000,
worker: 'w-xyz',
level: 'info',
msg: 'Working',
bead: 'bd-456',
tool: 'Write',
path: '/file2.ts',
},
{
ts: now + 2000,
worker: 'w-abc',
level: 'error',
msg: 'Error',
error: 'Test error',
},
];
events.forEach(e => store.add(e));
const digest = generator.generateDigest();
expect(digest.stats.totalEvents).toBe(3);
expect(digest.stats.totalWorkers).toBe(2);
expect(digest.stats.totalBeads).toBeGreaterThanOrEqual(2);
expect(digest.stats.totalFiles).toBe(2);
expect(digest.stats.totalErrors).toBe(1);
expect(digest.stats.avgEventsPerWorker).toBe(1.5);
});
test('filters by time range', () => {
const now = Date.now();
const events: LogEvent[] = [
{ ts: now - 2000, worker: 'w-abc', level: 'info', msg: 'Old event' },
{ ts: now, worker: 'w-abc', level: 'info', msg: 'Current event' },
{ ts: now + 2000, worker: 'w-abc', level: 'info', msg: 'Future event' },
];
events.forEach(e => store.add(e));
const digest = generator.generateDigest({
startTime: now - 500,
endTime: now + 500,
});
expect(digest.stats.totalEvents).toBe(1);
});
test('filters by workers', () => {
const now = Date.now();
const events: LogEvent[] = [
{ ts: now, worker: 'w-abc', level: 'info', msg: 'Event 1' },
{ ts: now + 1000, worker: 'w-xyz', level: 'info', msg: 'Event 2' },
{ ts: now + 2000, worker: 'w-123', level: 'info', msg: 'Event 3' },
];
events.forEach(e => store.add(e));
const digest = generator.generateDigest({
workers: ['w-abc', 'w-xyz'],
});
expect(digest.stats.totalEvents).toBe(2);
expect(digest.stats.totalWorkers).toBe(2);
});
test('limits files and errors', () => {
const now = Date.now();
// Add many files
for (let i = 0; i < 100; i++) {
store.add({
ts: now + i,
worker: 'w-abc',
level: 'info',
msg: `Editing file ${i}`,
tool: 'Edit',
path: `/src/file${i}.ts`,
});
}
// Add many errors
for (let i = 0; i < 100; i++) {
store.add({
ts: now + 1000 + i,
worker: 'w-abc',
level: 'error',
msg: `Error ${i}`,
error: `Error message ${i}`,
});
}
const digest = generator.generateDigest({
maxFiles: 10,
maxErrors: 5,
});
expect(digest.filesModified.length).toBeLessThanOrEqual(10);
expect(digest.errors.length).toBeLessThanOrEqual(5);
});
test('includes cost data when token events are present', () => {
const now = Date.now();
const events: LogEvent[] = [
{
ts: now,
worker: 'w-abc',
level: 'info',
msg: 'API call',
input_tokens: 1000,
output_tokens: 500,
} as LogEvent,
{
ts: now + 1000,
worker: 'w-xyz',
level: 'info',
msg: 'API call',
input_tokens: 2000,
output_tokens: 1000,
} as LogEvent,
];
events.forEach(e => store.add(e));
const digest = generator.generateDigest({ includeCost: true });
expect(digest.cost.totalTokens).toBe(4500);
expect(digest.cost.inputTokens).toBe(3000);
expect(digest.cost.outputTokens).toBe(1500);
expect(digest.cost.estimatedCostUsd).toBeGreaterThan(0);
});
});
describe('formatDigestAsMarkdown', () => {
test('formats empty digest', () => {
const now = Date.now();
const digest: SessionDigest = {
sessionId: 'test-session',
startTime: now,
endTime: now + 1000,
durationMs: 1000,
beadsCompleted: [],
filesModified: [],
errors: [],
workers: [],
cost: {
totalTokens: 0,
inputTokens: 0,
outputTokens: 0,
estimatedCostUsd: 0,
},
stats: {
totalEvents: 0,
totalWorkers: 0,
totalBeads: 0,
totalFiles: 0,
totalErrors: 0,
avgEventsPerWorker: 0,
avgBeadsPerWorker: 0,
},
};
const markdown = formatDigestAsMarkdown(digest);
expect(markdown).toContain('# Session Digest');
expect(markdown).toContain('test-session');
expect(markdown).toContain('## Summary');
expect(markdown).toContain('Total Events');
});
test('formats complete digest with all sections', () => {
const now = Date.now();
const digest: SessionDigest = {
sessionId: 'test-session',
startTime: now,
endTime: now + 60000,
durationMs: 60000,
beadsCompleted: [
{
beadId: 'bd-123',
workerId: 'w-abc',
completedAt: now + 30000,
durationMs: 30000,
},
],
filesModified: [
{
path: '/src/file1.ts',
modifications: 5,
workers: ['w-abc'],
tools: ['Edit'],
},
],
errors: [
{
message: 'Test error',
category: 'network',
workerId: 'w-abc',
timestamp: now + 10000,
},
],
workers: [
{
workerId: 'w-abc',
beadsCompleted: 1,
filesModified: 1,
errorsEncountered: 1,
totalEvents: 10,
activeTimeMs: 60000,
firstActivity: now,
lastActivity: now + 60000,
},
],
cost: {
totalTokens: 5000,
inputTokens: 3000,
outputTokens: 2000,
estimatedCostUsd: 0.05,
},
stats: {
totalEvents: 10,
totalWorkers: 1,
totalBeads: 1,
totalFiles: 1,
totalErrors: 1,
avgEventsPerWorker: 10,
avgBeadsPerWorker: 1,
},
};
const markdown = formatDigestAsMarkdown(digest);
// Check all major sections
expect(markdown).toContain('# Session Digest');
expect(markdown).toContain('## Summary');
expect(markdown).toContain('## Cost Summary');
expect(markdown).toContain('## Worker Activity');
expect(markdown).toContain('## Beads Completed');
expect(markdown).toContain('## Files Modified');
expect(markdown).toContain('## Errors Encountered');
// Check content
expect(markdown).toContain('bd-123');
expect(markdown).toContain('w-abc');
expect(markdown).toContain('/src/file1.ts');
expect(markdown).toContain('Test error');
expect(markdown).toContain('5,000');
expect(markdown).toContain('$0.0500');
});
test('omits cost section when no tokens', () => {
const now = Date.now();
const digest: SessionDigest = {
sessionId: 'test-session',
startTime: now,
endTime: now + 1000,
durationMs: 1000,
beadsCompleted: [],
filesModified: [],
errors: [],
workers: [],
cost: {
totalTokens: 0,
inputTokens: 0,
outputTokens: 0,
estimatedCostUsd: 0,
},
stats: {
totalEvents: 10,
totalWorkers: 1,
totalBeads: 0,
totalFiles: 0,
totalErrors: 0,
avgEventsPerWorker: 10,
avgBeadsPerWorker: 0,
},
};
const markdown = formatDigestAsMarkdown(digest);
expect(markdown).not.toContain('## Cost Summary');
});
test('formats durations correctly', () => {
const now = Date.now();
const digest: SessionDigest = {
sessionId: 'test-session',
startTime: now,
endTime: now + 3723000, // 1h 2m 3s
durationMs: 3723000,
beadsCompleted: [],
filesModified: [],
errors: [],
workers: [
{
workerId: 'w-abc',
beadsCompleted: 0,
filesModified: 0,
errorsEncountered: 0,
totalEvents: 1,
activeTimeMs: 3723000,
firstActivity: now,
lastActivity: now + 3723000,
},
],
cost: {
totalTokens: 0,
inputTokens: 0,
outputTokens: 0,
estimatedCostUsd: 0,
},
stats: {
totalEvents: 1,
totalWorkers: 1,
totalBeads: 0,
totalFiles: 0,
totalErrors: 0,
avgEventsPerWorker: 1,
avgBeadsPerWorker: 0,
},
};
const markdown = formatDigestAsMarkdown(digest);
expect(markdown).toContain('1h 2m');
});
});
});