1509 lines · 51.1 KB · TypeScript
RawDownload
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> = {}): 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> = {}): Session {  return {    id: 'test-session',    projectId: 'test-project',    projectPath: '/test/path',    createdAt: Date.now(),    hasSubagents: false,    messageCount: 0,    ...overrides,  };} function createMockMetrics(overrides: Partial<SessionMetrics> = {}): SessionMetrics {  return {    durationMs: 0,    totalTokens: 0,    inputTokens: 0,    outputTokens: 0,    cacheReadTokens: 0,    cacheCreationTokens: 0,    messageCount: 0,    ...overrides,  };} function createMockDetail(overrides: Partial<SessionDetail> = {}): 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.compaction.count).toBe(0);      expect(report.compaction.compactSummaryCount).toBe(0);      expect(report.gitBranches).toEqual([]);       // New sections      expect(report.skillsInvoked).toEqual([]);      expect(report.bashCommands.total).toBe(0);      expect(report.lifecycleTasks).toEqual([]);      expect(report.userQuestions).toEqual([]);      expect(report.outOfScopeFindings).toEqual([]);      expect(report.agentTree.agentCount).toBe(0);      expect(report.subagentsList).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[] = [

Lines 1–500 of 1509.