From 3f479dcf07de84910ce749cf8656a9e66a79b388 Mon Sep 17 00:00:00 2001 From: Paul Holstein <44263169+holstein13@users.noreply.github.com> Date: Sat, 21 Feb 2026 16:56:26 -0500 Subject: [PATCH] test(report): add session analyzer tests Co-Authored-By: Claude Opus 4.6 --- test/renderer/utils/sessionAnalyzer.test.ts | 1004 +++++++++++++++++++ 1 file changed, 1004 insertions(+) create mode 100644 test/renderer/utils/sessionAnalyzer.test.ts diff --git a/test/renderer/utils/sessionAnalyzer.test.ts b/test/renderer/utils/sessionAnalyzer.test.ts new file mode 100644 index 00000000..542ab5c6 --- /dev/null +++ b/test/renderer/utils/sessionAnalyzer.test.ts @@ -0,0 +1,1004 @@ +import { describe, it, expect } from 'vitest'; + +import { analyzeSession } from '@renderer/utils/sessionAnalyzer'; +import type { ParsedMessage, Session, SessionDetail, SessionMetrics, Process } from '@shared/types'; + +// ============================================================================= +// Test Helpers +// ============================================================================= + +let msgCounter = 0; + +function createMockMessage(overrides: Partial = {}): ParsedMessage { + msgCounter++; + return { + uuid: `uuid-${msgCounter}`, + parentUuid: `uuid-${msgCounter - 1}`, + type: 'assistant', + timestamp: new Date('2024-01-01T10:00:00Z'), + content: '', + isSidechain: false, + isMeta: false, + toolCalls: [], + toolResults: [], + ...overrides, + }; +} + +function createMockSession(overrides: Partial = {}): Session { + return { + id: 'test-session', + projectId: 'test-project', + projectPath: '/test/path', + createdAt: Date.now(), + hasSubagents: false, + messageCount: 0, + ...overrides, + }; +} + +function createMockMetrics(overrides: Partial = {}): SessionMetrics { + return { + durationMs: 0, + totalTokens: 0, + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheCreationTokens: 0, + messageCount: 0, + ...overrides, + }; +} + +function createMockDetail(overrides: Partial = {}): SessionDetail { + return { + session: createMockSession(), + messages: [], + chunks: [], + processes: [], + metrics: createMockMetrics(), + ...overrides, + }; +} + +// ============================================================================= +// Tests +// ============================================================================= + +describe('analyzeSession', () => { + beforeEach(() => { + msgCounter = 0; + }); + + // ------------------------------------------------------------------------- + // 1. Empty session + // ------------------------------------------------------------------------- + describe('empty session', () => { + it('returns a zeroed report with correct structure', () => { + const report = analyzeSession(createMockDetail()); + + expect(report.overview.sessionId).toBe('test-session'); + expect(report.overview.totalMessages).toBe(0); + expect(report.overview.durationSeconds).toBe(0); + + expect(report.tokenUsage.totals.grandTotal).toBe(0); + expect(report.tokenUsage.totals.inputTokens).toBe(0); + expect(report.tokenUsage.totals.outputTokens).toBe(0); + + expect(report.costAnalysis.totalSessionCostUsd).toBe(0); + + expect(report.toolUsage.totalCalls).toBe(0); + expect(report.toolUsage.counts).toEqual({}); + + expect(report.errors.errors).toHaveLength(0); + expect(report.errors.permissionDenials.count).toBe(0); + + expect(report.frictionSignals.correctionCount).toBe(0); + expect(report.frictionSignals.corrections).toHaveLength(0); + + expect(report.gitActivity.commitCount).toBe(0); + expect(report.gitActivity.pushCount).toBe(0); + + expect(report.idleAnalysis.idleGapCount).toBe(0); + + expect(report.modelSwitches.count).toBe(0); + expect(report.modelSwitches.switches).toHaveLength(0); + + expect(report.conversationTree.maxDepth).toBe(0); + expect(report.conversationTree.totalNodes).toBe(0); + + expect(report.tokenDensityTimeline.quartiles).toHaveLength(4); + expect(report.tokenDensityTimeline.quartiles.every((q) => q.avgTokens === 0)).toBe(true); + + expect(report.compactionCount).toBe(0); + expect(report.gitBranches).toEqual([]); + }); + }); + + // ------------------------------------------------------------------------- + // 2. Basic session with usage data + // ------------------------------------------------------------------------- + describe('basic session', () => { + it('computes overview, token totals, and cost', () => { + const messages: ParsedMessage[] = [ + createMockMessage({ + type: 'user', + isMeta: false, + content: 'Hello world', + timestamp: new Date('2024-01-01T10:00:00Z'), + }), + createMockMessage({ + type: 'assistant', + model: 'claude-sonnet-4-20250514', + timestamp: new Date('2024-01-01T10:01:00Z'), + content: [{ type: 'text' as const, text: 'Hi there!' }], + usage: { + input_tokens: 1000, + output_tokens: 500, + cache_read_input_tokens: 200, + cache_creation_input_tokens: 100, + }, + }), + createMockMessage({ + type: 'user', + isMeta: false, + content: 'Follow up', + timestamp: new Date('2024-01-01T10:02:00Z'), + }), + createMockMessage({ + type: 'assistant', + model: 'claude-sonnet-4-20250514', + timestamp: new Date('2024-01-01T10:03:00Z'), + content: [{ type: 'text' as const, text: 'Sure thing.' }], + usage: { + input_tokens: 1500, + output_tokens: 300, + cache_read_input_tokens: 400, + cache_creation_input_tokens: 0, + }, + }), + ]; + + const report = analyzeSession( + createMockDetail({ + messages, + session: createMockSession({ messageCount: 4 }), + }) + ); + + // Overview + expect(report.overview.totalMessages).toBe(4); + expect(report.overview.durationSeconds).toBe(180); // 3 minutes + expect(report.overview.durationHuman).toBe('3:00'); + + // Token totals + expect(report.tokenUsage.totals.inputTokens).toBe(2500); + expect(report.tokenUsage.totals.outputTokens).toBe(800); + expect(report.tokenUsage.totals.cacheRead).toBe(600); + expect(report.tokenUsage.totals.cacheCreation).toBe(100); + expect(report.tokenUsage.totals.grandTotal).toBe(4000); + + // Cost should be positive (sonnet-4 pricing) + expect(report.costAnalysis.parentCostUsd).toBeGreaterThan(0); + expect(report.costAnalysis.totalSessionCostUsd).toBeGreaterThan(0); + + // Message types + expect(report.messageTypes.user).toBe(2); + expect(report.messageTypes.assistant).toBe(2); + }); + }); + + // ------------------------------------------------------------------------- + // 3. Tool usage + // ------------------------------------------------------------------------- + describe('tool usage', () => { + it('counts tool calls and computes totalCalls', () => { + const messages: ParsedMessage[] = [ + createMockMessage({ + type: 'assistant', + model: 'claude-sonnet-4-20250514', + toolCalls: [ + { id: 'tc-1', name: 'Read', input: { file_path: '/foo.ts' }, isTask: false }, + { id: 'tc-2', name: 'Bash', input: { command: 'ls' }, isTask: false }, + ], + }), + createMockMessage({ + type: 'user', + isMeta: true, + content: [ + { + type: 'tool_result' as const, + tool_use_id: 'tc-1', + content: 'file contents', + is_error: false, + }, + { + type: 'tool_result' as const, + tool_use_id: 'tc-2', + content: 'output', + is_error: false, + }, + ], + toolResults: [ + { toolUseId: 'tc-1', content: 'file contents', isError: false }, + { toolUseId: 'tc-2', content: 'output', isError: false }, + ], + }), + createMockMessage({ + type: 'assistant', + model: 'claude-sonnet-4-20250514', + toolCalls: [{ id: 'tc-3', name: 'Read', input: { file_path: '/bar.ts' }, isTask: false }], + }), + ]; + + const report = analyzeSession(createMockDetail({ messages })); + + expect(report.toolUsage.totalCalls).toBe(3); + expect(report.toolUsage.counts.Read).toBe(2); + expect(report.toolUsage.counts.Bash).toBe(1); + }); + }); + + // ------------------------------------------------------------------------- + // 4. Error detection + // ------------------------------------------------------------------------- + describe('error detection', () => { + it('collects tool errors from isError results', () => { + const messages: ParsedMessage[] = [ + createMockMessage({ + type: 'assistant', + toolCalls: [ + { id: 'tc-1', name: 'Read', input: { file_path: '/missing.ts' }, isTask: false }, + ], + }), + createMockMessage({ + type: 'user', + isMeta: true, + content: [], + toolResults: [ + { toolUseId: 'tc-1', content: 'ENOENT: no such file or directory', isError: true }, + ], + }), + ]; + + const report = analyzeSession(createMockDetail({ messages })); + + expect(report.errors.errors).toHaveLength(1); + expect(report.errors.errors[0].tool).toBe('Read'); + expect(report.errors.errors[0].error).toContain('ENOENT'); + expect(report.errors.errors[0].isPermissionDenial).toBe(false); + }); + + it('detects Bash non-zero exit codes as errors', () => { + const messages: ParsedMessage[] = [ + createMockMessage({ + type: 'assistant', + toolCalls: [{ id: 'tc-1', name: 'Bash', input: { command: 'false' }, isTask: false }], + }), + createMockMessage({ + type: 'user', + isMeta: true, + content: [], + toolResults: [ + { toolUseId: 'tc-1', content: 'Exit code 1\nCommand failed', isError: false }, + ], + }), + ]; + + const report = analyzeSession(createMockDetail({ messages })); + + expect(report.errors.errors).toHaveLength(1); + expect(report.errors.errors[0].tool).toBe('Bash (non-zero exit)'); + }); + }); + + // ------------------------------------------------------------------------- + // 5. Permission denial + // ------------------------------------------------------------------------- + describe('permission denial', () => { + it('flags errors containing permission keywords', () => { + const messages: ParsedMessage[] = [ + createMockMessage({ + type: 'assistant', + toolCalls: [ + { id: 'tc-1', name: 'Bash', input: { command: 'rm /root/file' }, isTask: false }, + ], + }), + createMockMessage({ + type: 'user', + isMeta: true, + content: [], + toolResults: [{ toolUseId: 'tc-1', content: 'Error: permission denied', isError: true }], + }), + ]; + + const report = analyzeSession(createMockDetail({ messages })); + + expect(report.errors.permissionDenials.count).toBe(1); + expect(report.errors.permissionDenials.denials[0].isPermissionDenial).toBe(true); + expect(report.errors.permissionDenials.affectedTools).toContain('Bash'); + }); + + it('detects permission denial in Bash non-zero exit', () => { + const messages: ParsedMessage[] = [ + createMockMessage({ + type: 'assistant', + toolCalls: [ + { id: 'tc-1', name: 'Bash', input: { command: 'cat /etc/shadow' }, isTask: false }, + ], + }), + createMockMessage({ + type: 'user', + isMeta: true, + content: [], + toolResults: [ + { + toolUseId: 'tc-1', + content: 'Exit code 1\ncat: /etc/shadow: Operation not permitted', + isError: false, + }, + ], + }), + ]; + + const report = analyzeSession(createMockDetail({ messages })); + + expect(report.errors.permissionDenials.count).toBe(1); + }); + }); + + // ------------------------------------------------------------------------- + // 6. Friction detection + // ------------------------------------------------------------------------- + describe('friction detection', () => { + it('detects friction keywords in user messages', () => { + const messages: ParsedMessage[] = [ + createMockMessage({ + type: 'user', + isMeta: false, + content: 'Build the login page', + timestamp: new Date('2024-01-01T10:00:00Z'), + }), + createMockMessage({ + type: 'assistant', + content: [{ type: 'text' as const, text: 'Done.' }], + timestamp: new Date('2024-01-01T10:01:00Z'), + }), + createMockMessage({ + type: 'user', + isMeta: false, + content: 'No, that is wrong. Use React.', + timestamp: new Date('2024-01-01T10:02:00Z'), + }), + createMockMessage({ + type: 'assistant', + content: [{ type: 'text' as const, text: 'Updated.' }], + timestamp: new Date('2024-01-01T10:03:00Z'), + }), + createMockMessage({ + type: 'user', + isMeta: false, + content: 'Actually, use Next.js instead', + timestamp: new Date('2024-01-01T10:04:00Z'), + }), + ]; + + const report = analyzeSession(createMockDetail({ messages })); + + expect(report.frictionSignals.correctionCount).toBe(2); + expect(report.frictionSignals.corrections).toHaveLength(2); + + const keywords = report.frictionSignals.corrections.map((c) => c.keyword); + // "No," matches 'no,' and "actually" matches 'actually' + expect(keywords).toContain('no,'); + expect(keywords).toContain('actually'); + + // Friction rate = 2 corrections / 3 user messages + expect(report.frictionSignals.frictionRate).toBeCloseTo(2 / 3, 2); + }); + + it('does not count isMeta user messages as friction', () => { + const messages: ParsedMessage[] = [ + createMockMessage({ + type: 'user', + isMeta: true, + content: 'No, wrong, actually this is meta', + }), + ]; + + const report = analyzeSession(createMockDetail({ messages })); + expect(report.frictionSignals.correctionCount).toBe(0); + }); + }); + + // ------------------------------------------------------------------------- + // 7. Git activity + // ------------------------------------------------------------------------- + describe('git activity', () => { + it('detects git commits from Bash tool calls', () => { + const messages: ParsedMessage[] = [ + createMockMessage({ + type: 'assistant', + toolCalls: [ + { + id: 'tc-1', + name: 'Bash', + input: { command: "git commit -m 'initial commit'" }, + isTask: false, + }, + ], + }), + createMockMessage({ + type: 'assistant', + toolCalls: [ + { + id: 'tc-2', + name: 'Bash', + input: { command: "git commit -m 'add feature'" }, + isTask: false, + }, + ], + }), + ]; + + const report = analyzeSession(createMockDetail({ messages })); + + expect(report.gitActivity.commitCount).toBe(2); + expect(report.gitActivity.commits).toHaveLength(2); + expect(report.gitActivity.commits[0].messagePreview).toContain('initial commit'); + expect(report.gitActivity.commits[1].messagePreview).toContain('add feature'); + }); + + it('detects git push and branch creation', () => { + const messages: ParsedMessage[] = [ + createMockMessage({ + type: 'assistant', + toolCalls: [ + { + id: 'tc-1', + name: 'Bash', + input: { command: 'git checkout -b feat/new-branch' }, + isTask: false, + }, + ], + }), + createMockMessage({ + type: 'assistant', + toolCalls: [ + { + id: 'tc-2', + name: 'Bash', + input: { command: 'git push -u origin feat/new-branch' }, + isTask: false, + }, + ], + }), + ]; + + const report = analyzeSession(createMockDetail({ messages })); + + expect(report.gitActivity.pushCount).toBe(1); + expect(report.gitActivity.branchCreations).toContain('feat/new-branch'); + }); + }); + + // ------------------------------------------------------------------------- + // 8. Idle gaps + // ------------------------------------------------------------------------- + describe('idle gaps', () => { + it('detects idle gaps >60s between assistant and next user message', () => { + const messages: ParsedMessage[] = [ + createMockMessage({ + type: 'assistant', + timestamp: new Date('2024-01-01T10:00:00Z'), + }), + // 2 minutes later + createMockMessage({ + type: 'user', + isMeta: false, + content: 'back now', + timestamp: new Date('2024-01-01T10:02:00Z'), + }), + createMockMessage({ + type: 'assistant', + timestamp: new Date('2024-01-01T10:02:30Z'), + }), + // 30 seconds - no idle gap + createMockMessage({ + type: 'user', + isMeta: false, + content: 'quick reply', + timestamp: new Date('2024-01-01T10:03:00Z'), + }), + createMockMessage({ + type: 'assistant', + timestamp: new Date('2024-01-01T10:03:30Z'), + }), + // 5 minutes later + createMockMessage({ + type: 'user', + isMeta: false, + content: 'took a break', + timestamp: new Date('2024-01-01T10:08:30Z'), + }), + ]; + + const report = analyzeSession(createMockDetail({ messages })); + + expect(report.idleAnalysis.idleGapCount).toBe(2); + expect(report.idleAnalysis.totalIdleSeconds).toBeGreaterThan(0); + expect(report.idleAnalysis.idlePct).toBeGreaterThan(0); + + // First gap: 120s, second gap: 300s + const gapSeconds = report.idleAnalysis.longestGaps.map((g) => g.gapSeconds); + expect(gapSeconds).toContain(120); + expect(gapSeconds).toContain(300); + }); + + it('reports zero idle for no gaps', () => { + const messages: ParsedMessage[] = [ + createMockMessage({ + type: 'assistant', + timestamp: new Date('2024-01-01T10:00:00Z'), + }), + createMockMessage({ + type: 'user', + content: 'quick', + timestamp: new Date('2024-01-01T10:00:30Z'), + }), + ]; + + const report = analyzeSession(createMockDetail({ messages })); + expect(report.idleAnalysis.idleGapCount).toBe(0); + expect(report.idleAnalysis.totalIdleSeconds).toBe(0); + }); + }); + + // ------------------------------------------------------------------------- + // 9. Model switches + // ------------------------------------------------------------------------- + describe('model switches', () => { + it('detects switches between different model names', () => { + const messages: ParsedMessage[] = [ + createMockMessage({ + type: 'assistant', + model: 'claude-sonnet-4-20250514', + timestamp: new Date('2024-01-01T10:00:00Z'), + }), + createMockMessage({ + type: 'assistant', + model: 'claude-sonnet-4-20250514', + timestamp: new Date('2024-01-01T10:01:00Z'), + }), + createMockMessage({ + type: 'assistant', + model: 'claude-opus-4-20250514', + timestamp: new Date('2024-01-01T10:02:00Z'), + }), + createMockMessage({ + type: 'assistant', + model: 'claude-haiku-4-20250514', + timestamp: new Date('2024-01-01T10:03:00Z'), + }), + ]; + + const report = analyzeSession(createMockDetail({ messages })); + + expect(report.modelSwitches.count).toBe(2); + expect(report.modelSwitches.switches[0].from).toBe('claude-sonnet-4-20250514'); + expect(report.modelSwitches.switches[0].to).toBe('claude-opus-4-20250514'); + expect(report.modelSwitches.switches[1].from).toBe('claude-opus-4-20250514'); + expect(report.modelSwitches.switches[1].to).toBe('claude-haiku-4-20250514'); + + expect(report.modelSwitches.modelsUsed).toContain('claude-sonnet-4-20250514'); + expect(report.modelSwitches.modelsUsed).toContain('claude-opus-4-20250514'); + expect(report.modelSwitches.modelsUsed).toContain('claude-haiku-4-20250514'); + }); + + it('reports zero switches for single model', () => { + const messages: ParsedMessage[] = [ + createMockMessage({ + type: 'assistant', + model: 'claude-sonnet-4-20250514', + usage: { input_tokens: 100, output_tokens: 50 }, + }), + createMockMessage({ + type: 'assistant', + model: 'claude-sonnet-4-20250514', + usage: { input_tokens: 100, output_tokens: 50 }, + }), + ]; + + const report = analyzeSession(createMockDetail({ messages })); + expect(report.modelSwitches.count).toBe(0); + // modelsUsed falls back to modelStats keys when no switches + expect(report.modelSwitches.modelsUsed).toHaveLength(1); + expect(report.modelSwitches.modelsUsed[0]).toBe('claude-sonnet-4-20250514'); + }); + }); + + // ------------------------------------------------------------------------- + // 10. Conversation tree + // ------------------------------------------------------------------------- + describe('conversation tree', () => { + it('computes maxDepth from uuid/parentUuid chains', () => { + const messages: ParsedMessage[] = [ + createMockMessage({ uuid: 'root', parentUuid: null }), + createMockMessage({ uuid: 'child-1', parentUuid: 'root' }), + createMockMessage({ uuid: 'child-2', parentUuid: 'child-1' }), + createMockMessage({ uuid: 'child-3', parentUuid: 'child-2' }), + ]; + + const report = analyzeSession(createMockDetail({ messages })); + + expect(report.conversationTree.totalNodes).toBe(4); + expect(report.conversationTree.maxDepth).toBe(3); // root(0)->child1(1)->child2(2)->child3(3) + }); + + it('detects branch points (multiple children)', () => { + const messages: ParsedMessage[] = [ + createMockMessage({ uuid: 'root', parentUuid: null }), + createMockMessage({ uuid: 'branch-a', parentUuid: 'root' }), + createMockMessage({ uuid: 'branch-b', parentUuid: 'root' }), + createMockMessage({ uuid: 'branch-c', parentUuid: 'root' }), + ]; + + const report = analyzeSession(createMockDetail({ messages })); + + expect(report.conversationTree.branchPoints).toBe(1); + expect(report.conversationTree.branchDetails).toHaveLength(1); + expect(report.conversationTree.branchDetails[0].childCount).toBe(3); + }); + + it('counts sidechains', () => { + const messages: ParsedMessage[] = [ + createMockMessage({ uuid: 'root', parentUuid: null, isSidechain: false }), + createMockMessage({ uuid: 'side-1', parentUuid: 'root', isSidechain: true }), + createMockMessage({ uuid: 'side-2', parentUuid: 'root', isSidechain: true }), + ]; + + const report = analyzeSession(createMockDetail({ messages })); + expect(report.conversationTree.sidechainCount).toBe(2); + }); + }); + + // ------------------------------------------------------------------------- + // Additional coverage + // ------------------------------------------------------------------------- + describe('context consumption assessment', () => { + it('assesses healthy context consumption', () => { + const report = analyzeSession( + createMockDetail({ + session: createMockSession({ contextConsumption: 0.3 }), + }) + ); + + expect(report.overview.contextAssessment).toBe('healthy'); + expect(report.overview.contextConsumptionPct).toBe(30); + }); + + it('assesses critical context consumption', () => { + const report = analyzeSession( + createMockDetail({ + session: createMockSession({ contextConsumption: 0.85 }), + }) + ); + + expect(report.overview.contextAssessment).toBe('critical'); + }); + }); + + describe('cache economics', () => { + it('detects cold start when first assistant has cache creation but no reads', () => { + const messages: ParsedMessage[] = [ + createMockMessage({ + type: 'assistant', + model: 'claude-sonnet-4-20250514', + usage: { + input_tokens: 100, + output_tokens: 50, + cache_creation_input_tokens: 500, + cache_read_input_tokens: 0, + }, + }), + ]; + + const report = analyzeSession(createMockDetail({ messages })); + expect(report.cacheEconomics.coldStartDetected).toBe(true); + }); + + it('computes cache efficiency percentage', () => { + const messages: ParsedMessage[] = [ + createMockMessage({ + type: 'assistant', + model: 'claude-sonnet-4-20250514', + usage: { + input_tokens: 100, + output_tokens: 50, + cache_creation_input_tokens: 200, + cache_read_input_tokens: 800, + }, + }), + ]; + + const report = analyzeSession(createMockDetail({ messages })); + // efficiency = 800 / (200 + 800) * 100 = 80% + expect(report.cacheEconomics.cacheEfficiencyPct).toBe(80); + expect(report.cacheEconomics.cacheReadToWriteRatio).toBe(4); + }); + }); + + describe('file read redundancy', () => { + it('tracks redundant file reads', () => { + const messages: ParsedMessage[] = [ + createMockMessage({ + type: 'assistant', + toolCalls: [ + { id: 'tc-1', name: 'Read', input: { file_path: '/foo.ts' }, isTask: false }, + { id: 'tc-2', name: 'Read', input: { file_path: '/foo.ts' }, isTask: false }, + { id: 'tc-3', name: 'Read', input: { file_path: '/foo.ts' }, isTask: false }, + { id: 'tc-4', name: 'Read', input: { file_path: '/bar.ts' }, isTask: false }, + ], + }), + ]; + + const report = analyzeSession(createMockDetail({ messages })); + + expect(report.fileReadRedundancy.totalReads).toBe(4); + expect(report.fileReadRedundancy.uniqueFiles).toBe(2); + expect(report.fileReadRedundancy.redundantFiles['/foo.ts']).toBe(3); + expect(report.fileReadRedundancy.redundantFiles['/bar.ts']).toBeUndefined(); // only 1 read, threshold is >2 + }); + }); + + describe('prompt quality', () => { + it('assesses well_specified when few corrections', () => { + const messages: ParsedMessage[] = [ + createMockMessage({ + type: 'user', + isMeta: false, + content: 'Build me a React login component with form validation and error states', + }), + createMockMessage({ + type: 'assistant', + content: [{ type: 'text' as const, text: 'Done.' }], + }), + ]; + + const report = analyzeSession(createMockDetail({ messages })); + expect(report.promptQuality.assessment).toBe('well_specified'); + }); + + it('assesses underspecified when short prompt and many corrections', () => { + const messages: ParsedMessage[] = [ + createMockMessage({ + type: 'user', + isMeta: false, + content: 'Fix the bug', + timestamp: new Date('2024-01-01T10:00:00Z'), + }), + createMockMessage({ + type: 'assistant', + content: [{ type: 'text' as const, text: 'Done.' }], + timestamp: new Date('2024-01-01T10:01:00Z'), + }), + createMockMessage({ + type: 'user', + isMeta: false, + content: 'No, wrong file', + timestamp: new Date('2024-01-01T10:02:00Z'), + }), + createMockMessage({ + type: 'assistant', + content: [{ type: 'text' as const, text: 'Updated.' }], + timestamp: new Date('2024-01-01T10:03:00Z'), + }), + createMockMessage({ + type: 'user', + isMeta: false, + content: 'Actually the other module', + timestamp: new Date('2024-01-01T10:04:00Z'), + }), + ]; + + const report = analyzeSession(createMockDetail({ messages })); + expect(report.promptQuality.assessment).toBe('underspecified'); + expect(report.promptQuality.firstMessageLengthChars).toBe('Fix the bug'.length); + }); + }); + + describe('subagent metrics from processes', () => { + it('computes subagent summary from detail.processes', () => { + const processes: Process[] = [ + { + id: 'agent-1', + filePath: '/path/to/agent-1.jsonl', + messages: [ + createMockMessage({ + toolCalls: [ + { id: 'tc-1', name: 'Read', input: {}, isTask: false }, + { id: 'tc-2', name: 'Edit', input: {}, isTask: false }, + ], + }), + ], + startTime: new Date('2024-01-01T10:00:00Z'), + endTime: new Date('2024-01-01T10:01:00Z'), + durationMs: 60000, + metrics: createMockMetrics({ totalTokens: 5000, costUsd: 0.05 }), + description: 'Refactor module', + subagentType: 'code', + isParallel: false, + }, + ]; + + const report = analyzeSession(createMockDetail({ processes })); + + expect(report.subagentMetrics.count).toBe(1); + expect(report.subagentMetrics.totalTokens).toBe(5000); + expect(report.subagentMetrics.totalToolUseCount).toBe(2); + expect(report.subagentMetrics.byAgent[0].description).toBe('Refactor module'); + }); + }); + + describe('thinking blocks', () => { + it('counts thinking blocks and analyzes signals', () => { + const messages: ParsedMessage[] = [ + createMockMessage({ + type: 'assistant', + content: [ + { + type: 'thinking' as const, + thinking: + 'Let me think about an alternative approach. Actually, I should reconsider.', + signature: 'sig-1', + }, + { type: 'text' as const, text: 'Here is my response.' }, + ], + }), + ]; + + const report = analyzeSession(createMockDetail({ messages })); + + expect(report.thinkingBlocks.count).toBe(1); + expect(report.thinkingBlocks.analyzedCount).toBe(1); + expect(report.thinkingBlocks.signalSummary.alternatives).toBe(1); + expect(report.thinkingBlocks.signalSummary.direction_change).toBe(1); + }); + }); + + describe('working directories', () => { + it('tracks working directory changes', () => { + const messages: ParsedMessage[] = [ + createMockMessage({ cwd: '/project/src' }), + createMockMessage({ cwd: '/project/src' }), + createMockMessage({ cwd: '/project/test' }), + createMockMessage({ cwd: '/project/src' }), + ]; + + const report = analyzeSession(createMockDetail({ messages })); + + expect(report.workingDirectories.directoryCount).toBe(2); + expect(report.workingDirectories.isMultiDirectory).toBe(true); + expect(report.workingDirectories.changeCount).toBe(2); // src->test, test->src + }); + }); + + describe('git branches', () => { + it('collects unique git branches', () => { + const messages: ParsedMessage[] = [ + createMockMessage({ gitBranch: 'main' }), + createMockMessage({ gitBranch: 'main' }), + createMockMessage({ gitBranch: 'feat/new' }), + ]; + + const report = analyzeSession(createMockDetail({ messages })); + expect(report.gitBranches).toContain('main'); + expect(report.gitBranches).toContain('feat/new'); + expect(report.gitBranches).toHaveLength(2); + }); + }); + + describe('test progression', () => { + it('detects improving test trajectory', () => { + const messages: ParsedMessage[] = [ + createMockMessage({ + type: 'assistant', + toolCalls: [{ id: 'tc-1', name: 'Bash', input: { command: 'pnpm test' }, isTask: false }], + }), + createMockMessage({ + type: 'user', + isMeta: true, + content: [], + toolResults: [{ toolUseId: 'tc-1', content: '3 passed 2 failed', isError: false }], + }), + createMockMessage({ + type: 'assistant', + toolCalls: [{ id: 'tc-2', name: 'Bash', input: { command: 'pnpm test' }, isTask: false }], + }), + createMockMessage({ + type: 'user', + isMeta: true, + content: [], + toolResults: [{ toolUseId: 'tc-2', content: '5 passed 0 failed', isError: false }], + }), + ]; + + const report = analyzeSession(createMockDetail({ messages })); + + expect(report.testProgression.snapshotCount).toBe(2); + expect(report.testProgression.trajectory).toBe('improving'); + expect(report.testProgression.firstSnapshot?.passed).toBe(3); + expect(report.testProgression.lastSnapshot?.passed).toBe(5); + }); + }); + + describe('startup overhead', () => { + it('counts messages and tokens before first work tool', () => { + const messages: ParsedMessage[] = [ + // Startup: assistant response with no work tools + createMockMessage({ + type: 'assistant', + model: 'claude-sonnet-4-20250514', + usage: { input_tokens: 500, output_tokens: 200 }, + toolCalls: [], + }), + // First work tool + createMockMessage({ + type: 'assistant', + model: 'claude-sonnet-4-20250514', + usage: { input_tokens: 1000, output_tokens: 300 }, + toolCalls: [{ id: 'tc-1', name: 'Read', input: { file_path: '/foo.ts' }, isTask: false }], + }), + ]; + + const report = analyzeSession(createMockDetail({ messages })); + + expect(report.startupOverhead.messagesBeforeFirstWork).toBe(1); + expect(report.startupOverhead.tokensBeforeFirstWork).toBe(700); // 500 + 200 + }); + }); + + describe('thrashing signals', () => { + it('detects bash near-duplicates', () => { + const makeMsg = (cmd: string, id: string) => + createMockMessage({ + type: 'assistant', + toolCalls: [{ id, name: 'Bash', input: { command: cmd }, isTask: false }], + }); + + const messages: ParsedMessage[] = [ + makeMsg('pnpm test src/foo.test.ts', 'tc-1'), + makeMsg('pnpm test src/foo.test.ts', 'tc-2'), + makeMsg('pnpm test src/foo.test.ts', 'tc-3'), + ]; + + const report = analyzeSession(createMockDetail({ messages })); + + expect(report.thrashingSignals.bashNearDuplicates.length).toBeGreaterThanOrEqual(1); + expect(report.thrashingSignals.bashNearDuplicates[0].count).toBe(3); + }); + + it('detects file edit rework', () => { + const messages: ParsedMessage[] = [ + createMockMessage({ + type: 'assistant', + toolCalls: [{ id: 'tc-1', name: 'Edit', input: { file_path: '/foo.ts' }, isTask: false }], + }), + createMockMessage({ + type: 'assistant', + toolCalls: [{ id: 'tc-2', name: 'Edit', input: { file_path: '/foo.ts' }, isTask: false }], + }), + createMockMessage({ + type: 'assistant', + toolCalls: [{ id: 'tc-3', name: 'Edit', input: { file_path: '/foo.ts' }, isTask: false }], + }), + ]; + + const report = analyzeSession(createMockDetail({ messages })); + + expect(report.thrashingSignals.editReworkFiles).toHaveLength(1); + expect(report.thrashingSignals.editReworkFiles[0].filePath).toBe('/foo.ts'); + expect(report.thrashingSignals.editReworkFiles[0].editIndices).toHaveLength(3); + }); + }); +});