diff --git a/docs/plans/2026-02-21-session-analysis-report-design.md b/docs/plans/2026-02-21-session-analysis-report-design.md new file mode 100644 index 00000000..62aa64d8 --- /dev/null +++ b/docs/plans/2026-02-21-session-analysis-report-design.md @@ -0,0 +1,107 @@ +# Session Analysis Report — Design Document + +**Date:** 2026-02-21 +**Status:** Approved + +## Overview + +Port the `scripts/analyze-session.py` analysis logic to TypeScript and display results as a beautifully formatted report in a new tab. An Activity icon button in the session toolbar triggers the analysis. + +## Decisions + +- **TypeScript port** — no Python dependency; runs in-process in the renderer +- **New tab** — opens a dedicated report tab (consistent with Settings/Notifications pattern) +- **Activity icon** — in the toolbar next to the Export dropdown +- **Full port** — all ~30 analysis sections from the Python script +- **Renderer-only** — no new IPC; `SessionDetail.messages` already has all raw data + +## Architecture + +``` +TabBar.tsx (Activity button click) + → store action: openSessionReport(sourceTabId) + → creates tab { type: 'report', projectId, sessionId, sourceTabId } + → SessionReportTab mounts + → analyzeSession(sessionDetail) from sessionAnalyzer.ts + → renders report sections +``` + +## New Files + +### Types +- `src/renderer/types/sessionReport.ts` — `SessionReport` interface with all section types + +### Analysis Engine +- `src/renderer/utils/sessionAnalyzer.ts` — `analyzeSession(detail: SessionDetail): SessionReport` + - Single-pass over `detail.messages` (mirrors the Python script's accumulator pattern) + - Post-pass aggregation for derived metrics + - Uses `detail.session` for metadata, `detail.processes` for subagent data + +### Report UI +- `src/renderer/components/report/SessionReportTab.tsx` — Main report tab +- `src/renderer/components/report/sections/` — Section components: + - `OverviewSection` — Session ID, project, duration, message count, context assessment + - `CostSection` — Cost by model, total, per-commit, per-line + - `TokenSection` — Token usage by model, cache economics, density timeline + - `ToolSection` — Tool counts, success rates + - `SubagentSection` — Subagent metrics, cost, token usage + - `ErrorSection` — Tool errors, permission denials + - `GitSection` — Commits, pushes, branches, lines changed + - `FrictionSection` — User corrections, thrashing signals + - `TimelineSection` — Idle gaps, model switches, key events + - `ConversationTreeSection` — Tree depth, branching, sidechains + - `QualitySection` — Prompt quality, startup overhead, test progression + +### Integration Points (modified files) +- `src/renderer/types/tabs.ts` — Add `'report'` tab type +- `src/renderer/store/slices/tabSlice.ts` — Add `openSessionReport` action +- `src/renderer/components/layout/TabBar.tsx` — Add Activity icon button +- `src/renderer/App.tsx` (or routing equivalent) — Route `report` tabs to `SessionReportTab` + +## Report Visual Design + +- Each section is a card with `bg-surface-raised` background and `border-border` border +- Section headers with lucide-react icons and bold titles +- Data in tables and stat grids using theme-aware CSS variables +- Color-coded assessments: green (healthy), amber (warning), red (critical) +- Collapsible detail sections for verbose data (thinking blocks, error details, idle gaps) +- Scrollable report body with sticky section navigation + +## Data Flow + +The `SessionDetail` already contains: +- `messages: ParsedMessage[]` — raw messages with toolCalls, toolResults, usage, model, timestamp, uuid/parentUuid, isMeta, cwd, gitBranch, agentId, isSidechain +- `session: Session` — metadata (contextConsumption, compactionCount, phaseBreakdown, etc.) +- `processes: Process[]` — subagent executions with nested messages and metrics +- `metrics: SessionMetrics` — pre-computed aggregates + +The analyzer works directly with these types — no JSON serialization or IPC needed. + +## Analysis Sections (ported from Python) + +| Section | Key Metrics | +|---------|------------| +| Overview | Duration, message count, context consumption, compaction count | +| Cost Analysis | Parent + subagent cost, cost by model, per-commit, per-line | +| Token Usage | By model (input/output/cache), totals, cache read % | +| Cache Economics | Creation 5m/1h, read/write ratio, cold start, efficiency % | +| Tool Usage | Counts, success rates per tool | +| Subagent Metrics | Count, tokens, duration, cost per agent | +| Errors | Tool errors, permission denials, affected tools | +| Git Activity | Commits, pushes, branch creations, lines changed | +| Friction Signals | Correction count, friction rate, keyword matches | +| Thrashing | Bash near-duplicates, file edit rework | +| Conversation Tree | Max depth, sidechain count, branch points | +| Idle Analysis | Gap count, total idle time, active working time | +| Model Switches | Switch count, models used | +| Working Directories | Unique dirs, change count | +| Test Progression | Snapshots, trajectory (improving/regressing/stable) | +| Startup Overhead | Messages/tokens before first work tool | +| Token Density Timeline | Quartile averages | +| Prompt Quality | First message length, friction rate, assessment | +| Thinking Blocks | Count, signal analysis (alternatives, uncertainty, planning) | +| Key Events | Timestamped skill invocations, task launches | +| Service Tiers | API tier usage distribution | +| File Read Redundancy | Reads per unique file, redundant files | +| Compact Summaries | Count of context compaction events | +| Out-of-scope Findings | Keywords detected in assistant responses | diff --git a/docs/plans/2026-02-21-session-analysis-report.md b/docs/plans/2026-02-21-session-analysis-report.md new file mode 100644 index 00000000..9bd87604 --- /dev/null +++ b/docs/plans/2026-02-21-session-analysis-report.md @@ -0,0 +1,875 @@ +# Session Analysis Report Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add a toolbar button that runs a full session analysis and displays results in a beautifully formatted report tab. + +**Architecture:** Pure renderer-side analysis engine (`sessionAnalyzer.ts`) processes `SessionDetail.messages` in a single pass. Report opens in a new tab type (`'report'`) rendered by `SessionReportTab`. No new IPC needed — all data is already available from `tabSessionData`. + +**Tech Stack:** React 18, TypeScript, Zustand, Tailwind CSS, lucide-react icons + +--- + +### Task 1: Add report types + +**Acceptance Criteria:** +- [ ] File exists at `src/renderer/types/sessionReport.ts` +- [ ] `SessionReport` interface is exported from `src/renderer/types/sessionReport.ts` +- [ ] `pnpm typecheck` passes with no new errors + +**Files:** +- Create: `src/renderer/types/sessionReport.ts` + +**Step 1: Create the SessionReport type file** + +This file defines all the report section types. The analyzer will return a `SessionReport` object. + +```typescript +/** + * Session analysis report types. + * Output of analyzeSession() — one interface per report section. + */ + +// ============================================================================= +// Pricing +// ============================================================================= + +export interface ModelPricing { + input: number; + output: number; + cache_read: number; + cache_creation: number; +} + +// ============================================================================= +// Report Sections +// ============================================================================= + +export interface ReportOverview { + sessionId: string; + projectId: string; + projectPath: string; + firstMessage: string; + messageCount: number; + hasSubagents: boolean; + contextConsumption: number; + contextConsumptionPct: number | null; + contextAssessment: 'critical' | 'high' | 'moderate' | 'healthy' | null; + compactionCount: number; + gitBranch: string; + startTime: Date | null; + endTime: Date | null; + durationSeconds: number; + durationHuman: string; + totalMessages: number; +} + +export interface ModelTokenStats { + apiCalls: number; + inputTokens: number; + outputTokens: number; + cacheCreation: number; + cacheRead: number; + costUsd: number; +} + +export interface TokenTotals { + inputTokens: number; + outputTokens: number; + cacheCreation: number; + cacheRead: number; + grandTotal: number; + cacheReadPct: number; +} + +export interface ReportTokenUsage { + byModel: Record; + totals: TokenTotals; +} + +export interface ReportCostAnalysis { + parentCostUsd: number; + subagentCostUsd: number; + totalSessionCostUsd: number; + costByModel: Record; + costPerCommit: number | null; + costPerLineChanged: number | null; +} + +export interface ReportCacheEconomics { + cacheCreation5m: number; + cacheCreation1h: number; + cacheRead: number; + cacheEfficiencyPct: number; + coldStartDetected: boolean; + cacheReadToWriteRatio: number; +} + +export interface ToolSuccessRate { + totalCalls: number; + errors: number; + successRatePct: number; +} + +export interface ReportToolUsage { + counts: Record; + totalCalls: number; + successRates: Record; +} + +export interface SubagentEntry { + description: string; + subagentType: string; + model: string; + totalTokens: number; + totalDurationMs: number; + totalToolUseCount: number; + costUsd: number; + costNote?: string; +} + +export interface ReportSubagentMetrics { + count: number; + totalTokens: number; + totalDurationMs: number; + totalToolUseCount: number; + totalCostUsd: number; + byAgent: SubagentEntry[]; +} + +export interface ToolError { + tool: string; + inputPreview: string; + error: string; + messageIndex: number; + isPermissionDenial: boolean; +} + +export interface ReportErrors { + errors: ToolError[]; + permissionDenials: { + count: number; + denials: ToolError[]; + affectedTools: string[]; + }; +} + +export interface GitCommit { + messagePreview: string; + messageIndex: number; +} + +export interface ReportGitActivity { + commitCount: number; + commits: GitCommit[]; + pushCount: number; + branchCreations: string[]; + linesAdded: number; + linesRemoved: number; + linesChanged: number; +} + +export interface FrictionCorrection { + messageIndex: number; + keyword: string; + preview: string; +} + +export interface ReportFrictionSignals { + correctionCount: number; + corrections: FrictionCorrection[]; + frictionRate: number; +} + +export interface ReportThrashingSignals { + bashNearDuplicates: { prefix: string; count: number }[]; + editReworkFiles: { filePath: string; editIndices: number[] }[]; +} + +export interface ReportConversationTree { + totalNodes: number; + maxDepth: number; + sidechainCount: number; + branchPoints: number; + branchDetails: { + parentUuid: string; + childCount: number; + parentMessageIndex: number | undefined; + }[]; +} + +export interface IdleGap { + gapSeconds: number; + gapHuman: string; + afterMessageIndex: number; +} + +export interface ReportIdleAnalysis { + idleThresholdSeconds: number; + idleGapCount: number; + totalIdleSeconds: number; + totalIdleHuman: string; + wallClockSeconds: number; + activeWorkingSeconds: number; + activeWorkingHuman: string; + idlePct: number; + longestGaps: IdleGap[]; +} + +export interface ModelSwitch { + from: string; + to: string; + messageIndex: number; + timestamp: Date | null; +} + +export interface ReportModelSwitches { + count: number; + switches: ModelSwitch[]; + modelsUsed: string[]; +} + +export interface ReportWorkingDirectories { + uniqueDirectories: string[]; + directoryCount: number; + changes: { from: string; to: string; messageIndex: number }[]; + changeCount: number; + isMultiDirectory: boolean; +} + +export interface TestSnapshot { + messageIndex: number; + passed: number; + failed: number; + total: number; + raw: string; +} + +export interface ReportTestProgression { + snapshotCount: number; + snapshots: TestSnapshot[]; + trajectory: 'improving' | 'regressing' | 'stable' | 'insufficient_data'; + firstSnapshot: TestSnapshot | null; + lastSnapshot: TestSnapshot | null; +} + +export interface ReportStartupOverhead { + messagesBeforeFirstWork: number; + tokensBeforeFirstWork: number; + pctOfTotal: number; +} + +export interface ReportTokenDensityTimeline { + quartiles: { q: number; avgTokens: number; messageCount: number }[]; +} + +export interface ReportPromptQuality { + firstMessageLengthChars: number; + userMessageCount: number; + correctionCount: number; + frictionRate: number; + assessment: 'underspecified' | 'verbose_but_unclear' | 'well_specified' | 'moderate_friction'; + note: string; +} + +export interface ThinkingBlockAnalysis { + messageIndex: number; + preview: string; + charLength: number; + signals: Record; +} + +export interface ReportThinkingBlocks { + count: number; + analyzedCount: number; + signalSummary: Record; + notableBlocks: ThinkingBlockAnalysis[]; +} + +export interface KeyEvent { + timestamp: Date; + label: string; + deltaSeconds?: number; + deltaHuman?: string; +} + +export interface ReportFileReadRedundancy { + totalReads: number; + uniqueFiles: number; + readsPerUniqueFile: number; + redundantFiles: Record; +} + +// ============================================================================= +// Combined Report +// ============================================================================= + +export interface SessionReport { + overview: ReportOverview; + tokenUsage: ReportTokenUsage; + costAnalysis: ReportCostAnalysis; + cacheEconomics: ReportCacheEconomics; + toolUsage: ReportToolUsage; + subagentMetrics: ReportSubagentMetrics; + errors: ReportErrors; + gitActivity: ReportGitActivity; + frictionSignals: ReportFrictionSignals; + thrashingSignals: ReportThrashingSignals; + conversationTree: ReportConversationTree; + idleAnalysis: ReportIdleAnalysis; + modelSwitches: ReportModelSwitches; + workingDirectories: ReportWorkingDirectories; + testProgression: ReportTestProgression; + startupOverhead: ReportStartupOverhead; + tokenDensityTimeline: ReportTokenDensityTimeline; + promptQuality: ReportPromptQuality; + thinkingBlocks: ReportThinkingBlocks; + keyEvents: KeyEvent[]; + messageTypes: Record; + serviceTiers: Record; + fileReadRedundancy: ReportFileReadRedundancy; + compactionCount: number; + gitBranches: string[]; +} +``` + +**Step 2: Verify types compile** + +Run: `pnpm typecheck` +Expected: No errors related to sessionReport.ts (file is only types, no imports yet) + +**Step 3: Commit** + +```bash +git add src/renderer/types/sessionReport.ts +git commit -m "feat(report): add session analysis report type definitions" +``` + +--- + +### Task 2: Build the session analyzer + +**Acceptance Criteria:** +- [ ] File exists at `src/renderer/utils/sessionAnalyzer.ts` +- [ ] `analyzeSession` function is exported from `src/renderer/utils/sessionAnalyzer.ts` +- [ ] `pnpm typecheck` passes with no new errors + +**Files:** +- Create: `src/renderer/utils/sessionAnalyzer.ts` + +**Docs to reference:** +- `scripts/analyze-session.py` — the Python script being ported (all logic) +- `src/main/types/messages.ts` — `ParsedMessage`, `ToolCall`, `ToolResult` +- `src/main/types/domain.ts` — `Session`, `SessionMetrics`, `TokenUsage` (= `UsageMetadata`) +- `src/main/types/chunks.ts` — `SessionDetail`, `Process` +- `src/main/types/jsonl.ts` — `UsageMetadata` (input_tokens, output_tokens, cache_read_input_tokens, cache_creation_input_tokens) + +**Step 1: Create the analyzer** + +Port all logic from `scripts/analyze-session.py` to TypeScript. The analyzer takes a `SessionDetail` (which has `session`, `messages`, `processes`, `metrics`) and returns a `SessionReport`. + +Key mapping from Python to TS: +- Python `data["messages"]` → `detail.messages: ParsedMessage[]` +- Python `data["session"]` → `detail.session: Session` +- Python `m.get("toolCalls", [])` → `msg.toolCalls: ToolCall[]` +- Python `m.get("toolResults", [])` → `msg.toolResults: ToolResult[]` +- Python `m.get("usage")` → `msg.usage?: TokenUsage` (fields: `input_tokens`, `output_tokens`, `cache_read_input_tokens`, `cache_creation_input_tokens`) +- Python `m.get("model")` → `msg.model?: string` +- Python `m.get("timestamp")` → `msg.timestamp: Date` (already parsed) +- Python `m.get("isMeta")` → `msg.isMeta: boolean` +- Python `m.get("uuid")` → `msg.uuid: string` +- Python `m.get("parentUuid")` → `msg.parentUuid: string | null` +- Python `m.get("cwd")` → `msg.cwd?: string` +- Python `m.get("gitBranch")` → `msg.gitBranch?: string` +- Python `m.get("isSidechain")` → `msg.isSidechain: boolean` +- Python `m.get("isCompactSummary")` → `msg.isCompactSummary?: boolean` +- Python `m.get("agentId")` → `msg.agentId?: string` +- For subagent data, use `detail.processes: Process[]` (already resolved with metrics, duration, description, subagentType) + +The function signature: + +```typescript +import type { SessionDetail } from '@renderer/types/data'; +import type { SessionReport } from '@renderer/types/sessionReport'; + +export function analyzeSession(detail: SessionDetail): SessionReport { ... } +``` + +Follow the Python script's single-pass pattern: +1. Initialize accumulators +2. Loop over `detail.messages` once, extracting all data +3. Post-pass aggregation +4. Return typed `SessionReport` + +For content text extraction, use this helper (mirrors Python's `extract_text_content`): + +```typescript +function extractTextContent(msg: ParsedMessage): string { + const { content } = msg; + if (typeof content === 'string') return content; + if (Array.isArray(content)) { + return content + .filter((block) => block.type === 'text') + .map((block) => block.text) + .join(' '); + } + return ''; +} +``` + +For pricing, port the `MODEL_PRICING` table and `costUsd()` function directly. + +For subagent metrics, use `detail.processes` instead of parsing `` tags — the data is already resolved: +```typescript +const subagentEntries: SubagentEntry[] = detail.processes.map((proc) => ({ + description: proc.description ?? 'unknown', + subagentType: proc.subagentType ?? 'unknown', + model: 'default (inherits parent)', + totalTokens: proc.metrics.totalTokens, + totalDurationMs: proc.durationMs, + totalToolUseCount: proc.messages.reduce((sum, m) => sum + m.toolCalls.length, 0), + costUsd: proc.metrics.costUsd ?? 0, +})); +``` + +Port ALL regex patterns from Python: +- `FRICTION_PATTERNS` — friction keyword detection +- `PERMISSION_PATTERNS` — permission denial detection +- `TEST_PASS_PATTERNS`, `TEST_FAIL_PATTERNS`, `TEST_SUMMARY_PATTERN` — test output parsing +- `THINKING_SIGNALS` — thinking block content analysis + +**Step 2: Verify it compiles** + +Run: `pnpm typecheck` +Expected: PASS + +**Step 3: Commit** + +```bash +git add src/renderer/utils/sessionAnalyzer.ts +git commit -m "feat(report): add session analyzer engine (TS port of analyze-session.py)" +``` + +--- + +### Task 3: Write analyzer tests + +**Acceptance Criteria:** +- [ ] File exists at `test/renderer/utils/sessionAnalyzer.test.ts` +- [ ] `pnpm test test/renderer/utils/sessionAnalyzer.test.ts` passes with all tests green + +**Files:** +- Create: `test/renderer/utils/sessionAnalyzer.test.ts` + +**Step 1: Write tests** + +Test the analyzer with mock `SessionDetail` objects. At minimum: + +1. **Empty session** — no messages, returns zeroed report +2. **Basic session** — a few user + assistant messages with usage data, verify overview, token counts, cost +3. **Tool usage** — messages with toolCalls and toolResults, verify tool counts and success rates +4. **Error detection** — toolResults with `isError: true`, verify error list and permission denial detection +5. **Friction detection** — user messages with "no,", "wrong", "actually" keywords +6. **Git activity** — Bash toolCalls containing "git commit", "git push" +7. **Idle gaps** — messages with timestamps >60s apart +8. **Model switches** — assistant messages with different model fields +9. **Conversation tree** — messages with uuid/parentUuid, verify depth and branching + +Create a `createMockMessage()` helper for building `ParsedMessage` objects easily: + +```typescript +function createMockMessage(overrides: Partial = {}): ParsedMessage { + return { + uuid: crypto.randomUUID(), + parentUuid: null, + type: 'assistant', + timestamp: new Date(), + content: '', + isSidechain: false, + isMeta: false, + toolCalls: [], + toolResults: [], + ...overrides, + }; +} +``` + +And a `createMockDetail()` helper: + +```typescript +function createMockDetail(overrides: Partial = {}): SessionDetail { + return { + session: { id: 'test', projectId: 'test', projectPath: '/test', createdAt: Date.now(), hasSubagents: false, messageCount: 0 } as Session, + messages: [], + chunks: [], + processes: [], + metrics: { durationMs: 0, totalTokens: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreationTokens: 0, messageCount: 0 }, + ...overrides, + }; +} +``` + +**Step 2: Run tests** + +Run: `pnpm test test/renderer/utils/sessionAnalyzer.test.ts` +Expected: All tests pass + +**Step 3: Commit** + +```bash +git add test/renderer/utils/sessionAnalyzer.test.ts +git commit -m "test(report): add session analyzer tests" +``` + +--- + +### Task 4: Add 'report' tab type and store action + +**Acceptance Criteria:** +- [ ] `src/renderer/types/tabs.ts` contains `'report'` in the Tab type union +- [ ] `src/renderer/components/layout/SortableTab.tsx` contains `report: Activity` in TAB_ICONS +- [ ] `openSessionReport` is declared in TabSlice interface in `src/renderer/store/slices/tabSlice.ts` +- [ ] `pnpm typecheck` passes with no new errors + +**Files:** +- Modify: `src/renderer/types/tabs.ts:79` — add `'report'` to Tab type union +- Modify: `src/renderer/components/layout/SortableTab.tsx:28-33` — add report icon to TAB_ICONS +- Modify: `src/renderer/store/slices/tabSlice.ts` — add `openSessionReport` action +- Modify: `src/renderer/store/types.ts` (if needed for new slice, but likely just extend tabSlice) + +**Step 1: Add 'report' to Tab type** + +In `src/renderer/types/tabs.ts`, line 79, change: +```typescript +type: 'session' | 'dashboard' | 'notifications' | 'settings'; +``` +to: +```typescript +type: 'session' | 'dashboard' | 'notifications' | 'settings' | 'report'; +``` + +**Step 2: Add report icon to SortableTab** + +In `src/renderer/components/layout/SortableTab.tsx`, add `Activity` to the lucide-react import and to `TAB_ICONS`: + +```typescript +import { Activity, Bell, FileText, LayoutDashboard, Pin, Search, Settings, X } from 'lucide-react'; + +const TAB_ICONS = { + dashboard: LayoutDashboard, + notifications: Bell, + settings: Settings, + session: FileText, + report: Activity, +} as const; +``` + +**Step 3: Add openSessionReport action to tabSlice** + +In `src/renderer/store/slices/tabSlice.ts`, add to the `TabSlice` interface: + +```typescript +openSessionReport: (sourceTabId: string) => void; +``` + +Implement it following the `openNotificationsTab` pattern. It needs to: +1. Get `tabSessionData[sourceTabId]` to find the sessionDetail +2. Extract the session's firstMessage for the tab label +3. Open a new tab with `type: 'report'`, the same `projectId` and `sessionId` as the source tab + +```typescript +openSessionReport: (sourceTabId: string) => { + const state = get(); + const sourceTab = getAllTabs(state.paneLayout).find((t) => t.id === sourceTabId); + if (!sourceTab || sourceTab.type !== 'session') return; + + const tabData = state.tabSessionData[sourceTabId]; + const sessionDetail = tabData?.sessionDetail; + const label = sessionDetail?.session.firstMessage + ? `Report: ${truncateLabel(sessionDetail.session.firstMessage, 30)}` + : 'Session Report'; + + state.openTab({ + type: 'report', + label, + projectId: sourceTab.projectId, + sessionId: sourceTab.sessionId, + }); +}, +``` + +**Step 4: Verify types compile** + +Run: `pnpm typecheck` +Expected: PASS (PaneContent.tsx will have a gap for the `report` type — we'll add it in Task 6) + +**Step 5: Commit** + +```bash +git add src/renderer/types/tabs.ts src/renderer/components/layout/SortableTab.tsx src/renderer/store/slices/tabSlice.ts +git commit -m "feat(report): add 'report' tab type and openSessionReport store action" +``` + +--- + +### Task 5: Build the report UI components + +**Acceptance Criteria:** +- [ ] File exists at `src/renderer/components/report/SessionReportTab.tsx` +- [ ] File exists at `src/renderer/components/report/ReportSection.tsx` +- [ ] Files exist at `src/renderer/components/report/sections/OverviewSection.tsx`, `CostSection.tsx`, `TokenSection.tsx`, `ToolSection.tsx`, `SubagentSection.tsx`, `ErrorSection.tsx`, `GitSection.tsx`, `FrictionSection.tsx`, `TimelineSection.tsx`, `QualitySection.tsx` +- [ ] `pnpm typecheck` passes with no new errors + +**Files:** +- Create: `src/renderer/components/report/SessionReportTab.tsx` +- Create: `src/renderer/components/report/ReportSection.tsx` — reusable section card wrapper +- Create: `src/renderer/components/report/sections/OverviewSection.tsx` +- Create: `src/renderer/components/report/sections/CostSection.tsx` +- Create: `src/renderer/components/report/sections/TokenSection.tsx` +- Create: `src/renderer/components/report/sections/ToolSection.tsx` +- Create: `src/renderer/components/report/sections/SubagentSection.tsx` +- Create: `src/renderer/components/report/sections/ErrorSection.tsx` +- Create: `src/renderer/components/report/sections/GitSection.tsx` +- Create: `src/renderer/components/report/sections/FrictionSection.tsx` +- Create: `src/renderer/components/report/sections/TimelineSection.tsx` +- Create: `src/renderer/components/report/sections/QualitySection.tsx` + +**Docs to reference:** +- `src/renderer/index.css` — CSS variables for theming +- `.claude/rules/tailwind.md` — Theme architecture (use `bg-surface-raised`, `text-text`, `border-border`, etc.) +- `src/renderer/components/common/TokenUsageDisplay.tsx` — Example of formatted token display +- `src/renderer/utils/formatters.ts` — Existing formatting utilities + +**Step 1: Create ReportSection wrapper** + +A reusable card component for each report section: + +```tsx +interface ReportSectionProps { + title: string; + icon: React.ComponentType<{ className?: string }>; + children: React.ReactNode; + defaultCollapsed?: boolean; +} +``` + +Uses `bg-surface-raised`, `border-border`, collapsible with ChevronDown/ChevronRight toggle. + +**Step 2: Create section components** + +Each section receives its typed data from the `SessionReport` and renders it. Design guidelines: + +- **Stat grids**: 2-4 columns of key metrics with label + value +- **Tables**: For lists of items (tools, errors, subagents) using `` with `text-xs` +- **Color coding**: Use inline styles with CSS variables — green for good, amber for warning, red for critical +- **Collapsible details**: For verbose lists (errors, thinking blocks), show count in header and expand for details + +Key section designs: + +**OverviewSection**: Grid of 6-8 stat cards (duration, messages, context %, compaction, branch, cost) + +**CostSection**: Cost by model table + stat cards for total, per-commit, per-line + +**TokenSection**: By-model table (input/output/cache-read/cache-create/cost per model) + totals row + cache economics stats + +**ToolSection**: Sorted table (tool name, calls, errors, success %) — highlight tools with <90% success rate + +**SubagentSection**: Table of subagents (description, type, tokens, duration, cost) + summary stats + +**ErrorSection**: Grouped by tool, expandable error details with input preview + +**GitSection**: Commits list + stat cards (pushes, branches, lines added/removed) + +**FrictionSection**: Friction rate badge + corrections list with message previews + thrashing signals + +**TimelineSection**: Idle gaps table + model switches list + key events timeline + +**QualitySection**: Prompt quality assessment badge + startup overhead stats + test progression + +**Step 3: Create SessionReportTab** + +Main component that: +1. Gets `sessionDetail` from `tabSessionData` using the tab's `sessionId` (find the source session tab's data) +2. Calls `analyzeSession(sessionDetail)` with `useMemo` +3. Renders a scrollable container with all section components +4. Shows loading/error states if session data isn't loaded + +```tsx +import { useMemo } from 'react'; +import { useStore } from '@renderer/store'; +import { analyzeSession } from '@renderer/utils/sessionAnalyzer'; +import type { Tab } from '@renderer/types/tabs'; + +interface SessionReportTabProps { + tab: Tab; +} + +export const SessionReportTab = ({ tab }: SessionReportTabProps) => { + // Find session data from any session tab with matching sessionId + const sessionDetail = useStore((s) => { + const allTabs = s.paneLayout.panes.flatMap((p) => p.tabs); + const sourceTab = allTabs.find( + (t) => t.type === 'session' && t.sessionId === tab.sessionId + ); + return sourceTab ? s.tabSessionData[sourceTab.id]?.sessionDetail : null; + }); + + const report = useMemo( + () => (sessionDetail ? analyzeSession(sessionDetail) : null), + [sessionDetail] + ); + + if (!report) { + return
No session data available. Open the session first.
; + } + + return ( +
+

+ Session Analysis Report +

+
+ + + + + + + + + + +
+
+ ); +}; +``` + +**Step 4: Verify it compiles** + +Run: `pnpm typecheck` +Expected: PASS + +**Step 5: Commit** + +```bash +git add src/renderer/components/report/ +git commit -m "feat(report): add session report tab and all section components" +``` + +--- + +### Task 6: Wire up routing and toolbar button + +**Acceptance Criteria:** +- [ ] `src/renderer/components/layout/PaneContent.tsx` imports and renders `SessionReportTab` for `tab.type === 'report'` +- [ ] `src/renderer/components/layout/TabBar.tsx` contains an Activity button with `onClick` calling `openSessionReport` +- [ ] `pnpm typecheck` passes with no new errors +- [ ] `pnpm test` passes with all existing tests green + +**Files:** +- Modify: `src/renderer/components/layout/PaneContent.tsx:42-49` — add report tab routing +- Modify: `src/renderer/components/layout/TabBar.tsx:17,56,102-107,384-387` — add analyze button + +**Step 1: Add report routing in PaneContent** + +In `src/renderer/components/layout/PaneContent.tsx`, import `SessionReportTab` and add the route: + +```tsx +import { SessionReportTab } from '../report/SessionReportTab'; +``` + +In the tab rendering map (around line 42), add before or after the session case: + +```tsx +{tab.type === 'report' && } +``` + +**Step 2: Add analyze button in TabBar** + +In `src/renderer/components/layout/TabBar.tsx`: + +1. Add `Activity` to the lucide-react import (line 17) +2. Add `openSessionReport` to the store destructure (line 56 area) +3. Add a hover state: `const [analyzeHover, setAnalyzeHover] = useState(false);` +4. Add the button next to ExportDropdown (after line 387): + +```tsx +{/* Analyze button - show only for session tabs with loaded data */} +{activeTab?.type === 'session' && activeTabSessionDetail && activeTabId && ( + +)} +``` + +**Step 3: Verify it compiles** + +Run: `pnpm typecheck` +Expected: PASS + +**Step 4: Run existing tests to verify nothing broke** + +Run: `pnpm test` +Expected: All existing tests still pass + +**Step 5: Commit** + +```bash +git add src/renderer/components/layout/PaneContent.tsx src/renderer/components/layout/TabBar.tsx +git commit -m "feat(report): wire up toolbar button and report tab routing" +``` + +--- + +### Task 7: Manual verification and polish + +**Acceptance Criteria:** +- [ ] [MANUAL] App launches with `pnpm dev` and report tab opens when Activity button is clicked +- [ ] [MANUAL] All report sections render with data from the active session +- [ ] `pnpm test` passes +- [ ] `pnpm typecheck` passes +- [ ] `pnpm lint:fix && pnpm format` passes with no remaining issues + +**Step 1: Run the app** + +Run: `pnpm dev` + +1. Open a session tab +2. Click the Activity (analyze) icon in the toolbar +3. Verify a new "Report: ..." tab opens +4. Verify all sections render with data +5. Check that section cards use correct theme colors +6. Verify collapsible sections work +7. Verify the tab icon shows the Activity icon in the tab bar + +**Step 2: Run full test suite** + +Run: `pnpm test` +Expected: All tests pass + +**Step 3: Run typecheck** + +Run: `pnpm typecheck` +Expected: No errors + +**Step 4: Run lint and format** + +Run: `pnpm lint:fix && pnpm format` + +**Step 5: Final commit if any polish changes** + +```bash +git add -A +git commit -m "feat(report): polish and fix lint issues" +``` diff --git a/src/renderer/components/chat/SessionContextPanel/components/FlatInjectionList.tsx b/src/renderer/components/chat/SessionContextPanel/components/FlatInjectionList.tsx index cb2d9e39..5b5f35d4 100644 --- a/src/renderer/components/chat/SessionContextPanel/components/FlatInjectionList.tsx +++ b/src/renderer/components/chat/SessionContextPanel/components/FlatInjectionList.tsx @@ -191,9 +191,7 @@ export const FlatInjectionList = ({ } }; - const displayText = row.description - ? `${row.label} \u2014 ${row.description}` - : row.label; + const displayText = row.description ? `${row.label} \u2014 ${row.description}` : row.label; return (
diff --git a/src/renderer/components/chat/items/linkedTool/renderHelpers.tsx b/src/renderer/components/chat/items/linkedTool/renderHelpers.tsx index 4b64eed4..6463f947 100644 --- a/src/renderer/components/chat/items/linkedTool/renderHelpers.tsx +++ b/src/renderer/components/chat/items/linkedTool/renderHelpers.tsx @@ -152,7 +152,7 @@ export function extractOutputText(content: string | unknown[]): string { .map((block) => typeof block === 'object' && block !== null && 'text' in block ? (block as { text: string }).text - : JSON.stringify(block, null, 2), + : JSON.stringify(block, null, 2) ) .join('\n'); } else { diff --git a/src/renderer/components/layout/PaneContent.tsx b/src/renderer/components/layout/PaneContent.tsx index 8abf16d5..667f7a46 100644 --- a/src/renderer/components/layout/PaneContent.tsx +++ b/src/renderer/components/layout/PaneContent.tsx @@ -7,6 +7,7 @@ import { TabUIProvider } from '@renderer/contexts/TabUIContext'; import { DashboardView } from '../dashboard/DashboardView'; import { NotificationsView } from '../notifications/NotificationsView'; +import { SessionReportTab } from '../report/SessionReportTab'; import { SettingsView } from '../settings/SettingsView'; import { SessionTabContent } from './SessionTabContent'; @@ -47,6 +48,7 @@ export const PaneContent = ({ pane }: PaneContentProps): React.JSX.Element => { )} + {tab.type === 'report' && }
); })} diff --git a/src/renderer/components/layout/SortableTab.tsx b/src/renderer/components/layout/SortableTab.tsx index 472c617c..55beebe3 100644 --- a/src/renderer/components/layout/SortableTab.tsx +++ b/src/renderer/components/layout/SortableTab.tsx @@ -8,7 +8,7 @@ import { useCallback, useState } from 'react'; import { useSortable } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; import { useStore } from '@renderer/store'; -import { Bell, FileText, LayoutDashboard, Pin, Search, Settings, X } from 'lucide-react'; +import { Activity, Bell, FileText, LayoutDashboard, Pin, Search, Settings, X } from 'lucide-react'; import { useShallow } from 'zustand/react/shallow'; import type { Tab } from '@renderer/types/tabs'; @@ -30,6 +30,7 @@ const TAB_ICONS = { notifications: Bell, settings: Settings, session: FileText, + report: Activity, } as const; export const SortableTab = ({ diff --git a/src/renderer/components/layout/TabBar.tsx b/src/renderer/components/layout/TabBar.tsx index 167b0b21..b4874b5f 100644 --- a/src/renderer/components/layout/TabBar.tsx +++ b/src/renderer/components/layout/TabBar.tsx @@ -15,7 +15,7 @@ import { isElectronMode } from '@renderer/api'; import { HEADER_ROW1_HEIGHT } from '@renderer/constants/layout'; import { useStore } from '@renderer/store'; import { formatShortcut } from '@renderer/utils/stringUtils'; -import { Bell, PanelLeft, Plus, RefreshCw, Search, Settings } from 'lucide-react'; +import { Activity, Bell, PanelLeft, Plus, RefreshCw, Search, Settings } from 'lucide-react'; import { useShallow } from 'zustand/react/shallow'; import { ExportDropdown } from '../common/ExportDropdown'; @@ -46,6 +46,7 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => { unreadCount, openNotificationsTab, openSettingsTab, + openSessionReport, sidebarCollapsed, toggleSidebar, splitPane, @@ -73,6 +74,7 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => { unreadCount: s.unreadCount, openNotificationsTab: s.openNotificationsTab, openSettingsTab: s.openSettingsTab, + openSessionReport: s.openSessionReport, sidebarCollapsed: s.sidebarCollapsed, toggleSidebar: s.toggleSidebar, splitPane: s.splitPane, @@ -106,6 +108,7 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => { const [searchHover, setSearchHover] = useState(false); const [notificationsHover, setNotificationsHover] = useState(false); const [settingsHover, setSettingsHover] = useState(false); + const [analyzeHover, setAnalyzeHover] = useState(false); // Context menu state const [contextMenu, setContextMenu] = useState<{ x: number; y: number; tabId: string } | null>( @@ -400,6 +403,23 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => { )} + {/* Analyze button - show only for session tabs with loaded data */} + {activeTab?.type === 'session' && activeTabSessionDetail && activeTabId && ( + + )} + {/* Notifications bell icon */} + {!collapsed &&
{children}
} + + ); +}; + +export { sectionId }; diff --git a/src/renderer/components/report/SessionReportTab.tsx b/src/renderer/components/report/SessionReportTab.tsx new file mode 100644 index 00000000..f5cd8031 --- /dev/null +++ b/src/renderer/components/report/SessionReportTab.tsx @@ -0,0 +1,99 @@ +import { useMemo } from 'react'; + +import { useStore } from '@renderer/store'; +import { computeTakeaways } from '@renderer/utils/reportAssessments'; +import { analyzeSession } from '@renderer/utils/sessionAnalyzer'; + +import { CostSection } from './sections/CostSection'; +import { ErrorSection } from './sections/ErrorSection'; +import { FrictionSection } from './sections/FrictionSection'; +import { GitSection } from './sections/GitSection'; +import { InsightsSection } from './sections/InsightsSection'; +import { KeyTakeawaysSection } from './sections/KeyTakeawaysSection'; +import { OverviewSection } from './sections/OverviewSection'; +import { QualitySection } from './sections/QualitySection'; +import { SubagentSection } from './sections/SubagentSection'; +import { TimelineSection } from './sections/TimelineSection'; +import { TokenSection } from './sections/TokenSection'; +import { ToolSection } from './sections/ToolSection'; + +import type { Tab } from '@renderer/types/tabs'; + +interface SessionReportTabProps { + tab: Tab; +} + +export const SessionReportTab = ({ tab }: SessionReportTabProps) => { + // Find session data from any session tab with matching sessionId + const sessionDetail = useStore((s) => { + const allTabs = s.paneLayout.panes.flatMap((p) => p.tabs); + const sourceTab = allTabs.find((t) => t.type === 'session' && t.sessionId === tab.sessionId); + return sourceTab ? s.tabSessionData[sourceTab.id]?.sessionDetail : null; + }); + + const report = useMemo( + () => (sessionDetail ? analyzeSession(sessionDetail) : null), + [sessionDetail] + ); + + const takeaways = useMemo(() => (report ? computeTakeaways(report) : []), [report]); + + if (!report) { + return ( +
+ No session data available. Open the session tab first. +
+ ); + } + + return ( +
+

Session Analysis Report

+
+ {takeaways.length > 0 && } + + + + + {report.subagentMetrics.count > 0 && ( + + )} + {report.errors.errors.length > 0 && } + + + + + +
+
+ ); +}; diff --git a/src/renderer/components/report/sections/CostSection.tsx b/src/renderer/components/report/sections/CostSection.tsx new file mode 100644 index 00000000..c1fdd2e0 --- /dev/null +++ b/src/renderer/components/report/sections/CostSection.tsx @@ -0,0 +1,260 @@ +import { Fragment, useState } from 'react'; + +import { getPricing } from '@renderer/utils/sessionAnalyzer'; +import { DollarSign } from 'lucide-react'; + +import { AssessmentBadge } from '../AssessmentBadge'; +import { ReportSection, sectionId } from '../ReportSection'; + +import type { + ModelPricing, + ModelTokenStats, + ReportCostAnalysis, +} from '@renderer/types/sessionReport'; + +const fmt = (v: number) => `$${v.toFixed(4)}`; +const fmtK = (v: number) => (v >= 1000 ? `${(v / 1000).toFixed(1)}k` : String(v)); +const fmtRate = (v: number) => `$${v}`; +const lineCost = (tokens: number, ratePerM: number) => (tokens * ratePerM) / 1_000_000; + +interface CostSectionProps { + data: ReportCostAnalysis; + tokensByModel: Record; + commitCount: number; + linesChanged: number; + defaultCollapsed?: boolean; +} + +interface BreakdownLine { + label: string; + tokens: number; + ratePerM: number; +} + +const CostBreakdownCard = ({ + stats, + pricing, +}: { + stats: ModelTokenStats; + pricing: ModelPricing; +}) => { + const lines: BreakdownLine[] = [ + { label: 'Input', tokens: stats.inputTokens, ratePerM: pricing.input }, + { label: 'Output', tokens: stats.outputTokens, ratePerM: pricing.output }, + { label: 'Cache Read', tokens: stats.cacheRead, ratePerM: pricing.cache_read }, + { label: 'Cache Write', tokens: stats.cacheCreation, ratePerM: pricing.cache_creation }, + ]; + const total = lines.reduce((sum, l) => sum + lineCost(l.tokens, l.ratePerM), 0); + + return ( +
+
+ Cost Breakdown (per 1M tokens) +
+
+ {lines.map((l) => { + const cost = lineCost(l.tokens, l.ratePerM); + return ( +
+ {l.label} + + {l.tokens.toLocaleString()} {'\u00D7'} {fmtRate(l.ratePerM)}/M = {fmt(cost)} + +
+ ); + })} +
+ Total + {fmt(total)} +
+
+
+ ); +}; + +export const CostSection = ({ + data, + tokensByModel, + commitCount, + linesChanged, + defaultCollapsed, +}: CostSectionProps) => { + const [expandedModel, setExpandedModel] = useState(null); + const modelEntries = Object.entries(data.costByModel).sort((a, b) => b[1] - a[1]); + const showStackedBar = data.subagentCostUsd > 0; + const parentPct = + showStackedBar && data.totalSessionCostUsd > 0 + ? (data.parentCostUsd / data.totalSessionCostUsd) * 100 + : 100; + + return ( + +
{fmt(data.totalSessionCostUsd)}
+ + {/* Parent/Subagent stacked bar */} + {showStackedBar && ( +
+
+
+
+
+
+
+ + Parent: {fmt(data.parentCostUsd)} +
+
+ + Subagent: {fmt(data.subagentCostUsd)} +
+
+
+ )} + +
+ {!showStackedBar && ( + <> +
+
Parent Cost
+
{fmt(data.parentCostUsd)}
+
+
+
Subagent Cost
+
{fmt(data.subagentCostUsd)}
+
+ + )} +
+
Per Commit
+
+ {commitCount > 0 ? ( + <> + total cost {'\u00F7'} {commitCount} commit{commitCount !== 1 ? 's' : ''} + + ) : ( + 'no commits' + )} +
+
+ + {data.costPerCommit != null ? fmt(data.costPerCommit) : 'N/A'} + + {data.costPerCommitAssessment && ( + + )} +
+
+
+
Per Line Changed
+
+ {linesChanged > 0 ? ( + <> + total cost {'\u00F7'} {linesChanged.toLocaleString()} line + {linesChanged !== 1 ? 's' : ''} + + ) : ( + 'no lines changed' + )} +
+
+ + {data.costPerLineChanged != null ? `$${data.costPerLineChanged.toFixed(6)}` : 'N/A'} + + {data.costPerLineAssessment && ( + + )} +
+
+
+ + {modelEntries.length > 0 && ( +
+ + + + + + + + + + + + {modelEntries.map(([model, cost]) => { + const stats = tokensByModel[model]; + // Don't allow expansion for the synthetic aggregated row — getPricing + // would return wrong default rates for a non-model label. + const isAggregateRow = model === 'Subagents (combined)'; + const isExpanded = expandedModel === model && !!stats && !isAggregateRow; + const pricing = isAggregateRow ? null : getPricing(model); + return ( + + { + if (isAggregateRow) { + const el = document.getElementById(sectionId('Subagents')); + if (el) { + el.scrollIntoView({ behavior: 'smooth' }); + el.dispatchEvent(new CustomEvent('report-section-expand')); + } + } else if (stats) { + setExpandedModel(isExpanded ? null : model); + } + }} + > + + + + + + + + {isExpanded && stats && pricing && ( + + + + )} + + ); + })} + +
ModelInputOutputCache ReadCache WriteCost
+ {isAggregateRow ? ( + {'\u2192'} + ) : ( + + {stats ? (isExpanded ? '\u25BC' : '\u25B6') : ''} + + )} + {model} + + {stats ? fmtK(stats.inputTokens) : '—'} + + {stats ? fmtK(stats.outputTokens) : '—'} + + {stats ? fmtK(stats.cacheRead) : '—'} + + {stats ? fmtK(stats.cacheCreation) : '—'} + {fmt(cost)}
+ +
+ )} + + ); +}; diff --git a/src/renderer/components/report/sections/ErrorSection.tsx b/src/renderer/components/report/sections/ErrorSection.tsx new file mode 100644 index 00000000..88832434 --- /dev/null +++ b/src/renderer/components/report/sections/ErrorSection.tsx @@ -0,0 +1,103 @@ +import { useState } from 'react'; + +import { AlertTriangle, ChevronDown, ChevronRight } from 'lucide-react'; + +import { ReportSection } from '../ReportSection'; + +import type { ReportErrors, ToolError } from '@renderer/types/sessionReport'; + +interface ErrorItemProps { + error: ToolError; +} + +const ErrorItem = ({ error }: ErrorItemProps) => { + const [expanded, setExpanded] = useState(false); + + return ( +
+ + {expanded && ( +
+ {error.inputPreview && ( +
+
+ Input +
+
+ {error.inputPreview} +
+
+ )} +
+
+ Error +
+
+ {error.error} +
+
+
+ )} +
+ ); +}; + +interface ErrorSectionProps { + data: ReportErrors; + defaultCollapsed?: boolean; +} + +export const ErrorSection = ({ data, defaultCollapsed }: ErrorSectionProps) => { + return ( + +
+ + {data.errors.length} error{data.errors.length !== 1 ? 's' : ''} + + {data.permissionDenials.count > 0 && ( + + {data.permissionDenials.count} permission denial + {data.permissionDenials.count !== 1 ? 's' : ''} + + )} +
+ +
+ {data.errors.map((error, idx) => ( + + ))} +
+
+ ); +}; diff --git a/src/renderer/components/report/sections/FrictionSection.tsx b/src/renderer/components/report/sections/FrictionSection.tsx new file mode 100644 index 00000000..147b74ed --- /dev/null +++ b/src/renderer/components/report/sections/FrictionSection.tsx @@ -0,0 +1,97 @@ +import { severityColor } from '@renderer/utils/reportAssessments'; +import { MessageSquareWarning } from 'lucide-react'; + +import { AssessmentBadge } from '../AssessmentBadge'; +import { ReportSection } from '../ReportSection'; + +import type { ReportFrictionSignals, ReportThrashingSignals } from '@renderer/types/sessionReport'; + +interface FrictionSectionProps { + data: ReportFrictionSignals; + thrashing: ReportThrashingSignals; + defaultCollapsed?: boolean; +} + +export const FrictionSection = ({ data, thrashing, defaultCollapsed }: FrictionSectionProps) => { + const frictionSeverity = + data.frictionRate <= 0.1 ? 'good' : data.frictionRate <= 0.25 ? 'warning' : 'danger'; + const frictionColor = severityColor(frictionSeverity); + + return ( + +
+ + Friction Rate: {(data.frictionRate * 100).toFixed(1)}% + + + {data.correctionCount} correction{data.correctionCount !== 1 ? 's' : ''} + +
+ + {data.corrections.length > 0 && ( +
+
Corrections
+
+ {data.corrections.map((corr, idx) => ( +
+ + {corr.keyword} + + {corr.preview} +
+ ))} +
+
+ )} + + {(thrashing.bashNearDuplicates.length > 0 || thrashing.editReworkFiles.length > 0) && ( +
+
+ Thrashing Signals + +
+ + {thrashing.bashNearDuplicates.length > 0 && ( +
+
Repeated Bash Commands
+ {thrashing.bashNearDuplicates.map((dup, idx) => ( +
+ {dup.count}x + {dup.prefix} +
+ ))} +
+ )} + + {thrashing.editReworkFiles.length > 0 && ( +
+
Reworked Files (3+ edits)
+ {thrashing.editReworkFiles.map((file, idx) => ( +
+ {file.editIndices.length}x + {file.filePath} +
+ ))} +
+ )} +
+ )} +
+ ); +}; diff --git a/src/renderer/components/report/sections/GitSection.tsx b/src/renderer/components/report/sections/GitSection.tsx new file mode 100644 index 00000000..5481d835 --- /dev/null +++ b/src/renderer/components/report/sections/GitSection.tsx @@ -0,0 +1,72 @@ +import { GitBranch } from 'lucide-react'; + +import { ReportSection } from '../ReportSection'; + +import type { ReportGitActivity } from '@renderer/types/sessionReport'; + +interface GitSectionProps { + data: ReportGitActivity; + defaultCollapsed?: boolean; +} + +export const GitSection = ({ data, defaultCollapsed }: GitSectionProps) => { + return ( + +
+
+
Commits
+
{data.commitCount}
+
+
+
Pushes
+
{data.pushCount}
+
+
+
Lines Added
+
+ +{data.linesAdded.toLocaleString()} +
+
+
+
Lines Removed
+
+ -{data.linesRemoved.toLocaleString()} +
+
+
+ + {data.commits.length > 0 && ( +
+
Commits
+
+ {data.commits.map((commit, idx) => ( +
+ #{commit.messageIndex} + {commit.messagePreview} +
+ ))} +
+
+ )} + + {data.branchCreations.length > 0 && ( +
+
Branches Created
+
+ {data.branchCreations.map((branch, idx) => ( + + {branch} + + ))} +
+
+ )} +
+ ); +}; diff --git a/src/renderer/components/report/sections/InsightsSection.tsx b/src/renderer/components/report/sections/InsightsSection.tsx new file mode 100644 index 00000000..655ecb1a --- /dev/null +++ b/src/renderer/components/report/sections/InsightsSection.tsx @@ -0,0 +1,207 @@ +import { Lightbulb } from 'lucide-react'; + +import { ReportSection } from '../ReportSection'; + +import type { + OutOfScopeFindings, + ReportAgentTree, + ReportBashCommands, + SkillInvocation, + SubagentBasicEntry, + UserQuestion, +} from '@renderer/types/sessionReport'; + +interface InsightsSectionProps { + skills: SkillInvocation[]; + bash: ReportBashCommands; + lifecycleTasks: string[]; + userQuestions: UserQuestion[]; + outOfScope: OutOfScopeFindings[]; + agentTree: ReportAgentTree; + subagentsList: SubagentBasicEntry[]; + defaultCollapsed?: boolean; +} + +export const InsightsSection = ({ + skills, + bash, + lifecycleTasks, + userQuestions, + outOfScope, + agentTree, + subagentsList, + defaultCollapsed, +}: InsightsSectionProps) => { + return ( + + {/* Skills invoked */} + {skills.length > 0 && ( +
+
+ Skills Invoked ({skills.length}) +
+
+ {skills.map((s, idx) => ( +
+ {s.skill} + {s.argsPreview && {s.argsPreview}} +
+ ))} +
+
+ )} + + {/* Bash commands */} +
+
Bash Commands
+
+
+
Total
+
{bash.total}
+
+
+
Unique
+
{bash.unique}
+
+
+
Repeated
+
{Object.keys(bash.repeated).length}
+
+
+ {Object.keys(bash.repeated).length > 0 && ( +
+ {Object.entries(bash.repeated) + .slice(0, 10) + .map(([cmd, count], idx) => ( +
+ {count}x + {cmd} +
+ ))} +
+ )} +
+ + {/* Task tool subagent list */} + {subagentsList.length > 0 && ( +
+
+ Task Dispatches ({subagentsList.length}) +
+
+ {subagentsList.map((s, idx) => ( +
+ + {s.subagentType} + + {s.description} + {s.runInBackground && (background)} +
+ ))} +
+
+ )} + + {/* Lifecycle tasks */} + {lifecycleTasks.length > 0 && ( +
+
+ Tasks Created ({lifecycleTasks.length}) +
+
+ {lifecycleTasks.map((task, idx) => ( +
+ {task} +
+ ))} +
+
+ )} + + {/* User questions */} + {userQuestions.length > 0 && ( +
+
+ Questions Asked ({userQuestions.length}) +
+
+ {userQuestions.map((q, idx) => ( +
+
{q.question}
+ {q.options.length > 0 && ( +
+ {q.options.map((opt, optIdx) => ( + + {opt} + + ))} +
+ )} +
+ ))} +
+
+ )} + + {/* Agent tree */} + {agentTree.agentCount > 0 && ( +
+
+ Agent Tree ({agentTree.agentCount} agent{agentTree.agentCount !== 1 ? 's' : ''}) + {agentTree.hasTeamMode && ( + + Team Mode + + )} +
+ {agentTree.teamNames.length > 0 && ( +
+ Teams: {agentTree.teamNames.join(', ')} +
+ )} +
+ {agentTree.agents.map((agent, idx) => ( +
+ + {agent.agentType} + + + {agent.agentId.slice(0, 12)}... + +
+ ))} +
+
+ )} + + {/* Out-of-scope findings */} + {outOfScope.length > 0 && ( +
+
+ Out-of-Scope Findings ({outOfScope.length}) +
+
+ {outOfScope.map((f, idx) => ( +
+ + {f.keyword} + + {f.snippet} +
+ ))} +
+
+ )} +
+ ); +}; diff --git a/src/renderer/components/report/sections/KeyTakeawaysSection.tsx b/src/renderer/components/report/sections/KeyTakeawaysSection.tsx new file mode 100644 index 00000000..9eee50b0 --- /dev/null +++ b/src/renderer/components/report/sections/KeyTakeawaysSection.tsx @@ -0,0 +1,55 @@ +import { severityColor } from '@renderer/utils/reportAssessments'; +import { AlertTriangle, CheckCircle, ChevronRight, Info, XCircle } from 'lucide-react'; + +import { sectionId } from '../ReportSection'; + +import type { Severity, Takeaway } from '@renderer/utils/reportAssessments'; + +const SEVERITY_ICONS: Record> = { + danger: XCircle, + warning: AlertTriangle, + good: CheckCircle, + neutral: Info, +}; + +const scrollToSection = (sectionTitle: string) => { + const el = document.getElementById(sectionId(sectionTitle)); + if (!el) return; + el.dispatchEvent(new CustomEvent('report-section-expand')); +}; + +interface KeyTakeawaysSectionProps { + takeaways: Takeaway[]; +} + +export const KeyTakeawaysSection = ({ takeaways }: KeyTakeawaysSectionProps) => { + return ( +
+
Key Takeaways
+
+ {takeaways.map((t, idx) => { + const Icon = SEVERITY_ICONS[t.severity]; + const color = severityColor(t.severity); + return ( + + ); + })} +
+
+ ); +}; diff --git a/src/renderer/components/report/sections/OverviewSection.tsx b/src/renderer/components/report/sections/OverviewSection.tsx new file mode 100644 index 00000000..d871b02d --- /dev/null +++ b/src/renderer/components/report/sections/OverviewSection.tsx @@ -0,0 +1,64 @@ +import { assessmentColor } from '@renderer/utils/reportAssessments'; +import { Activity } from 'lucide-react'; + +import { ReportSection } from '../ReportSection'; + +import type { ReportOverview } from '@renderer/types/sessionReport'; + +interface OverviewSectionProps { + data: ReportOverview; +} + +export const OverviewSection = ({ data }: OverviewSectionProps) => { + return ( + +
{data.firstMessage}
+
+
+
Duration
+
{data.durationHuman}
+
+
+
Messages
+
{data.totalMessages.toLocaleString()}
+
+
+
Context Usage
+
+ {data.contextConsumptionPct != null ? `${data.contextConsumptionPct}%` : 'N/A'} + {data.contextAssessment && ( + ({data.contextAssessment}) + )} +
+
+
+
Compactions
+
{data.compactionCount}
+
+
+
Branch
+
{data.gitBranch}
+
+
+
Subagents
+
{data.hasSubagents ? 'Yes' : 'No'}
+
+
+
Project
+
+ {data.projectPath} +
+
+
+
Session ID
+
+ {data.sessionId.slice(0, 12)}... +
+
+
+
+ ); +}; diff --git a/src/renderer/components/report/sections/QualitySection.tsx b/src/renderer/components/report/sections/QualitySection.tsx new file mode 100644 index 00000000..fa1794cb --- /dev/null +++ b/src/renderer/components/report/sections/QualitySection.tsx @@ -0,0 +1,153 @@ +import { severityColor } from '@renderer/utils/reportAssessments'; +import { BarChart3 } from 'lucide-react'; + +import { AssessmentBadge } from '../AssessmentBadge'; +import { ReportSection } from '../ReportSection'; + +import type { + ReportFileReadRedundancy, + ReportPromptQuality, + ReportStartupOverhead, + ReportTestProgression, +} from '@renderer/types/sessionReport'; + +interface QualitySectionProps { + prompt: ReportPromptQuality; + startup: ReportStartupOverhead; + testProgression: ReportTestProgression; + fileReadRedundancy: ReportFileReadRedundancy; + defaultCollapsed?: boolean; +} + +export const QualitySection = ({ + prompt, + startup, + testProgression, + fileReadRedundancy, + defaultCollapsed, +}: QualitySectionProps) => { + return ( + + {/* Prompt quality */} +
+
Prompt Quality
+
+ +
+
{prompt.note}
+
+
+
First Message
+
+ {prompt.firstMessageLengthChars.toLocaleString()} chars +
+
+
+
User Messages
+
{prompt.userMessageCount}
+
+
+
Corrections
+
{prompt.correctionCount}
+
+
+
Friction Rate
+
+ {(prompt.frictionRate * 100).toFixed(1)}% +
+
+
+
+ + {/* Startup overhead */} +
+
+ Startup Overhead + +
+
+
+
Messages Before Work
+
{startup.messagesBeforeFirstWork}
+
+
+
Tokens Before Work
+
+ {startup.tokensBeforeFirstWork.toLocaleString()} +
+
+
+
% of Total
+
{startup.pctOfTotal}%
+
+
+
+ + {/* File read redundancy */} +
+
+ File Read Redundancy + +
+
+
+
Total Reads
+
{fileReadRedundancy.totalReads}
+
+
+
Unique Files
+
{fileReadRedundancy.uniqueFiles}
+
+
+
Reads/Unique File
+
+ {fileReadRedundancy.readsPerUniqueFile}x +
+
+
+
+ + {/* Test progression */} +
+
Test Progression
+
+ + + {testProgression.snapshotCount} snapshot{testProgression.snapshotCount !== 1 ? 's' : ''} + +
+ {testProgression.firstSnapshot && testProgression.lastSnapshot && ( +
+
+
First Run
+
+ + {testProgression.firstSnapshot.passed} passed + + {' / '} + + {testProgression.firstSnapshot.failed} failed + +
+
+
+
Last Run
+
+ + {testProgression.lastSnapshot.passed} passed + + {' / '} + + {testProgression.lastSnapshot.failed} failed + +
+
+
+ )} +
+
+ ); +}; diff --git a/src/renderer/components/report/sections/SubagentSection.tsx b/src/renderer/components/report/sections/SubagentSection.tsx new file mode 100644 index 00000000..7ce3dd60 --- /dev/null +++ b/src/renderer/components/report/sections/SubagentSection.tsx @@ -0,0 +1,88 @@ +import { severityColor } from '@renderer/utils/reportAssessments'; +import { Users } from 'lucide-react'; + +import { ReportSection } from '../ReportSection'; + +import type { ReportSubagentMetrics } from '@renderer/types/sessionReport'; + +const fmtCost = (v: number) => `$${v.toFixed(4)}`; +const fmtDuration = (ms: number) => { + const s = Math.round(ms / 1000); + const m = Math.floor(s / 60); + const sec = s % 60; + return m > 0 ? `${m}m ${sec}s` : `${sec}s`; +}; + +interface SubagentSectionProps { + data: ReportSubagentMetrics; + defaultCollapsed?: boolean; +} + +export const SubagentSection = ({ data, defaultCollapsed }: SubagentSectionProps) => { + return ( + +
+
+
Count
+
{data.count}
+
+
+
Total Tokens
+
{data.totalTokens.toLocaleString()}
+
+
+
Total Duration
+
{fmtDuration(data.totalDurationMs)}
+
+
+
Total Cost
+
{fmtCost(data.totalCostUsd)}
+
+
+ + {data.byAgent.length > 0 && ( +
+ + + + + + + + + + + + {data.byAgent.map((agent, idx) => ( + + + + + + + + ))} + +
DescriptionTypeTokensDurationCost
+
+ {agent.description} +
+ {agent.modelMismatch && ( +
+ {agent.modelMismatch.recommendation} +
+ )} +
{agent.subagentType} + {agent.totalTokens.toLocaleString()} + + {fmtDuration(agent.totalDurationMs)} + {fmtCost(agent.costUsd)}
+
+ )} +
+ ); +}; diff --git a/src/renderer/components/report/sections/TimelineSection.tsx b/src/renderer/components/report/sections/TimelineSection.tsx new file mode 100644 index 00000000..395974dd --- /dev/null +++ b/src/renderer/components/report/sections/TimelineSection.tsx @@ -0,0 +1,111 @@ +import { assessmentColor, assessmentLabel } from '@renderer/utils/reportAssessments'; +import { Clock } from 'lucide-react'; + +import { AssessmentBadge } from '../AssessmentBadge'; +import { ReportSection } from '../ReportSection'; + +import type { + KeyEvent, + ReportIdleAnalysis, + ReportModelSwitches, +} from '@renderer/types/sessionReport'; + +interface TimelineSectionProps { + idle: ReportIdleAnalysis; + modelSwitches: ReportModelSwitches; + keyEvents: KeyEvent[]; + defaultCollapsed?: boolean; +} + +export const TimelineSection = ({ + idle, + modelSwitches, + keyEvents, + defaultCollapsed, +}: TimelineSectionProps) => { + const idleColor = assessmentColor(idle.idleAssessment); + + return ( + + {/* Idle stats */} +
+
+ Idle Analysis + +
+
+
+
Idle Gaps
+
{idle.idleGapCount}
+
+
+
Total Idle
+
{idle.totalIdleHuman}
+
+
+
Active Time
+
{idle.activeWorkingHuman}
+
+
+
Idle %
+
+ {idle.idlePct}% +
+
+
+
+ + {/* Model switches */} + {modelSwitches.count > 0 && ( +
+
+ + Model Switches ({modelSwitches.count}) + + {modelSwitches.switchPattern && ( + + {assessmentLabel(modelSwitches.switchPattern)} + + )} +
+
+ {modelSwitches.switches.map((sw, idx) => ( +
+ {sw.from} + + {sw.to} + msg #{sw.messageIndex} +
+ ))} +
+
+ )} + + {/* Key events */} + {keyEvents.length > 0 && ( +
+
Key Events
+
+ {keyEvents.map((event, idx) => ( +
+ + {event.timestamp.toLocaleTimeString()} + + {event.label} + {event.deltaHuman && ( + +{event.deltaHuman} + )} +
+ ))} +
+
+ )} +
+ ); +}; diff --git a/src/renderer/components/report/sections/TokenSection.tsx b/src/renderer/components/report/sections/TokenSection.tsx new file mode 100644 index 00000000..f3aeaa27 --- /dev/null +++ b/src/renderer/components/report/sections/TokenSection.tsx @@ -0,0 +1,116 @@ +import { Coins } from 'lucide-react'; + +import { AssessmentBadge } from '../AssessmentBadge'; +import { ReportSection } from '../ReportSection'; + +import type { ReportCacheEconomics, ReportTokenUsage } from '@renderer/types/sessionReport'; + +const fmt = (v: number) => v.toLocaleString(); +const fmtCost = (v: number) => `$${v.toFixed(4)}`; + +interface TokenSectionProps { + data: ReportTokenUsage; + cacheEconomics: ReportCacheEconomics; + defaultCollapsed?: boolean; +} + +export const TokenSection = ({ data, cacheEconomics, defaultCollapsed }: TokenSectionProps) => { + const modelEntries = Object.entries(data.byModel).sort((a, b) => b[1].costUsd - a[1].costUsd); + + return ( + + {/* By-model table */} +
+ + + + + + + + + + + + + + {modelEntries.map(([model, stats]) => ( + + + + + + + + + + ))} + {/* Totals row */} + + + + + + + + + + +
ModelAPI CallsInputOutputCache ReadCache CreateCost
{model}{fmt(stats.apiCalls)}{fmt(stats.inputTokens)}{fmt(stats.outputTokens)}{fmt(stats.cacheRead)}{fmt(stats.cacheCreation)}{fmtCost(stats.costUsd)}
Total + {fmt(modelEntries.reduce((s, [, st]) => s + st.apiCalls, 0))} + {fmt(data.totals.inputTokens)}{fmt(data.totals.outputTokens)}{fmt(data.totals.cacheRead)}{fmt(data.totals.cacheCreation)} + {fmtCost(modelEntries.reduce((s, [, st]) => s + st.costUsd, 0))} +
+
+ + {/* Cache economics */} +
+
+
Cache Efficiency
+
+ + {cacheEconomics.cacheEfficiencyPct}% + + {cacheEconomics.cacheEfficiencyAssessment && ( + + )} +
+
+
+
R/W Ratio
+
+ + {cacheEconomics.cacheReadToWriteRatio}x + + {cacheEconomics.cacheRatioAssessment && ( + + )} +
+
+
+
Cache Read %
+
{data.totals.cacheReadPct}%
+
+
+
Cold Start
+
+ {cacheEconomics.coldStartDetected ? 'Yes' : 'No'} +
+
+
+
+ ); +}; diff --git a/src/renderer/components/report/sections/ToolSection.tsx b/src/renderer/components/report/sections/ToolSection.tsx new file mode 100644 index 00000000..fd15c852 --- /dev/null +++ b/src/renderer/components/report/sections/ToolSection.tsx @@ -0,0 +1,77 @@ +import { assessmentColor } from '@renderer/utils/reportAssessments'; +import { Wrench } from 'lucide-react'; + +import { AssessmentBadge } from '../AssessmentBadge'; +import { ReportSection, sectionId } from '../ReportSection'; + +import type { ReportToolUsage } from '@renderer/types/sessionReport'; + +interface ToolSectionProps { + data: ReportToolUsage; + defaultCollapsed?: boolean; +} + +export const ToolSection = ({ data, defaultCollapsed }: ToolSectionProps) => { + const toolEntries = Object.entries(data.successRates).sort( + (a, b) => b[1].totalCalls - a[1].totalCalls + ); + + return ( + +
+ + {data.totalCalls.toLocaleString()} total calls across {toolEntries.length} tools + + +
+
+ + + + + + + + + + + + {toolEntries.map(([tool, stats]) => { + const color = assessmentColor(stats.assessment); + return ( + + + + + + + + ); + })} + +
ToolCallsErrorsSuccess %Health
{tool} + {stats.totalCalls.toLocaleString()} + + {stats.errors > 0 ? ( + + ) : ( + stats.errors.toLocaleString() + )} + + {stats.successRatePct}% + + +
+
+
+ ); +}; diff --git a/src/renderer/index.css b/src/renderer/index.css index 6497bdc2..5a7db141 100644 --- a/src/renderer/index.css +++ b/src/renderer/index.css @@ -181,6 +181,12 @@ --card-text-lighter: #e4e4e7; --card-separator: #3f3f46; + /* Assessment severity colors (badges, health indicators) */ + --assess-good: #4ade80; + --assess-warning: #fbbf24; + --assess-danger: #f87171; + --assess-neutral: #a1a1aa; + /* Sticky Context button - transparent glass */ --context-btn-bg: rgba(255, 255, 255, 0.08); --context-btn-bg-hover: rgba(255, 255, 255, 0.14); @@ -206,6 +212,12 @@ --color-text-secondary: #4d4b46; /* Warm secondary text */ --color-text-muted: #6d6b65; /* Warm muted text */ + /* Assessment severity colors - darker for light backgrounds */ + --assess-good: #16a34a; + --assess-warning: #d97706; + --assess-danger: #dc2626; + --assess-neutral: #57534e; + /* Scrollbar colors for light mode */ --scrollbar-thumb: rgba(0, 0, 0, 0.15); --scrollbar-thumb-hover: rgba(0, 0, 0, 0.28); diff --git a/src/renderer/store/slices/tabSlice.ts b/src/renderer/store/slices/tabSlice.ts index f0d4f3af..677733a0 100644 --- a/src/renderer/store/slices/tabSlice.ts +++ b/src/renderer/store/slices/tabSlice.ts @@ -46,6 +46,7 @@ export interface TabSlice { closeTab: (tabId: string) => void; setActiveTab: (tabId: string) => void; openDashboard: () => void; + openSessionReport: (sourceTabId: string) => void; getActiveTab: () => Tab | null; isSessionOpen: (sessionId: string) => boolean; enqueueTabNavigation: (tabId: string, request: TabNavigationRequest) => void; @@ -380,6 +381,28 @@ export const createTabSlice: StateCreator = (set, ge set(syncFromLayout(newLayout)); }, + // Open a session report tab based on a source session tab + openSessionReport: (sourceTabId: string) => { + const state = get(); + const allTabs = getAllTabs(state.paneLayout); + const sourceTab = allTabs.find((t) => t.id === sourceTabId); + if (sourceTab?.type !== 'session') return; + if (!sourceTab.sessionId || !sourceTab.projectId) return; + + const tabData = state.tabSessionData[sourceTabId]; + const firstMsg = tabData?.sessionDetail?.session.firstMessage; + const label = firstMsg + ? `Report: ${firstMsg.slice(0, 30)}${firstMsg.length > 30 ? '…' : ''}` + : 'Session Report'; + + state.openTab({ + type: 'report', + label, + projectId: sourceTab.projectId, + sessionId: sourceTab.sessionId, + }); + }, + // Get the currently active tab (from the focused pane) getActiveTab: () => { const state = get(); diff --git a/src/renderer/types/sessionReport.ts b/src/renderer/types/sessionReport.ts new file mode 100644 index 00000000..b8707292 --- /dev/null +++ b/src/renderer/types/sessionReport.ts @@ -0,0 +1,391 @@ +/** + * Session analysis report types. + * Output of analyzeSession() — one interface per report section. + */ + +import type { + CacheAssessment, + CostAssessment, + IdleAssessment, + ModelMismatch, + OverheadAssessment, + RedundancyAssessment, + SubagentCostShareAssessment, + SwitchPattern, + ThrashingAssessment, + ToolHealthAssessment, +} from '@renderer/utils/reportAssessments'; + +// ============================================================================= +// Pricing +// ============================================================================= + +export interface ModelPricing { + input: number; + output: number; + cache_read: number; + cache_creation: number; +} + +// ============================================================================= +// Report Sections +// ============================================================================= + +export interface ReportOverview { + sessionId: string; + projectId: string; + projectPath: string; + firstMessage: string; + messageCount: number; + hasSubagents: boolean; + contextConsumption: number; + contextConsumptionPct: number | null; + contextAssessment: 'critical' | 'high' | 'moderate' | 'healthy' | null; + compactionCount: number; + gitBranch: string; + startTime: Date | null; + endTime: Date | null; + durationSeconds: number; + durationHuman: string; + totalMessages: number; +} + +export interface ModelTokenStats { + apiCalls: number; + inputTokens: number; + outputTokens: number; + cacheCreation: number; + cacheRead: number; + costUsd: number; +} + +export interface TokenTotals { + inputTokens: number; + outputTokens: number; + cacheCreation: number; + cacheRead: number; + grandTotal: number; + cacheReadPct: number; +} + +export interface ReportTokenUsage { + byModel: Record; + totals: TokenTotals; +} + +export interface ReportCostAnalysis { + parentCostUsd: number; + subagentCostUsd: number; + totalSessionCostUsd: number; + costByModel: Record; + costPerCommit: number | null; + costPerLineChanged: number | null; + costPerCommitAssessment: CostAssessment | null; + costPerLineAssessment: CostAssessment | null; + subagentCostSharePct: number | null; + subagentCostShareAssessment: SubagentCostShareAssessment | null; +} + +export interface ReportCacheEconomics { + cacheRead: number; + cacheEfficiencyPct: number; + coldStartDetected: boolean; + cacheReadToWriteRatio: number; + cacheEfficiencyAssessment: CacheAssessment | null; + cacheRatioAssessment: CacheAssessment | null; +} + +export interface ToolSuccessRate { + totalCalls: number; + errors: number; + successRatePct: number; + assessment: ToolHealthAssessment; +} + +export interface ReportToolUsage { + counts: Record; + totalCalls: number; + successRates: Record; + overallToolHealth: ToolHealthAssessment; +} + +export interface SubagentEntry { + description: string; + subagentType: string; + model: string; + totalTokens: number; + totalDurationMs: number; + totalToolUseCount: number; + costUsd: number; + costNote?: string; + modelMismatch: ModelMismatch | null; +} + +export interface ReportSubagentMetrics { + count: number; + totalTokens: number; + totalDurationMs: number; + totalToolUseCount: number; + totalCostUsd: number; + byAgent: SubagentEntry[]; +} + +export interface ToolError { + tool: string; + inputPreview: string; + error: string; + messageIndex: number; + isPermissionDenial: boolean; +} + +export interface ReportErrors { + errors: ToolError[]; + permissionDenials: { + count: number; + denials: ToolError[]; + affectedTools: string[]; + }; +} + +export interface GitCommit { + messagePreview: string; + messageIndex: number; +} + +export interface ReportGitActivity { + commitCount: number; + commits: GitCommit[]; + pushCount: number; + branchCreations: string[]; + linesAdded: number; + linesRemoved: number; + linesChanged: number; +} + +export interface FrictionCorrection { + messageIndex: number; + keyword: string; + preview: string; +} + +export interface ReportFrictionSignals { + correctionCount: number; + corrections: FrictionCorrection[]; + frictionRate: number; +} + +export interface ReportThrashingSignals { + bashNearDuplicates: { prefix: string; count: number }[]; + editReworkFiles: { filePath: string; editIndices: number[] }[]; + thrashingAssessment: ThrashingAssessment; +} + +export interface ReportConversationTree { + totalNodes: number; + maxDepth: number; + sidechainCount: number; + branchPoints: number; + branchDetails: { + parentUuid: string; + childCount: number; + parentMessageIndex: number | undefined; + }[]; +} + +export interface IdleGap { + gapSeconds: number; + gapHuman: string; + afterMessageIndex: number; +} + +export interface ReportIdleAnalysis { + idleThresholdSeconds: number; + idleGapCount: number; + totalIdleSeconds: number; + totalIdleHuman: string; + wallClockSeconds: number; + activeWorkingSeconds: number; + activeWorkingHuman: string; + idlePct: number; + longestGaps: IdleGap[]; + idleAssessment: IdleAssessment; +} + +export interface ModelSwitch { + from: string; + to: string; + messageIndex: number; + timestamp: Date | null; +} + +export interface ReportModelSwitches { + count: number; + switches: ModelSwitch[]; + modelsUsed: string[]; + switchPattern: SwitchPattern | null; +} + +export interface ReportWorkingDirectories { + uniqueDirectories: string[]; + directoryCount: number; + changes: { from: string; to: string; messageIndex: number }[]; + changeCount: number; + isMultiDirectory: boolean; +} + +export interface TestSnapshot { + messageIndex: number; + passed: number; + failed: number; + total: number; + raw: string; +} + +export interface ReportTestProgression { + snapshotCount: number; + snapshots: TestSnapshot[]; + trajectory: 'improving' | 'regressing' | 'stable' | 'insufficient_data'; + firstSnapshot: TestSnapshot | null; + lastSnapshot: TestSnapshot | null; +} + +export interface ReportStartupOverhead { + messagesBeforeFirstWork: number; + tokensBeforeFirstWork: number; + pctOfTotal: number; + overheadAssessment: OverheadAssessment; +} + +export interface ReportTokenDensityTimeline { + quartiles: { q: number; avgTokens: number; messageCount: number }[]; +} + +export interface ReportPromptQuality { + firstMessageLengthChars: number; + userMessageCount: number; + correctionCount: number; + frictionRate: number; + assessment: 'underspecified' | 'verbose_but_unclear' | 'well_specified' | 'moderate_friction'; + note: string; +} + +export interface ThinkingBlockAnalysis { + messageIndex: number; + preview: string; + charLength: number; + signals: Record; +} + +export interface ReportThinkingBlocks { + count: number; + analyzedCount: number; + signalSummary: Record; + notableBlocks: ThinkingBlockAnalysis[]; +} + +export interface KeyEvent { + timestamp: Date; + label: string; + deltaSeconds?: number; + deltaHuman?: string; +} + +export interface ReportFileReadRedundancy { + totalReads: number; + uniqueFiles: number; + readsPerUniqueFile: number; + redundantFiles: Record; + redundancyAssessment: RedundancyAssessment; +} + +// ============================================================================= +// Missing Sections (ported from Python analyzer) +// ============================================================================= + +export interface SkillInvocation { + skill: string; + argsPreview: string; +} + +export interface ReportBashCommands { + total: number; + unique: number; + repeated: Record; +} + +export interface UserQuestion { + question: string; + options: string[]; +} + +export interface OutOfScopeFindings { + keyword: string; + messageIndex: number; + snippet: string; +} + +export interface AgentTreeNode { + agentId: string; + agentType: string; + teamName: string; + parentToolUseId: string; + messageIndex: number; +} + +export interface ReportAgentTree { + agentCount: number; + agents: AgentTreeNode[]; + hasTeamMode: boolean; + teamNames: string[]; +} + +export interface ReportCompaction { + count: number; + compactSummaryCount: number; + note: string; +} + +export interface SubagentBasicEntry { + description: string; + subagentType: string; + model: string; + runInBackground: boolean; +} + +// ============================================================================= +// Combined Report +// ============================================================================= + +export interface SessionReport { + overview: ReportOverview; + tokenUsage: ReportTokenUsage; + costAnalysis: ReportCostAnalysis; + cacheEconomics: ReportCacheEconomics; + toolUsage: ReportToolUsage; + subagentMetrics: ReportSubagentMetrics; + subagentsList: SubagentBasicEntry[]; + errors: ReportErrors; + gitActivity: ReportGitActivity; + frictionSignals: ReportFrictionSignals; + thrashingSignals: ReportThrashingSignals; + conversationTree: ReportConversationTree; + idleAnalysis: ReportIdleAnalysis; + modelSwitches: ReportModelSwitches; + workingDirectories: ReportWorkingDirectories; + testProgression: ReportTestProgression; + startupOverhead: ReportStartupOverhead; + tokenDensityTimeline: ReportTokenDensityTimeline; + promptQuality: ReportPromptQuality; + thinkingBlocks: ReportThinkingBlocks; + keyEvents: KeyEvent[]; + messageTypes: Record; + fileReadRedundancy: ReportFileReadRedundancy; + compaction: ReportCompaction; + gitBranches: string[]; + skillsInvoked: SkillInvocation[]; + bashCommands: ReportBashCommands; + lifecycleTasks: string[]; + userQuestions: UserQuestion[]; + outOfScopeFindings: OutOfScopeFindings[]; + agentTree: ReportAgentTree; +} diff --git a/src/renderer/types/tabs.ts b/src/renderer/types/tabs.ts index 4e60603e..434b2ff9 100644 --- a/src/renderer/types/tabs.ts +++ b/src/renderer/types/tabs.ts @@ -76,7 +76,7 @@ export interface Tab { id: string; /** Type of content displayed in this tab */ - type: 'session' | 'dashboard' | 'notifications' | 'settings'; + type: 'session' | 'dashboard' | 'notifications' | 'settings' | 'report'; /** Session ID (required when type === 'session') */ sessionId?: string; diff --git a/src/renderer/utils/displayItemBuilder.ts b/src/renderer/utils/displayItemBuilder.ts index 07b4e786..ddaf1f89 100644 --- a/src/renderer/utils/displayItemBuilder.ts +++ b/src/renderer/utils/displayItemBuilder.ts @@ -425,8 +425,7 @@ export function buildDisplayItemsFromMessages( } // Only treat as subagent input if there are NO tool_result blocks in this message const hasToolResults = - Array.isArray(msg.content) && - msg.content.some((b) => b.type === 'tool_result'); + Array.isArray(msg.content) && msg.content.some((b) => b.type === 'tool_result'); if (rawText.trim() && !hasToolResults) { displayItems.push({ type: 'subagent_input', diff --git a/src/renderer/utils/reportAssessments.ts b/src/renderer/utils/reportAssessments.ts new file mode 100644 index 00000000..abaa3a65 --- /dev/null +++ b/src/renderer/utils/reportAssessments.ts @@ -0,0 +1,555 @@ +/** + * Centralized assessment severity/color utilities for session reports. + * + * Maps raw assessment values to severity levels and colors, + * replacing duplicated assessmentColor() functions across report sections. + */ + +// ============================================================================= +// Types +// ============================================================================= + +export type Severity = 'good' | 'warning' | 'danger' | 'neutral'; + +// ============================================================================= +// Colors +// ============================================================================= + +const SEVERITY_CSS_VAR: Record = { + good: '--assess-good', + warning: '--assess-warning', + danger: '--assess-danger', + neutral: '--assess-neutral', +}; + +const SEVERITY_FALLBACKS: Record = { + good: '#4ade80', + warning: '#fbbf24', + danger: '#f87171', + neutral: '#a1a1aa', +}; + +export function severityColor(severity: Severity): string { + if (typeof document === 'undefined') return SEVERITY_FALLBACKS[severity]; + const value = getComputedStyle(document.documentElement) + .getPropertyValue(SEVERITY_CSS_VAR[severity]) + .trim(); + return value || SEVERITY_FALLBACKS[severity]; +} + +// ============================================================================= +// Assessment → Severity Mapping +// ============================================================================= + +const ASSESSMENT_SEVERITY: Record = { + // Context + healthy: 'good', + moderate: 'warning', + high: 'danger', + critical: 'danger', + + // Cost / subagent share + efficient: 'good', + normal: 'good', + expensive: 'warning', + red_flag: 'danger', + very_high: 'danger', + + // Cache + good: 'good', + concerning: 'warning', + + // Tool health + degraded: 'warning', + unreliable: 'danger', + + // Idle ('moderate' already mapped above under Context) + high_idle: 'danger', + + // File read + wasteful: 'warning', + + // Startup + heavy: 'warning', + + // Thrashing + none: 'good', + mild: 'warning', + severe: 'danger', + + // Prompt quality + well_specified: 'good', + moderate_friction: 'warning', + underspecified: 'danger', + verbose_but_unclear: 'danger', + + // Test trajectory + improving: 'good', + stable: 'warning', + regressing: 'danger', + insufficient_data: 'neutral', + + // Model switch + opus_plan_mode: 'good', + manual_switch: 'neutral', +}; + +export function assessmentSeverity(assessment: string | null | undefined): Severity { + if (!assessment) return 'neutral'; + return ASSESSMENT_SEVERITY[assessment] ?? 'neutral'; +} + +export function assessmentColor(assessment: string | null | undefined): string { + return severityColor(assessmentSeverity(assessment)); +} + +// ============================================================================= +// Label Formatting +// ============================================================================= + +export function assessmentLabel(value: string): string { + return value + .split('_') + .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) + .join(' '); +} + +// ============================================================================= +// Threshold Constants +// ============================================================================= + +export const THRESHOLDS = { + costPerCommit: { + efficient: 0.5, + normal: 2, + expensive: 5, + }, + costPerLine: { + efficient: 0.01, + normal: 0.05, + expensive: 0.2, + }, + subagentCostShare: { + normal: 30, + high: 60, + veryHigh: 80, + }, + cacheEfficiency: { + good: 95, + }, + cacheRwRatio: { + good: 20, + }, + toolSuccess: { + healthy: 95, + degraded: 80, + }, + idle: { + efficient: 20, + moderate: 50, + }, + fileReadsPerUnique: { + normal: 2.0, + }, + startupOverhead: { + normal: 5, + }, +} as const; + +// ============================================================================= +// Metric Keys & Explanations +// ============================================================================= + +export type MetricKey = + | 'costPerCommit' + | 'costPerLine' + | 'subagentCostShare' + | 'cacheEfficiency' + | 'cacheRatio' + | 'toolHealth' + | 'idle' + | 'fileReads' + | 'startup' + | 'thrashing' + | 'promptQuality' + | 'testTrajectory'; + +const EXPLANATIONS: Record> = { + costPerCommit: { + efficient: `Under $${THRESHOLDS.costPerCommit.efficient}/commit`, + normal: `$${THRESHOLDS.costPerCommit.efficient}\u2013$${THRESHOLDS.costPerCommit.normal}/commit`, + expensive: `$${THRESHOLDS.costPerCommit.normal}\u2013$${THRESHOLDS.costPerCommit.expensive}/commit`, + red_flag: `Over $${THRESHOLDS.costPerCommit.expensive}/commit`, + }, + costPerLine: { + efficient: `Under $${THRESHOLDS.costPerLine.efficient}/line`, + normal: `$${THRESHOLDS.costPerLine.efficient}\u2013$${THRESHOLDS.costPerLine.normal}/line`, + expensive: `$${THRESHOLDS.costPerLine.normal}\u2013$${THRESHOLDS.costPerLine.expensive}/line`, + red_flag: `Over $${THRESHOLDS.costPerLine.expensive}/line`, + }, + subagentCostShare: { + normal: `Under ${THRESHOLDS.subagentCostShare.normal}% of total cost`, + high: `${THRESHOLDS.subagentCostShare.normal}\u2013${THRESHOLDS.subagentCostShare.high}% of total cost`, + very_high: `${THRESHOLDS.subagentCostShare.high}\u2013${THRESHOLDS.subagentCostShare.veryHigh}% of total cost`, + red_flag: `Over ${THRESHOLDS.subagentCostShare.veryHigh}% of total cost`, + }, + cacheEfficiency: { + good: `${THRESHOLDS.cacheEfficiency.good}%+ cache hit rate`, + concerning: `Below ${THRESHOLDS.cacheEfficiency.good}% cache hit rate`, + }, + cacheRatio: { + good: `${THRESHOLDS.cacheRwRatio.good}x+ read-to-write ratio`, + concerning: `Below ${THRESHOLDS.cacheRwRatio.good}x read-to-write ratio`, + }, + toolHealth: { + healthy: `Over ${THRESHOLDS.toolSuccess.healthy}% success rate`, + degraded: `${THRESHOLDS.toolSuccess.degraded}\u2013${THRESHOLDS.toolSuccess.healthy}% success rate`, + unreliable: `Below ${THRESHOLDS.toolSuccess.degraded}% success rate`, + }, + idle: { + efficient: `Under ${THRESHOLDS.idle.efficient}% idle time`, + moderate: `${THRESHOLDS.idle.efficient}\u2013${THRESHOLDS.idle.moderate}% idle time`, + high_idle: `Over ${THRESHOLDS.idle.moderate}% idle time`, + }, + fileReads: { + normal: `${THRESHOLDS.fileReadsPerUnique.normal}x or fewer reads per unique file`, + wasteful: `Over ${THRESHOLDS.fileReadsPerUnique.normal}x reads per unique file`, + }, + startup: { + normal: `${THRESHOLDS.startupOverhead.normal}% or less of tokens before first work`, + heavy: `Over ${THRESHOLDS.startupOverhead.normal}% of tokens before first work`, + }, + thrashing: { + none: 'No repeated commands or reworked files', + mild: '1\u20132 thrashing signals detected', + severe: '3+ thrashing signals detected', + }, + promptQuality: { + well_specified: 'Clear first message with low friction rate', + moderate_friction: 'Some corrections needed mid-session', + underspecified: 'Short initial prompt led to many corrections', + verbose_but_unclear: 'Long initial prompt but still high friction', + }, + testTrajectory: { + improving: 'Test failures decreased over the session', + stable: 'Test results stayed roughly the same', + regressing: 'Test failures increased over the session', + insufficient_data: 'Not enough test runs to determine trend', + }, +}; + +export function assessmentExplanation(metricKey: MetricKey, assessment: string): string { + return EXPLANATIONS[metricKey]?.[assessment] ?? ''; +} + +// ============================================================================= +// Assessment Computers +// ============================================================================= + +export type CostAssessment = 'efficient' | 'normal' | 'expensive' | 'red_flag'; +export type CacheAssessment = 'good' | 'concerning'; +export type ToolHealthAssessment = 'healthy' | 'degraded' | 'unreliable'; +export type IdleAssessment = 'efficient' | 'moderate' | 'high_idle'; +export type RedundancyAssessment = 'normal' | 'wasteful'; +export type OverheadAssessment = 'normal' | 'heavy'; +export type ThrashingAssessment = 'none' | 'mild' | 'severe'; +export type SubagentCostShareAssessment = 'normal' | 'high' | 'very_high' | 'red_flag'; +export type SwitchPattern = 'opus_plan_mode' | 'manual_switch' | 'none'; + +export function computeCostPerCommitAssessment(costPerCommit: number): CostAssessment { + if (costPerCommit < THRESHOLDS.costPerCommit.efficient) return 'efficient'; + if (costPerCommit < THRESHOLDS.costPerCommit.normal) return 'normal'; + if (costPerCommit < THRESHOLDS.costPerCommit.expensive) return 'expensive'; + return 'red_flag'; +} + +export function computeCostPerLineAssessment(costPerLine: number): CostAssessment { + if (costPerLine < THRESHOLDS.costPerLine.efficient) return 'efficient'; + if (costPerLine < THRESHOLDS.costPerLine.normal) return 'normal'; + if (costPerLine < THRESHOLDS.costPerLine.expensive) return 'expensive'; + return 'red_flag'; +} + +export function computeSubagentCostShareAssessment(pct: number): SubagentCostShareAssessment { + if (pct < THRESHOLDS.subagentCostShare.normal) return 'normal'; + if (pct < THRESHOLDS.subagentCostShare.high) return 'high'; + if (pct < THRESHOLDS.subagentCostShare.veryHigh) return 'very_high'; + return 'red_flag'; +} + +export function computeCacheEfficiencyAssessment(pct: number): CacheAssessment { + return pct >= THRESHOLDS.cacheEfficiency.good ? 'good' : 'concerning'; +} + +export function computeCacheRatioAssessment(ratio: number): CacheAssessment { + return ratio >= THRESHOLDS.cacheRwRatio.good ? 'good' : 'concerning'; +} + +export function computeToolHealthAssessment(successPct: number): ToolHealthAssessment { + if (successPct > THRESHOLDS.toolSuccess.healthy) return 'healthy'; + if (successPct >= THRESHOLDS.toolSuccess.degraded) return 'degraded'; + return 'unreliable'; +} + +export function computeIdleAssessment(idlePct: number): IdleAssessment { + if (idlePct < THRESHOLDS.idle.efficient) return 'efficient'; + if (idlePct < THRESHOLDS.idle.moderate) return 'moderate'; + return 'high_idle'; +} + +export function computeRedundancyAssessment(readsPerUnique: number): RedundancyAssessment { + return readsPerUnique <= THRESHOLDS.fileReadsPerUnique.normal ? 'normal' : 'wasteful'; +} + +export function computeOverheadAssessment(pctOfTotal: number): OverheadAssessment { + return pctOfTotal <= THRESHOLDS.startupOverhead.normal ? 'normal' : 'heavy'; +} + +export function computeThrashingAssessment(signalCount: number): ThrashingAssessment { + if (signalCount === 0) return 'none'; + if (signalCount <= 2) return 'mild'; + return 'severe'; +} + +export interface ModelMismatch { + description: string; + expectedComplexity: 'mechanical' | 'read_only'; + recommendation: string; +} + +const MECHANICAL_PATTERNS = /\b(rename|move|lint|format|delete|remove|copy|replace)\b/i; +const READ_ONLY_PATTERNS = /\b(explore|search|find|verify|check|scan|discover|list|read)\b/i; + +export function detectModelMismatch(description: string, model: string): ModelMismatch | null { + const isOpus = model.toLowerCase().includes('opus'); + if (!isOpus) return null; + + if (MECHANICAL_PATTERNS.test(description)) { + return { + description, + expectedComplexity: 'mechanical', + recommendation: 'Consider using Haiku for mechanical tasks to reduce cost.', + }; + } + + if (READ_ONLY_PATTERNS.test(description)) { + return { + description, + expectedComplexity: 'read_only', + recommendation: 'Consider using Haiku or Sonnet for read-only exploration tasks.', + }; + } + + return null; +} + +export function detectSwitchPattern( + switches: { from: string; to: string }[] +): SwitchPattern | null { + if (switches.length === 0) return null; + if (switches.length < 2) return 'manual_switch'; + + // Look for Sonnet→Opus→Sonnet pattern (plan mode) + for (let i = 0; i < switches.length - 1; i++) { + const s1 = switches[i]; + const s2 = switches[i + 1]; + if ( + s1.from.toLowerCase().includes('sonnet') && + s1.to.toLowerCase().includes('opus') && + s2.from.toLowerCase().includes('opus') && + s2.to.toLowerCase().includes('sonnet') + ) { + return 'opus_plan_mode'; + } + } + + return 'manual_switch'; +} + +// ============================================================================= +// Key Takeaways +// ============================================================================= + +export interface Takeaway { + severity: Severity; + title: string; + detail: string; + sectionTitle: string; +} + +interface TakeawayReport { + costAnalysis: { + costPerCommitAssessment: string | null; + costPerLineAssessment: string | null; + totalSessionCostUsd: number; + }; + cacheEconomics: { + cacheEfficiencyAssessment: string | null; + cacheEfficiencyPct: number; + }; + toolUsage: { + overallToolHealth: string; + }; + thrashingSignals: { + thrashingAssessment: string; + bashNearDuplicates: unknown[]; + editReworkFiles: unknown[]; + }; + idleAnalysis: { + idleAssessment: string; + idlePct: number; + }; + promptQuality: { + assessment: string; + frictionRate: number; + }; + overview: { + contextAssessment: string | null; + compactionCount: number; + }; + fileReadRedundancy: { + redundancyAssessment: string; + readsPerUniqueFile: number; + }; + testProgression: { + trajectory: string; + }; +} + +export function computeTakeaways(report: TakeawayReport): Takeaway[] { + const items: Takeaway[] = []; + + // Cost red flags + const costSev = assessmentSeverity(report.costAnalysis.costPerCommitAssessment); + if (costSev === 'danger') { + items.push({ + severity: 'danger', + title: 'High cost per commit', + detail: `$${report.costAnalysis.totalSessionCostUsd.toFixed(2)} total \u2014 consider smaller, focused sessions`, + sectionTitle: 'Cost Analysis', + }); + } else if (costSev === 'warning') { + items.push({ + severity: 'warning', + title: 'Elevated cost per commit', + detail: 'Cost per commit is above typical range', + sectionTitle: 'Cost Analysis', + }); + } + + // Cache efficiency + if (report.cacheEconomics.cacheEfficiencyAssessment === 'concerning') { + items.push({ + severity: 'warning', + title: 'Low cache efficiency', + detail: `${report.cacheEconomics.cacheEfficiencyPct}% cache hit rate \u2014 prompt structure may reduce caching`, + sectionTitle: 'Token Usage', + }); + } + + // Tool health + const toolSev = assessmentSeverity(report.toolUsage.overallToolHealth); + if (toolSev === 'danger') { + items.push({ + severity: 'danger', + title: 'Tool reliability issues', + detail: 'Multiple tool calls are failing \u2014 check error section for details', + sectionTitle: 'Tool Usage', + }); + } else if (toolSev === 'warning') { + items.push({ + severity: 'warning', + title: 'Degraded tool health', + detail: 'Some tools have elevated failure rates', + sectionTitle: 'Tool Usage', + }); + } + + // Thrashing + if (report.thrashingSignals.thrashingAssessment === 'severe') { + items.push({ + severity: 'danger', + title: 'Significant thrashing detected', + detail: 'Repeated commands and file rework suggest unclear direction', + sectionTitle: 'Friction Signals', + }); + } else if (report.thrashingSignals.thrashingAssessment === 'mild') { + items.push({ + severity: 'warning', + title: 'Mild thrashing detected', + detail: 'Some repeated commands or file rework occurred', + sectionTitle: 'Friction Signals', + }); + } + + // Idle time + if (report.idleAnalysis.idleAssessment === 'high_idle') { + items.push({ + severity: 'warning', + title: 'High idle time', + detail: `${report.idleAnalysis.idlePct}% of wall-clock time was idle`, + sectionTitle: 'Timeline & Activity', + }); + } + + // Prompt quality + const promptSev = assessmentSeverity(report.promptQuality.assessment); + if (promptSev === 'danger') { + items.push({ + severity: 'danger', + title: 'Prompt quality issues', + detail: `${(report.promptQuality.frictionRate * 100).toFixed(0)}% friction rate \u2014 try more detailed initial prompts`, + sectionTitle: 'Quality Signals', + }); + } + + // Context pressure + if ( + report.overview.contextAssessment === 'critical' || + report.overview.contextAssessment === 'high' + ) { + items.push({ + severity: report.overview.contextAssessment === 'critical' ? 'danger' : 'warning', + title: 'Context window pressure', + detail: `${report.overview.compactionCount} compaction${report.overview.compactionCount !== 1 ? 's' : ''} occurred \u2014 session may lose early context`, + sectionTitle: 'Overview', + }); + } + + // File read redundancy + if (report.fileReadRedundancy.redundancyAssessment === 'wasteful') { + items.push({ + severity: 'warning', + title: 'Redundant file reads', + detail: `${report.fileReadRedundancy.readsPerUniqueFile}x reads per unique file`, + sectionTitle: 'Quality Signals', + }); + } + + // Test regression + if (report.testProgression.trajectory === 'regressing') { + items.push({ + severity: 'danger', + title: 'Tests regressing', + detail: 'Test failures increased over the session', + sectionTitle: 'Quality Signals', + }); + } + + // Sort by severity (danger first), then limit to 4 + const severityOrder: Record = { danger: 0, warning: 1, neutral: 2, good: 3 }; + items.sort((a, b) => severityOrder[a.severity] - severityOrder[b.severity]); + + if (items.length === 0) { + return [ + { + severity: 'good', + title: 'Session looks healthy', + detail: 'No significant issues detected across all metrics', + sectionTitle: 'Overview', + }, + ]; + } + + return items.slice(0, 4); +} diff --git a/src/renderer/utils/sessionAnalyzer.ts b/src/renderer/utils/sessionAnalyzer.ts new file mode 100644 index 00000000..201acc47 --- /dev/null +++ b/src/renderer/utils/sessionAnalyzer.ts @@ -0,0 +1,1419 @@ +/** + * Session analyzer — TypeScript port of scripts/analyze-session.py. + * + * Takes a SessionDetail (already parsed by the main process) and produces + * a SessionReport with structured metrics, cost analysis, friction signals, + * conversation tree analysis, idle gap detection, and more. + * + * Runs entirely in the renderer process — no IPC needed. + */ + +import { + computeCacheEfficiencyAssessment, + computeCacheRatioAssessment, + computeCostPerCommitAssessment, + computeCostPerLineAssessment, + computeIdleAssessment, + computeOverheadAssessment, + computeRedundancyAssessment, + computeSubagentCostShareAssessment, + computeThrashingAssessment, + computeToolHealthAssessment, + detectModelMismatch, + detectSwitchPattern, +} from '@renderer/utils/reportAssessments'; + +import type { + AgentTreeNode, + FrictionCorrection, + GitCommit, + IdleGap, + KeyEvent, + ModelPricing, + ModelSwitch, + ModelTokenStats, + OutOfScopeFindings, + SessionReport, + SkillInvocation, + SubagentBasicEntry, + SubagentEntry, + TestSnapshot, + ThinkingBlockAnalysis, + ToolError, + ToolSuccessRate, + UserQuestion, +} from '@renderer/types/sessionReport'; +import type { + ContentBlock, + ParsedMessage, + Process, + SessionDetail, + TextContent, + ThinkingContent, + ToolCall, +} from '@shared/types'; + +// ============================================================================= +// Pricing Table (USD per 1M tokens) +// ============================================================================= + +const MODEL_PRICING: Record = { + 'opus-4': { + input: 15.0, + output: 75.0, + cache_read: 1.5, + cache_creation: 18.75, + }, + 'sonnet-4': { + input: 3.0, + output: 15.0, + cache_read: 0.3, + cache_creation: 3.75, + }, + 'haiku-4': { + input: 0.8, + output: 4.0, + cache_read: 0.08, + cache_creation: 1.0, + }, + 'opus-3': { + input: 15.0, + output: 75.0, + cache_read: 1.5, + cache_creation: 18.75, + }, + 'sonnet-3': { + input: 3.0, + output: 15.0, + cache_read: 0.3, + cache_creation: 3.75, + }, + 'haiku-3': { + input: 0.25, + output: 1.25, + cache_read: 0.03, + cache_creation: 0.3, + }, +}; + +const DEFAULT_PRICING: ModelPricing = { + input: 3.0, + output: 15.0, + cache_read: 0.3, + cache_creation: 3.75, +}; + +export function getPricing(modelName: string): ModelPricing { + const nameTokens: string[] = modelName.toLowerCase().match(/[a-z0-9]+/g) ?? []; + for (const [key, pricing] of Object.entries(MODEL_PRICING)) { + const keyTokens: string[] = key.match(/[a-z0-9]+/g) ?? []; + if (keyTokens.every((t) => nameTokens.includes(t))) return pricing; + } + return DEFAULT_PRICING; +} + +function costUsd( + modelName: string, + inputTok: number, + outputTok: number, + cacheReadTok: number, + cacheCreationTok: number +): number { + const p = getPricing(modelName); + return ( + (inputTok * p.input + + outputTok * p.output + + cacheReadTok * p.cache_read + + cacheCreationTok * p.cache_creation) / + 1_000_000 + ); +} + +// ============================================================================= +// Helpers +// ============================================================================= + +function isTextBlock(block: ContentBlock): block is TextContent { + return block.type === 'text'; +} + +function isThinkingBlock(block: ContentBlock): block is ThinkingContent { + return block.type === 'thinking'; +} + +function extractTextContent(msg: ParsedMessage): string { + const { content } = msg; + if (typeof content === 'string') return content; + if (Array.isArray(content)) { + return content + .filter(isTextBlock) + .map((block) => block.text) + .join(' '); + } + return ''; +} + +function formatDuration(totalSeconds: number): string { + const h = Math.floor(totalSeconds / 3600); + const m = Math.floor((totalSeconds % 3600) / 60); + const s = Math.floor(totalSeconds % 60); + if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`; + return `${m}:${String(s).padStart(2, '0')}`; +} + +// Friction keyword patterns +const FRICTION_PATTERNS: [RegExp, string][] = [ + [/\bno,/i, 'no,'], + [/\bwrong\b/i, 'wrong'], + [/\bactually\b/i, 'actually'], + [/\bundo\b/i, 'undo'], + [/\brevert\b/i, 'revert'], + [/that's not\b/i, "that's not"], + [/\binstead,/i, 'instead,'], + [/\bwait,/i, 'wait,'], + [/\bnevermind\b/i, 'nevermind'], + [/I don't want\b/i, "I don't want"], +]; + +// Permission denial keywords (case-insensitive substring match) +const PERMISSION_KEYWORDS = [ + 'permission denied', + 'not allowed', + 'requires approval', + 'cannot execute', + 'access denied', + 'operation not permitted', + 'eacces', + 'eperm', + 'user rejected', + 'user denied', + 'needs_user_approval', +]; + +function isPermissionDenial(text: string): boolean { + const lower = text.toLowerCase(); + return PERMISSION_KEYWORDS.some((kw) => lower.includes(kw)); +} + +/** + * Extract a number immediately before a keyword in text. + * E.g., extractNumberBefore("42 passed", "passed") => 42 + */ +function extractNumberBefore(text: string, keyword: string): number | null { + const idx = text.toLowerCase().indexOf(keyword.toLowerCase()); + if (idx <= 0) return null; + const before = text.slice(Math.max(0, idx - 15), idx).trim(); + const parts = before.split(/\s+/); + const numStr = parts[parts.length - 1]; + if (numStr && /^\d+$/.test(numStr)) return parseInt(numStr, 10); + return null; +} + +/** + * Parse test summary from command output. + * Returns [passed, failed] or null if no match. + */ +function parseTestSummary(text: string): [number, number] | null { + // Try "passed"/"failed" keywords — treat missing count as 0 + // (runners often omit "0 failed" when all tests pass) + const passed = extractNumberBefore(text, ' passed'); + const failed = extractNumberBefore(text, ' failed'); + if (passed != null || failed != null) return [passed ?? 0, failed ?? 0]; + + // Try "passing"/"failing" keywords (mocha-style) + const passing = extractNumberBefore(text, ' passing'); + const failing = extractNumberBefore(text, ' failing'); + if (passing != null || failing != null) return [passing ?? 0, failing ?? 0]; + + return null; +} + +// Thinking block analysis signals +const THINKING_SIGNALS: Record = { + alternatives: /\balternative(?:ly|s)?\b|\binstead\b|\bother approach\b|\bcould also\b/i, + uncertainty: /\bnot sure\b|\buncertain\b|\bmight be\b|\bpossibly\b|\bI think\b.*\bbut\b/i, + errors_noticed: /\bbug\b|\berror\b|\bwrong\b|\bincorrect\b|\bfail\b|\bbroken\b/i, + planning: /\bfirst.*then\b|\bstep \d\b|\bplan\b|\bapproach\b|\bstrategy\b/i, + direction_change: /\bwait\b|\bactually\b|\bon second thought\b|\blet me reconsider\b|\bhmm\b/i, +}; + +// "Work" tools (non-Skill) for startup overhead detection +const NON_SKILL_TOOLS = new Set([ + 'Read', + 'Write', + 'Edit', + 'Bash', + 'Grep', + 'Glob', + 'Task', + 'WebFetch', + 'WebSearch', + 'NotebookEdit', +]); + +// ============================================================================= +// Main Analyzer +// ============================================================================= + +export function analyzeSession(detail: SessionDetail): SessionReport { + const { session, messages } = detail; + + // --- Session Overview --- + const timestamps = messages.filter((m) => m.timestamp).map((m) => m.timestamp); + const firstTs = timestamps.length > 0 ? timestamps[0] : null; + const lastTs = timestamps.length > 0 ? timestamps[timestamps.length - 1] : null; + const durationMs = firstTs && lastTs ? lastTs.getTime() - firstTs.getTime() : 0; + const durationSeconds = durationMs / 1000; + + // Context consumption interpretation + const ctxConsumption = session.contextConsumption ?? 0; + let ctxConsumptionPct: number | null = null; + let ctxAssessment: 'critical' | 'high' | 'moderate' | 'healthy' | null = null; + if (ctxConsumption <= 1) { + ctxConsumptionPct = ctxConsumption ? Math.round(ctxConsumption * 1000) / 10 : 0; + if (ctxConsumption > 0.8) ctxAssessment = 'critical'; + else if (ctxConsumption > 0.6) ctxAssessment = 'high'; + else if (ctxConsumption > 0.4) ctxAssessment = 'moderate'; + else ctxAssessment = 'healthy'; + } + + // =================================================================== + // SINGLE-PASS ACCUMULATORS + // =================================================================== + + // Token usage by model + const modelStats = new Map(); + + const getModelStats = (model: string): ModelTokenStats => { + let stats = modelStats.get(model); + if (!stats) { + stats = { + apiCalls: 0, + inputTokens: 0, + outputTokens: 0, + cacheCreation: 0, + cacheRead: 0, + costUsd: 0, + }; + modelStats.set(model, stats); + } + return stats; + }; + + // Cache economics + let totalCacheCreation = 0; + let totalCacheRead = 0; + let coldStartDetected = false; + let firstAssistantWithUsageSeen = false; + + // Message type counts + const typeCounts = new Map(); + + // Tool usage counts + const toolCounts = new Map(); + + // Tool call index: toolUseId -> [messageIndex, toolCall] + const toolCallIndex = new Map(); + + // Tool errors + const errors: ToolError[] = []; + const errorsByTool = new Map(); + + // Permission denials + const permissionDenials: ToolError[] = []; + + // Key events + const keyEvents: KeyEvent[] = []; + + // Thinking blocks + let thinkingCount = 0; + const thinkingAnalysis: ThinkingBlockAnalysis[] = []; + + // Git branches + const branches = new Set(); + + // Friction signals + const corrections: FrictionCorrection[] = []; + let userMessageCount = 0; + + // Thrashing detection + const bashPrefixGroups = new Map(); + const fileEditIndices = new Map(); + + // Startup overhead + let firstWorkToolSeen = false; + let startupMessages = 0; + let startupTokens = 0; + + // Token density timeline + const assistantMsgData: [Date, number][] = []; + + // Conversation tree + const uuidToIdx = new Map(); + const parentMap = new Map(); + let sidechainCount = 0; + const childrenMap = new Map(); + + // Idle gap detection + let lastAssistantTs: Date | null = null; + const idleGaps: IdleGap[] = []; + const IDLE_THRESHOLD_SEC = 60; + + // Model switch detection + let lastModel: string | null = null; + const modelSwitches: ModelSwitch[] = []; + + // Working directory tracking + const cwdSet = new Set(); + const cwdChanges: { from: string; to: string; messageIndex: number }[] = []; + let lastCwd: string | null = null; + + // Test progression + const testSnapshots: TestSnapshot[] = []; + + // Cost tracking + let parentCost = 0; + + // Git activity + const gitCommits: GitCommit[] = []; + let gitPushCount = 0; + const gitBranchCreations: string[] = []; + let linesAddedTotal = 0; + let linesRemovedTotal = 0; + + // File read redundancy + const fileReadCounts = new Map(); + + // First user message length + let firstUserMessageLength = 0; + let firstUserSeen = false; + + // Skills invoked + const skillsInvoked: SkillInvocation[] = []; + + // Bash commands + const bashCmds: string[] = []; + + // Subagents list (backward compat) + const subagentsList: SubagentBasicEntry[] = []; + + // Lifecycle tasks + const lifecycleTasks: string[] = []; + + // User questions + const userQuestions: UserQuestion[] = []; + + // Out-of-scope findings + const OUT_OF_SCOPE_KEYWORDS = [ + 'pre-existing', + 'out of scope', + 'tech debt', + 'anti-pattern', + 'existed before', + ]; + const outOfScopeFindings: OutOfScopeFindings[] = []; + + // Agent tree metadata + const agentTreeNodes: AgentTreeNode[] = []; + + // Compact summary count + + let compactSummaryCount = 0; + + // =================================================================== + // SINGLE PASS + // =================================================================== + + for (let i = 0; i < messages.length; i++) { + const m = messages[i]; + const msgType = m.type ?? 'unknown'; + typeCounts.set(msgType, (typeCounts.get(msgType) ?? 0) + 1); + const msgUuid = m.uuid ?? ''; + const msgParent = m.parentUuid ?? ''; + const msgTs = m.timestamp; + + // --- Conversation tree --- + if (msgUuid) { + uuidToIdx.set(msgUuid, i); + parentMap.set(msgUuid, msgParent || null); + if (msgParent) { + const children = childrenMap.get(msgParent); + if (children) children.push(msgUuid); + else childrenMap.set(msgParent, [msgUuid]); + } + } + + if (m.isSidechain) sidechainCount++; + + // --- Working directory tracking --- + const msgCwd = m.cwd ?? ''; + if (msgCwd) { + cwdSet.add(msgCwd); + if (lastCwd && msgCwd !== lastCwd) { + cwdChanges.push({ from: lastCwd, to: msgCwd, messageIndex: i }); + } + lastCwd = msgCwd; + } + + // --- Token usage, cache economics, and cost --- + // Skip sidechain messages to avoid double-counting (subagent costs are + // accounted for separately via processSubagentCost). + if (m.usage && m.model && !m.isSidechain && m.model !== '') { + const model = m.model; + const u = m.usage; + const inpTok = u.input_tokens ?? 0; + const outTok = u.output_tokens ?? 0; + const cc = u.cache_creation_input_tokens ?? 0; + const cr = u.cache_read_input_tokens ?? 0; + + const stats = getModelStats(model); + stats.apiCalls += 1; + stats.inputTokens += inpTok; + stats.outputTokens += outTok; + stats.cacheCreation += cc; + stats.cacheRead += cr; + + const callCost = costUsd(model, inpTok, outTok, cr, cc); + stats.costUsd += callCost; + parentCost += callCost; + + totalCacheCreation += cc; + totalCacheRead += cr; + + // Cold start detection + if (msgType === 'assistant' && !firstAssistantWithUsageSeen) { + firstAssistantWithUsageSeen = true; + if (cc > 0 && cr === 0) coldStartDetected = true; + } + } + + // --- Git branches --- + if (m.gitBranch) branches.add(m.gitBranch); + + // --- Compact summaries --- + if (m.isCompactSummary) compactSummaryCount++; + + // --- Agent tree metadata --- + if (m.agentId) { + // agentType/teamName/parentToolUseId may exist on raw data but not typed in ParsedMessage + const raw = m as unknown as Record; + agentTreeNodes.push({ + agentId: m.agentId, + agentType: (raw.agentType as string) ?? 'unknown', + teamName: (raw.teamName as string) ?? '', + parentToolUseId: (raw.parentToolUseId as string) ?? '', + messageIndex: i, + }); + } + + // --- Thinking blocks (with content analysis) --- + if (Array.isArray(m.content)) { + for (const block of m.content) { + if (isThinkingBlock(block)) { + thinkingCount++; + const thinkText = block.thinking ?? ''; + const signalsFound: Record = {}; + for (const [signalName, pattern] of Object.entries(THINKING_SIGNALS)) { + if (pattern.test(thinkText)) signalsFound[signalName] = true; + } + if (Object.keys(signalsFound).length > 0 || thinkingCount <= 5) { + thinkingAnalysis.push({ + messageIndex: i, + preview: thinkText.slice(0, 200).replace(/\n/g, ' ').trim(), + charLength: thinkText.length, + signals: signalsFound, + }); + } + } + } + } + + // --- Model switch detection --- + if (msgType === 'assistant' && m.model) { + const currentModel = m.model; + if (lastModel && currentModel !== lastModel) { + modelSwitches.push({ + from: lastModel, + to: currentModel, + messageIndex: i, + timestamp: msgTs ?? null, + }); + } + lastModel = currentModel; + } + + // --- Idle gap detection --- + if (msgType === 'assistant' && msgTs) { + lastAssistantTs = msgTs; + } + if (msgType === 'user' && msgTs && lastAssistantTs) { + const gap = (msgTs.getTime() - lastAssistantTs.getTime()) / 1000; + if (gap > IDLE_THRESHOLD_SEC) { + idleGaps.push({ + gapSeconds: Math.round(gap * 10) / 10, + gapHuman: formatDuration(Math.floor(gap)), + afterMessageIndex: i, + }); + } + } + + // --- First user message length (prompt quality) --- + if (msgType === 'user' && !firstUserSeen && !m.isMeta) { + const contentText = extractTextContent(m); + if (contentText.trim()) { + firstUserMessageLength = contentText.length; + firstUserSeen = true; + } + } + + // --- Tool calls (assistant messages) --- + for (const tc of m.toolCalls) { + const toolName = tc.name; + toolCounts.set(toolName, (toolCounts.get(toolName) ?? 0) + 1); + if (tc.id) toolCallIndex.set(tc.id, [i, tc]); + const inp = tc.input ?? {}; + + // Bash commands + if (toolName === 'Bash') { + const cmd = typeof inp.command === 'string' ? inp.command : ''; + const cmdTrunc = cmd.slice(0, 200); + bashCmds.push(cmdTrunc); + // Thrashing: bash prefix groups + const prefix = cmd.slice(0, 40); + bashPrefixGroups.set(prefix, (bashPrefixGroups.get(prefix) ?? 0) + 1); + + // Git activity + if (cmd.includes('git commit')) { + const heredocMatch = /cat\s+<<['"]?EOF['"]?\n(.+?)(?:\n|$)/.exec(cmd); + let preview: string; + if (heredocMatch) { + preview = heredocMatch[1].trim().slice(0, 80); + } else { + const msgMatch = /-m\s+["'](.+?)["']/.exec(cmd); + preview = msgMatch ? msgMatch[1].slice(0, 80) : cmd.slice(0, 80); + } + gitCommits.push({ messagePreview: preview, messageIndex: i }); + } + if (cmd.includes('git push')) gitPushCount++; + if (cmd.includes('git checkout -b') || cmd.includes('git switch -c')) { + const branchMatch = /git (?:checkout -b|switch -c)\s+(\S+)/.exec(cmd); + if (branchMatch) gitBranchCreations.push(branchMatch[1]); + } + } + + // Skills + if (toolName === 'Skill') { + skillsInvoked.push({ + skill: (inp.skill as string) ?? 'unknown', + argsPreview: (typeof inp.args === 'string' + ? inp.args + : JSON.stringify(inp.args ?? '') + ).slice(0, 120), + }); + } + + // Task (subagent list) + if (toolName === 'Task') { + subagentsList.push({ + description: (inp.description as string) ?? 'unknown', + subagentType: (inp.subagent_type as string) ?? 'unknown', + model: (inp.model as string) ?? 'default (inherits parent)', + runInBackground: (inp.run_in_background as boolean) ?? false, + }); + } + + // TaskCreate + if (toolName === 'TaskCreate') { + lifecycleTasks.push((inp.subject as string) ?? 'unknown'); + } + + // AskUserQuestion + if (toolName === 'AskUserQuestion') { + const questions = inp.questions as { question?: string; options?: { label?: string }[] }[]; + if (Array.isArray(questions)) { + for (const q of questions) { + userQuestions.push({ + question: q.question ?? '', + options: Array.isArray(q.options) ? q.options.map((o) => o.label ?? '') : [], + }); + } + } + } + + // File reads + if (toolName === 'Read') { + const filePath = (inp.file_path as string) ?? ''; + if (filePath) { + fileReadCounts.set(filePath, (fileReadCounts.get(filePath) ?? 0) + 1); + } + } + + // Write/Edit for thrashing + if (toolName === 'Write' || toolName === 'Edit') { + const fp = (inp.file_path as string) ?? ''; + if (fp) { + const indices = fileEditIndices.get(fp); + if (indices) indices.push(i); + else fileEditIndices.set(fp, [i]); + } + } + + // Startup overhead: track first non-Skill tool call + if (!firstWorkToolSeen && NON_SKILL_TOOLS.has(toolName)) { + firstWorkToolSeen = true; + } + } + + // --- Startup overhead: count assistant messages before first work tool --- + if (msgType === 'assistant' && !firstWorkToolSeen) { + startupMessages++; + if (m.usage) { + startupTokens += m.usage.output_tokens ?? 0; + startupTokens += m.usage.input_tokens ?? 0; + startupTokens += m.usage.cache_creation_input_tokens ?? 0; + startupTokens += m.usage.cache_read_input_tokens ?? 0; + } + } + + // --- Token density timeline --- + if (msgType === 'assistant' && msgTs && m.usage) { + const totalMsgTokens = + (m.usage.input_tokens ?? 0) + + (m.usage.output_tokens ?? 0) + + (m.usage.cache_creation_input_tokens ?? 0) + + (m.usage.cache_read_input_tokens ?? 0); + assistantMsgData.push([msgTs, totalMsgTokens]); + } + + // --- Timing / key events --- + if (msgTs) { + let label: string | null = null; + if (msgType === 'user' && typeof m.content === 'string') { + const content = m.content; + if (content.includes('start feature')) { + label = `User: ${content.slice(0, 60)}`; + } else if (content.includes('being continued')) { + label = 'Context compaction/continuation'; + } + } + + for (const tc of m.toolCalls) { + if (tc.name === 'Skill') { + label = `Skill: ${(tc.input.skill as string) ?? ''}`; + } else if (tc.name === 'Task') { + const inpTc = tc.input ?? {}; + label = `Task: ${(inpTc.description as string) ?? ''} (${(inpTc.subagent_type as string) ?? ''})`; + } + } + + if (label) { + keyEvents.push({ timestamp: msgTs, label }); + } + } + + // --- Friction signals (user messages) --- + if (msgType === 'user' && !m.isMeta) { + const contentText = extractTextContent(m); + if (contentText.trim()) { + userMessageCount++; + for (const [regex, keyword] of FRICTION_PATTERNS) { + if (regex.test(contentText)) { + corrections.push({ + messageIndex: i, + keyword, + preview: contentText.slice(0, 120).replace(/\n/g, ' '), + }); + break; + } + } + } + } + + // --- Out-of-scope findings (assistant messages) --- + if (msgType === 'assistant') { + const contentText = extractTextContent(m); + const contentLower = contentText.toLowerCase(); + for (const kw of OUT_OF_SCOPE_KEYWORDS) { + const kwIdx = contentLower.indexOf(kw.toLowerCase()); + if (kwIdx >= 0) { + const start = Math.max(0, kwIdx - 80); + const end = Math.min(contentText.length, kwIdx + 300); + outOfScopeFindings.push({ + keyword: kw, + messageIndex: i, + snippet: contentText.slice(start, end).replace(/\n/g, ' ').trim(), + }); + break; + } + } + } + + // --- Tool results --- + for (const tr of m.toolResults) { + const toolUseId = tr.toolUseId; + if (!toolUseId) continue; + const contentStr = typeof tr.content === 'string' ? tr.content : JSON.stringify(tr.content); + + // Tool errors + if (tr.isError) { + let toolName = 'unknown'; + let toolInput = ''; + const indexed = toolCallIndex.get(toolUseId); + if (indexed) { + const [, tc] = indexed; + toolName = tc.name ?? 'unknown'; + toolInput = JSON.stringify(tc.input ?? {}).slice(0, 300); + } + + const errorEntry: ToolError = { + tool: toolName, + inputPreview: toolInput, + error: contentStr.slice(0, 500), + messageIndex: i, + isPermissionDenial: false, + }; + + if (isPermissionDenial(contentStr)) { + errorEntry.isPermissionDenial = true; + permissionDenials.push(errorEntry); + } + + errors.push(errorEntry); + errorsByTool.set(toolName, (errorsByTool.get(toolName) ?? 0) + 1); + } + + // Bash exit code errors + if ( + !tr.isError && + (contentStr.includes('Exit code 1') || contentStr.includes('Exit code 127')) + ) { + const indexed = toolCallIndex.get(toolUseId); + if (indexed) { + const [, tc] = indexed; + if (tc.name === 'Bash') { + const bashError: ToolError = { + tool: 'Bash (non-zero exit)', + inputPreview: JSON.stringify(tc.input ?? {}).slice(0, 300), + error: contentStr.slice(0, 500), + messageIndex: i, + isPermissionDenial: false, + }; + if (isPermissionDenial(contentStr)) { + bashError.isPermissionDenial = true; + permissionDenials.push(bashError); + } + errors.push(bashError); + errorsByTool.set( + 'Bash (non-zero exit)', + (errorsByTool.get('Bash (non-zero exit)') ?? 0) + 1 + ); + } + } + } + + // --- Test progression: parse test output from bash results --- + const indexedForTest = toolCallIndex.get(toolUseId); + if (indexedForTest) { + const [, tcOrig] = indexedForTest; + if (tcOrig.name === 'Bash') { + const testResult = parseTestSummary(contentStr); + if (testResult) { + const [passed, failed] = testResult; + testSnapshots.push({ + messageIndex: i, + passed, + failed, + total: passed + failed, + raw: contentStr.slice(0, 200).replace(/\n/g, ' '), + }); + } + } + } + + // --- Lines changed: parse git diff --stat output --- + const indexedForDiff = toolCallIndex.get(toolUseId); + if (indexedForDiff) { + const [, tcOrig] = indexedForDiff; + if (tcOrig.name === 'Bash') { + const rawCmd = tcOrig.input?.command; + const cmdText = typeof rawCmd === 'string' ? rawCmd : ''; + if (cmdText.includes('git diff') || cmdText.includes('git show')) { + const insertionIdx = contentStr.indexOf(' insertion'); + const deletionIdx = contentStr.indexOf(' deletion'); + if (insertionIdx > 0) { + const numStr = contentStr + .slice(Math.max(0, insertionIdx - 10), insertionIdx) + .trim() + .split(/\s+/) + .pop(); + if (numStr && /^\d+$/.test(numStr)) linesAddedTotal += parseInt(numStr, 10); + } + if (deletionIdx > 0) { + const numStr = contentStr + .slice(Math.max(0, deletionIdx - 10), deletionIdx) + .trim() + .split(/\s+/) + .pop(); + if (numStr && /^\d+$/.test(numStr)) linesRemovedTotal += parseInt(numStr, 10); + } + } + } + } + } + } + + // =================================================================== + // POST-PASS AGGREGATION + // =================================================================== + + // --- Token usage --- + let totalInputTokens = 0; + let totalOutputTokens = 0; + let totalCacheCreationTokens = 0; + let totalCacheReadTokens = 0; + + const byModel: Record = {}; + for (const [model, stats] of modelStats) { + stats.costUsd = Math.round(stats.costUsd * 10000) / 10000; + byModel[model] = stats; + totalInputTokens += stats.inputTokens; + totalOutputTokens += stats.outputTokens; + totalCacheCreationTokens += stats.cacheCreation; + totalCacheReadTokens += stats.cacheRead; + } + + let grandTotal = + totalInputTokens + totalOutputTokens + totalCacheCreationTokens + totalCacheReadTokens; + + // --- Cost analysis --- + const commitCount = gitCommits.length; + const linesChanged = linesAddedTotal + linesRemovedTotal; + + // --- Subagent metrics from detail.processes --- + const subagentEntries: SubagentEntry[] = detail.processes.map((proc: Process) => { + const desc = proc.description ?? 'unknown'; + // Extract actual model from subagent messages (first assistant message with a model field) + const subagentModel = + proc.messages.find((m: ParsedMessage) => m.type === 'assistant' && m.model)?.model ?? + 'default (inherits parent)'; + // Compute cost from subagent token breakdown (proc.metrics.costUsd is not populated upstream) + const computedCost = costUsd( + subagentModel, + proc.metrics.inputTokens, + proc.metrics.outputTokens, + proc.metrics.cacheReadTokens, + proc.metrics.cacheCreationTokens + ); + return { + description: desc, + subagentType: proc.subagentType ?? 'unknown', + model: subagentModel, + totalTokens: proc.metrics.totalTokens, + totalDurationMs: proc.durationMs, + totalToolUseCount: proc.messages.reduce( + (sum: number, pm: ParsedMessage) => sum + pm.toolCalls.length, + 0 + ), + costUsd: computedCost, + modelMismatch: detectModelMismatch(desc, subagentModel), + }; + }); + + const saFromProcesses = { + count: subagentEntries.length, + totalTokens: subagentEntries.reduce((sum, a) => sum + a.totalTokens, 0), + totalDurationMs: subagentEntries.reduce((sum, a) => sum + a.totalDurationMs, 0), + totalToolUseCount: subagentEntries.reduce((sum, a) => sum + a.totalToolUseCount, 0), + totalCostUsd: + Math.round(subagentEntries.reduce((sum, a) => sum + a.costUsd, 0) * 10000) / 10000, + byAgent: subagentEntries, + }; + + // --- Tool usage with success rates --- + const toolSuccessRates: Record = {}; + const sortedToolCounts = [...toolCounts.entries()].sort((a, b) => b[1] - a[1]); + const countsRecord: Record = {}; + for (const [tool, count] of sortedToolCounts) { + countsRecord[tool] = count; + const errCount = errorsByTool.get(tool) ?? 0; + const successPct = count ? Math.round(((count - errCount) / count) * 1000) / 10 : 0; + toolSuccessRates[tool] = { + totalCalls: count, + errors: errCount, + successRatePct: successPct, + assessment: computeToolHealthAssessment(successPct), + }; + } + + // Overall tool health: worst assessment among tools with >5 calls + const significantTools = Object.values(toolSuccessRates).filter((t) => t.totalCalls > 5); + type THAssessment = 'healthy' | 'degraded' | 'unreliable'; + const overallToolHealth: THAssessment = + significantTools.length > 0 + ? significantTools.reduce((worst, t) => { + const order = { healthy: 0, degraded: 1, unreliable: 2 } as const; + return order[t.assessment] > order[worst] ? t.assessment : worst; + }, 'healthy') + : computeToolHealthAssessment(100); + + // --- Key events timing --- + for (let j = 1; j < keyEvents.length; j++) { + const prevDt = keyEvents[j - 1].timestamp; + const currDt = keyEvents[j].timestamp; + const delta = (currDt.getTime() - prevDt.getTime()) / 1000; + keyEvents[j].deltaSeconds = Math.round(delta * 10) / 10; + keyEvents[j].deltaHuman = formatDuration(Math.floor(delta)); + } + + // --- Thinking blocks signal aggregation --- + const signalTotals: Record = {}; + for (const ta of thinkingAnalysis) { + for (const sig of Object.keys(ta.signals)) { + signalTotals[sig] = (signalTotals[sig] ?? 0) + 1; + } + } + + // --- Cache economics --- + const cacheTotalCreationAndRead = totalCacheCreation + totalCacheRead; + const cacheEfficiency = cacheTotalCreationAndRead + ? Math.round((totalCacheRead / cacheTotalCreationAndRead) * 10000) / 100 + : 0; + const cacheRwRatio = totalCacheCreation + ? Math.round((totalCacheRead / totalCacheCreation) * 10) / 10 + : 0; + + // --- File read redundancy --- + let totalReads = 0; + const redundantFiles: Record = {}; + for (const [path, count] of fileReadCounts) { + totalReads += count; + if (count > 2) redundantFiles[path] = count; + } + const uniqueFiles = fileReadCounts.size; + + // --- Token density timeline --- + const quartiles: { q: number; avgTokens: number; messageCount: number }[] = []; + if (assistantMsgData.length > 0) { + const n = assistantMsgData.length; + const qSize = Math.max(1, Math.floor(n / 4)); + for (let q = 0; q < 4; q++) { + const startIdx = q * qSize; + const endIdx = q === 3 ? n : (q + 1) * qSize; + const chunk = assistantMsgData.slice(startIdx, endIdx); + if (chunk.length > 0) { + const avgTokens = Math.round(chunk.reduce((sum, [, t]) => sum + t, 0) / chunk.length); + quartiles.push({ q: q + 1, avgTokens, messageCount: chunk.length }); + } else { + quartiles.push({ q: q + 1, avgTokens: 0, messageCount: 0 }); + } + } + } else { + for (let q = 0; q < 4; q++) { + quartiles.push({ q: q + 1, avgTokens: 0, messageCount: 0 }); + } + } + + // --- Conversation tree analysis --- + const depthMemo = new Map(); + function getDepth(uuid: string, visited = new Set()): number { + if (depthMemo.has(uuid)) return depthMemo.get(uuid)!; + if (visited.has(uuid)) { + depthMemo.set(uuid, 0); + return 0; + } + visited.add(uuid); + const parent = parentMap.get(uuid); + if (!parent) { + depthMemo.set(uuid, 0); + return 0; + } + const depth = 1 + getDepth(parent, visited); + depthMemo.set(uuid, depth); + return depth; + } + + let maxDepth = 0; + for (const uuid of parentMap.keys()) { + const d = getDepth(uuid); + if (d > maxDepth) maxDepth = d; + } + + // Branch points: parents with multiple children + const branchPoints = new Map(); + for (const [parent, children] of childrenMap) { + if (children.length > 1) branchPoints.set(parent, children); + } + + const branchDetails = [...branchPoints.entries()] + .sort((a, b) => b[1].length - a[1].length) + .slice(0, 10) + .map(([p, c]) => ({ + parentUuid: p.slice(0, 12) + '...', + childCount: c.length, + parentMessageIndex: uuidToIdx.get(p), + })); + + // --- Idle gap analysis --- + const totalIdle = idleGaps.reduce((sum, g) => sum + g.gapSeconds, 0); + const wallClock = durationSeconds; + const activeTime = wallClock > 0 ? wallClock - totalIdle : 0; + + // --- Thrashing signals --- + const bashNearDuplicates = [...bashPrefixGroups.entries()] + .filter(([, count]) => count > 2) + .sort((a, b) => b[1] - a[1]) + .map(([prefix, count]) => ({ prefix, count })); + + const editReworkFiles = [...fileEditIndices.entries()] + .filter(([, indices]) => indices.length >= 3) + .map(([filePath, editIndices]) => ({ filePath, editIndices })); + + // --- Model switches --- + const modelsUsed = + modelSwitches.length > 0 + ? [...new Set([...modelSwitches.map((s) => s.from), ...modelSwitches.map((s) => s.to)])] + : [...modelStats.keys()]; + + // --- Test progression trajectory --- + let trajectory: 'improving' | 'regressing' | 'stable' | 'insufficient_data' = 'insufficient_data'; + if (testSnapshots.length >= 2) { + const first = testSnapshots[0]; + const last = testSnapshots[testSnapshots.length - 1]; + if (last.passed > first.passed) trajectory = 'improving'; + else if (last.passed < first.passed) trajectory = 'regressing'; + else trajectory = 'stable'; + } + + // --- Prompt quality assessment --- + const correctionCount = corrections.length; + const frictionRate = userMessageCount + ? Math.round((correctionCount / userMessageCount) * 10000) / 10000 + : 0; + + type PromptAssessment = + | 'underspecified' + | 'verbose_but_unclear' + | 'well_specified' + | 'moderate_friction'; + + let assessment: PromptAssessment; + let promptNote: string; + + if (firstUserMessageLength < 100 && correctionCount >= 2) { + assessment = 'underspecified'; + promptNote = + 'Short initial prompt with multiple corrections suggests the task needed more upfront specification.'; + } else if (firstUserMessageLength > 500 && correctionCount >= 3) { + assessment = 'verbose_but_unclear'; + promptNote = + 'Initial prompt was detailed but still required corrections — consider restructuring for clarity.'; + } else if (correctionCount <= 1) { + assessment = 'well_specified'; + promptNote = 'Low friction — initial prompt effectively communicated intent.'; + } else { + assessment = 'moderate_friction'; + promptNote = + 'Moderate friction detected — review correction patterns for improvement opportunities.'; + } + + // --- Message types --- + const messageTypes: Record = {}; + for (const [type, count] of typeCounts) { + messageTypes[type] = count; + } + + // --- Subagent cost from processes --- + const processSubagentCost = subagentEntries.reduce((sum, a) => sum + a.costUsd, 0); + const totalCost = parentCost + processSubagentCost; + + // Add aggregated subagent row to costByModel and byModel for the cost table + if (subagentEntries.length > 0 && processSubagentCost > 0) { + const subagentTokenStats: ModelTokenStats = { + apiCalls: 0, + inputTokens: 0, + outputTokens: 0, + cacheCreation: 0, + cacheRead: 0, + costUsd: 0, + }; + for (const proc of detail.processes) { + subagentTokenStats.inputTokens += proc.metrics.inputTokens; + subagentTokenStats.outputTokens += proc.metrics.outputTokens; + subagentTokenStats.cacheCreation += proc.metrics.cacheCreationTokens; + subagentTokenStats.cacheRead += proc.metrics.cacheReadTokens; + // Count assistant messages with usage as API calls + subagentTokenStats.apiCalls += proc.messages.filter( + (m: ParsedMessage) => m.type === 'assistant' && m.usage + ).length; + } + subagentTokenStats.costUsd = Math.round(processSubagentCost * 10000) / 10000; + const subagentLabel = 'Subagents (combined)'; + byModel[subagentLabel] = subagentTokenStats; + modelStats.set(subagentLabel, subagentTokenStats); + + // Update totals to include subagent tokens so the footer row stays consistent + totalInputTokens += subagentTokenStats.inputTokens; + totalOutputTokens += subagentTokenStats.outputTokens; + totalCacheCreationTokens += subagentTokenStats.cacheCreation; + totalCacheReadTokens += subagentTokenStats.cacheRead; + grandTotal = + totalInputTokens + totalOutputTokens + totalCacheCreationTokens + totalCacheReadTokens; + } + + // --- Assessment computations --- + const costPerCommitVal = + commitCount > 0 ? Math.round((totalCost / commitCount) * 10000) / 10000 : null; + const costPerLineVal = + linesChanged > 0 ? Math.round((totalCost / linesChanged) * 1000000) / 1000000 : null; + const subagentCostSharePct = + totalCost > 0 ? Math.round((processSubagentCost / totalCost) * 10000) / 100 : null; + + const readsPerUniqueFile = uniqueFiles ? Math.round((totalReads / uniqueFiles) * 100) / 100 : 0; + const startupPctOfTotal = grandTotal ? Math.round((startupTokens / grandTotal) * 10000) / 100 : 0; + const idlePct = wallClock > 0 ? Math.round((totalIdle / wallClock) * 1000) / 10 : 0; + const thrashingSignalCount = bashNearDuplicates.length + editReworkFiles.length; + + // =================================================================== + // BUILD REPORT + // =================================================================== + + const report: SessionReport = { + overview: { + sessionId: session.id, + projectId: session.projectId ?? 'unknown', + projectPath: session.projectPath ?? 'unknown', + firstMessage: session.firstMessage ?? 'unknown', + messageCount: session.messageCount ?? 0, + hasSubagents: session.hasSubagents ?? false, + contextConsumption: ctxConsumption, + contextConsumptionPct: ctxConsumptionPct, + contextAssessment: ctxAssessment, + compactionCount: session.compactionCount ?? 0, + gitBranch: session.gitBranch ?? 'unknown', + startTime: firstTs, + endTime: lastTs, + durationSeconds, + durationHuman: durationSeconds > 0 ? formatDuration(Math.floor(durationSeconds)) : 'unknown', + totalMessages: messages.length, + }, + + tokenUsage: { + byModel, + totals: { + inputTokens: totalInputTokens, + outputTokens: totalOutputTokens, + cacheCreation: totalCacheCreationTokens, + cacheRead: totalCacheReadTokens, + grandTotal, + cacheReadPct: grandTotal + ? Math.round((totalCacheReadTokens / grandTotal) * 10000) / 100 + : 0, + }, + }, + + costAnalysis: { + parentCostUsd: Math.round(parentCost * 10000) / 10000, + subagentCostUsd: Math.round(processSubagentCost * 10000) / 10000, + totalSessionCostUsd: Math.round(totalCost * 10000) / 10000, + costByModel: Object.fromEntries( + [...modelStats.entries()].map(([model, stats]) => [ + model, + Math.round(stats.costUsd * 10000) / 10000, + ]) + ), + costPerCommit: costPerCommitVal, + costPerLineChanged: costPerLineVal, + costPerCommitAssessment: + costPerCommitVal != null ? computeCostPerCommitAssessment(costPerCommitVal) : null, + costPerLineAssessment: + costPerLineVal != null ? computeCostPerLineAssessment(costPerLineVal) : null, + subagentCostSharePct, + subagentCostShareAssessment: + subagentCostSharePct != null + ? computeSubagentCostShareAssessment(subagentCostSharePct) + : null, + }, + + cacheEconomics: { + cacheRead: totalCacheRead, + cacheEfficiencyPct: cacheEfficiency, + coldStartDetected, + cacheReadToWriteRatio: cacheRwRatio, + cacheEfficiencyAssessment: + cacheTotalCreationAndRead > 0 ? computeCacheEfficiencyAssessment(cacheEfficiency) : null, + cacheRatioAssessment: + totalCacheCreation > 0 ? computeCacheRatioAssessment(cacheRwRatio) : null, + }, + + toolUsage: { + counts: countsRecord, + totalCalls: [...toolCounts.values()].reduce((sum, c) => sum + c, 0), + successRates: toolSuccessRates, + overallToolHealth, + }, + + subagentMetrics: saFromProcesses, + + subagentsList, + + errors: { + errors, + permissionDenials: { + count: permissionDenials.length, + denials: permissionDenials, + affectedTools: [...new Set(permissionDenials.map((d) => d.tool))], + }, + }, + + gitActivity: { + commitCount: gitCommits.length, + commits: gitCommits, + pushCount: gitPushCount, + branchCreations: gitBranchCreations, + linesAdded: linesAddedTotal, + linesRemoved: linesRemovedTotal, + linesChanged, + }, + + frictionSignals: { + correctionCount, + corrections, + frictionRate, + }, + + thrashingSignals: { + bashNearDuplicates, + editReworkFiles, + thrashingAssessment: computeThrashingAssessment(thrashingSignalCount), + }, + + conversationTree: { + totalNodes: uuidToIdx.size, + maxDepth, + sidechainCount, + branchPoints: branchPoints.size, + branchDetails, + }, + + idleAnalysis: { + idleThresholdSeconds: IDLE_THRESHOLD_SEC, + idleGapCount: idleGaps.length, + totalIdleSeconds: Math.round(totalIdle * 10) / 10, + totalIdleHuman: formatDuration(Math.floor(totalIdle)), + wallClockSeconds: Math.round(wallClock * 10) / 10, + activeWorkingSeconds: Math.round(Math.max(activeTime, 0) * 10) / 10, + activeWorkingHuman: formatDuration(Math.floor(Math.max(activeTime, 0))), + idlePct, + longestGaps: [...idleGaps].sort((a, b) => b.gapSeconds - a.gapSeconds).slice(0, 5), + idleAssessment: computeIdleAssessment(idlePct), + }, + + modelSwitches: { + count: modelSwitches.length, + switches: modelSwitches, + modelsUsed, + switchPattern: detectSwitchPattern(modelSwitches), + }, + + workingDirectories: { + uniqueDirectories: [...cwdSet], + directoryCount: cwdSet.size, + changes: cwdChanges, + changeCount: cwdChanges.length, + isMultiDirectory: cwdSet.size > 1, + }, + + testProgression: { + snapshotCount: testSnapshots.length, + snapshots: testSnapshots, + trajectory, + firstSnapshot: testSnapshots.length > 0 ? testSnapshots[0] : null, + lastSnapshot: testSnapshots.length > 0 ? testSnapshots[testSnapshots.length - 1] : null, + }, + + startupOverhead: { + messagesBeforeFirstWork: startupMessages, + tokensBeforeFirstWork: startupTokens, + pctOfTotal: startupPctOfTotal, + overheadAssessment: computeOverheadAssessment(startupPctOfTotal), + }, + + tokenDensityTimeline: { quartiles }, + + promptQuality: { + firstMessageLengthChars: firstUserMessageLength, + userMessageCount, + correctionCount, + frictionRate, + assessment, + note: promptNote, + }, + + thinkingBlocks: { + count: thinkingCount, + analyzedCount: thinkingAnalysis.length, + signalSummary: signalTotals, + notableBlocks: thinkingAnalysis.slice(0, 20), + }, + + keyEvents, + + messageTypes, + + fileReadRedundancy: { + totalReads, + uniqueFiles, + readsPerUniqueFile, + redundantFiles, + redundancyAssessment: computeRedundancyAssessment(readsPerUniqueFile), + }, + + compaction: { + count: session.compactionCount ?? 0, + compactSummaryCount, + note: + (session.compactionCount ?? 0) > 0 + ? 'Session underwent compaction, which may have caused loss of earlier context. Check for repeated work after compaction events.' + : 'No compaction occurred — session stayed within context limits.', + }, + + gitBranches: [...branches], + + skillsInvoked, + + bashCommands: { + total: bashCmds.length, + unique: new Set(bashCmds).size, + repeated: Object.fromEntries( + [ + ...bashCmds + .reduce((acc, cmd) => acc.set(cmd, (acc.get(cmd) ?? 0) + 1), new Map()) + .entries(), + ] + .filter(([, count]) => count > 1) + .sort((a, b) => b[1] - a[1]) + ), + }, + + lifecycleTasks, + + userQuestions, + + outOfScopeFindings, + + agentTree: (() => { + const uniqueAgents = new Map(); + for (const node of agentTreeNodes) { + if (!uniqueAgents.has(node.agentId)) uniqueAgents.set(node.agentId, node); + } + return { + agentCount: uniqueAgents.size, + agents: [...uniqueAgents.values()], + hasTeamMode: agentTreeNodes.some((n) => n.teamName), + teamNames: [...new Set(agentTreeNodes.filter((n) => n.teamName).map((n) => n.teamName))], + }; + })(), + }; + + return report; +} diff --git a/test/renderer/utils/reportAssessments.test.ts b/test/renderer/utils/reportAssessments.test.ts new file mode 100644 index 00000000..b25e08b0 --- /dev/null +++ b/test/renderer/utils/reportAssessments.test.ts @@ -0,0 +1,398 @@ +import { describe, it, expect } from 'vitest'; + +import { + assessmentColor, + assessmentExplanation, + assessmentLabel, + assessmentSeverity, + computeCacheEfficiencyAssessment, + computeCacheRatioAssessment, + computeCostPerCommitAssessment, + computeCostPerLineAssessment, + computeIdleAssessment, + computeOverheadAssessment, + computeRedundancyAssessment, + computeSubagentCostShareAssessment, + computeTakeaways, + computeThrashingAssessment, + computeToolHealthAssessment, + detectModelMismatch, + detectSwitchPattern, + severityColor, + THRESHOLDS, +} from '@renderer/utils/reportAssessments'; + +import type { MetricKey } from '@renderer/utils/reportAssessments'; + +describe('reportAssessments', () => { + describe('severityColor', () => { + it('maps severity to hex color', () => { + expect(severityColor('good')).toBe('#4ade80'); + expect(severityColor('warning')).toBe('#fbbf24'); + expect(severityColor('danger')).toBe('#f87171'); + expect(severityColor('neutral')).toBe('#a1a1aa'); + }); + }); + + describe('assessmentSeverity', () => { + it('maps known assessments to severity', () => { + expect(assessmentSeverity('healthy')).toBe('good'); + expect(assessmentSeverity('efficient')).toBe('good'); + expect(assessmentSeverity('expensive')).toBe('warning'); + expect(assessmentSeverity('red_flag')).toBe('danger'); + expect(assessmentSeverity('very_high')).toBe('danger'); + expect(assessmentSeverity('degraded')).toBe('warning'); + expect(assessmentSeverity('unreliable')).toBe('danger'); + expect(assessmentSeverity('high_idle')).toBe('danger'); + expect(assessmentSeverity('moderate')).toBe('warning'); + }); + + it('returns neutral for null/undefined/unknown', () => { + expect(assessmentSeverity(null)).toBe('neutral'); + expect(assessmentSeverity(undefined)).toBe('neutral'); + expect(assessmentSeverity('unknown_value')).toBe('neutral'); + }); + }); + + describe('assessmentColor', () => { + it('returns correct color for assessment string', () => { + expect(assessmentColor('healthy')).toBe('#4ade80'); + expect(assessmentColor('red_flag')).toBe('#f87171'); + expect(assessmentColor(null)).toBe('#a1a1aa'); + }); + }); + + describe('assessmentLabel', () => { + it('converts snake_case to Title Case', () => { + expect(assessmentLabel('red_flag')).toBe('Red Flag'); + expect(assessmentLabel('well_specified')).toBe('Well Specified'); + expect(assessmentLabel('healthy')).toBe('Healthy'); + expect(assessmentLabel('high_idle')).toBe('High Idle'); + expect(assessmentLabel('opus_plan_mode')).toBe('Opus Plan Mode'); + }); + }); + + describe('computeCostPerCommitAssessment', () => { + it('returns efficient below threshold', () => { + expect(computeCostPerCommitAssessment(0.3)).toBe('efficient'); + }); + it('returns normal in range', () => { + expect(computeCostPerCommitAssessment(1.0)).toBe('normal'); + }); + it('returns expensive in range', () => { + expect(computeCostPerCommitAssessment(3.0)).toBe('expensive'); + }); + it('returns red_flag above threshold', () => { + expect(computeCostPerCommitAssessment(10.0)).toBe('red_flag'); + }); + it('respects threshold boundaries', () => { + expect(computeCostPerCommitAssessment(THRESHOLDS.costPerCommit.efficient - 0.01)).toBe( + 'efficient' + ); + expect(computeCostPerCommitAssessment(THRESHOLDS.costPerCommit.efficient)).toBe('normal'); + }); + }); + + describe('computeCostPerLineAssessment', () => { + it('returns efficient below threshold', () => { + expect(computeCostPerLineAssessment(0.005)).toBe('efficient'); + }); + it('returns red_flag above threshold', () => { + expect(computeCostPerLineAssessment(0.5)).toBe('red_flag'); + }); + }); + + describe('computeSubagentCostShareAssessment', () => { + it('returns normal below 30%', () => { + expect(computeSubagentCostShareAssessment(20)).toBe('normal'); + }); + it('returns high in range', () => { + expect(computeSubagentCostShareAssessment(45)).toBe('high'); + }); + it('returns very_high in range', () => { + expect(computeSubagentCostShareAssessment(70)).toBe('very_high'); + }); + it('returns red_flag above 80%', () => { + expect(computeSubagentCostShareAssessment(90)).toBe('red_flag'); + }); + }); + + describe('computeCacheEfficiencyAssessment', () => { + it('returns good above 95%', () => { + expect(computeCacheEfficiencyAssessment(96)).toBe('good'); + }); + it('returns concerning below 95%', () => { + expect(computeCacheEfficiencyAssessment(90)).toBe('concerning'); + }); + }); + + describe('computeCacheRatioAssessment', () => { + it('returns good above 20', () => { + expect(computeCacheRatioAssessment(25)).toBe('good'); + }); + it('returns concerning below 20', () => { + expect(computeCacheRatioAssessment(10)).toBe('concerning'); + }); + }); + + describe('computeToolHealthAssessment', () => { + it('returns healthy above 95%', () => { + expect(computeToolHealthAssessment(98)).toBe('healthy'); + }); + it('returns degraded between 80-95%', () => { + expect(computeToolHealthAssessment(85)).toBe('degraded'); + }); + it('returns unreliable below 80%', () => { + expect(computeToolHealthAssessment(70)).toBe('unreliable'); + }); + it('boundary: 95 is degraded, 95.1 is healthy', () => { + expect(computeToolHealthAssessment(95)).toBe('degraded'); + expect(computeToolHealthAssessment(95.1)).toBe('healthy'); + }); + }); + + describe('computeIdleAssessment', () => { + it('returns efficient below 20%', () => { + expect(computeIdleAssessment(10)).toBe('efficient'); + }); + it('returns moderate between 20-50%', () => { + expect(computeIdleAssessment(35)).toBe('moderate'); + }); + it('returns high_idle above 50%', () => { + expect(computeIdleAssessment(60)).toBe('high_idle'); + }); + }); + + describe('computeRedundancyAssessment', () => { + it('returns normal at or below 2.0', () => { + expect(computeRedundancyAssessment(1.5)).toBe('normal'); + expect(computeRedundancyAssessment(2.0)).toBe('normal'); + }); + it('returns wasteful above 2.0', () => { + expect(computeRedundancyAssessment(3.0)).toBe('wasteful'); + }); + }); + + describe('computeOverheadAssessment', () => { + it('returns normal at or below 5%', () => { + expect(computeOverheadAssessment(3)).toBe('normal'); + expect(computeOverheadAssessment(5)).toBe('normal'); + }); + it('returns heavy above 5%', () => { + expect(computeOverheadAssessment(10)).toBe('heavy'); + }); + }); + + describe('computeThrashingAssessment', () => { + it('returns none for 0 signals', () => { + expect(computeThrashingAssessment(0)).toBe('none'); + }); + it('returns mild for 1-2 signals', () => { + expect(computeThrashingAssessment(1)).toBe('mild'); + expect(computeThrashingAssessment(2)).toBe('mild'); + }); + it('returns severe for 3+ signals', () => { + expect(computeThrashingAssessment(3)).toBe('severe'); + expect(computeThrashingAssessment(5)).toBe('severe'); + }); + }); + + describe('detectModelMismatch', () => { + it('returns null for non-opus models', () => { + expect(detectModelMismatch('rename files', 'claude-sonnet-4')).toBeNull(); + }); + + it('detects mechanical tasks on opus', () => { + const result = detectModelMismatch('rename all variables', 'claude-opus-4'); + expect(result).not.toBeNull(); + expect(result!.expectedComplexity).toBe('mechanical'); + }); + + it('detects read-only tasks on opus', () => { + const result = detectModelMismatch('explore the codebase', 'claude-opus-4'); + expect(result).not.toBeNull(); + expect(result!.expectedComplexity).toBe('read_only'); + }); + + it('returns null for complex tasks on opus', () => { + expect(detectModelMismatch('implement authentication system', 'claude-opus-4')).toBeNull(); + }); + + it('detects various mechanical keywords', () => { + for (const kw of ['lint', 'format', 'delete', 'move', 'copy', 'replace']) { + expect(detectModelMismatch(`${kw} the code`, 'opus')).not.toBeNull(); + } + }); + + it('detects various read-only keywords', () => { + for (const kw of ['search', 'find', 'verify', 'check', 'scan', 'discover']) { + expect(detectModelMismatch(`${kw} for errors`, 'opus')).not.toBeNull(); + } + }); + }); + + describe('detectSwitchPattern', () => { + it('returns null for no switches', () => { + expect(detectSwitchPattern([])).toBeNull(); + }); + + it('returns manual_switch for single switch', () => { + expect(detectSwitchPattern([{ from: 'claude-sonnet-4', to: 'claude-haiku-4' }])).toBe( + 'manual_switch' + ); + }); + + it('detects opus_plan_mode pattern', () => { + expect( + detectSwitchPattern([ + { from: 'claude-sonnet-4', to: 'claude-opus-4' }, + { from: 'claude-opus-4', to: 'claude-sonnet-4' }, + ]) + ).toBe('opus_plan_mode'); + }); + + it('returns manual_switch for non-plan-mode switches', () => { + expect( + detectSwitchPattern([ + { from: 'claude-sonnet-4', to: 'claude-haiku-4' }, + { from: 'claude-haiku-4', to: 'claude-sonnet-4' }, + ]) + ).toBe('manual_switch'); + }); + }); + + describe('assessmentExplanation', () => { + const ALL_METRIC_ASSESSMENTS: Record = { + costPerCommit: ['efficient', 'normal', 'expensive', 'red_flag'], + costPerLine: ['efficient', 'normal', 'expensive', 'red_flag'], + subagentCostShare: ['normal', 'high', 'very_high', 'red_flag'], + cacheEfficiency: ['good', 'concerning'], + cacheRatio: ['good', 'concerning'], + toolHealth: ['healthy', 'degraded', 'unreliable'], + idle: ['efficient', 'moderate', 'high_idle'], + fileReads: ['normal', 'wasteful'], + startup: ['normal', 'heavy'], + thrashing: ['none', 'mild', 'severe'], + promptQuality: [ + 'well_specified', + 'moderate_friction', + 'underspecified', + 'verbose_but_unclear', + ], + testTrajectory: ['improving', 'stable', 'regressing', 'insufficient_data'], + }; + + it('returns non-empty string for all valid metric/assessment combos', () => { + for (const [metricKey, assessments] of Object.entries(ALL_METRIC_ASSESSMENTS)) { + for (const assessment of assessments) { + const result = assessmentExplanation(metricKey as MetricKey, assessment); + expect(result, `${metricKey}/${assessment}`).not.toBe(''); + } + } + }); + + it('returns empty string for unknown combinations', () => { + expect(assessmentExplanation('costPerCommit', 'unknown_value')).toBe(''); + expect(assessmentExplanation('toolHealth' as MetricKey, 'nonexistent')).toBe(''); + }); + + it('includes threshold values in explanations', () => { + expect(assessmentExplanation('costPerCommit', 'efficient')).toContain( + String(THRESHOLDS.costPerCommit.efficient) + ); + expect(assessmentExplanation('toolHealth', 'healthy')).toContain( + String(THRESHOLDS.toolSuccess.healthy) + ); + }); + }); + + describe('computeTakeaways', () => { + const healthyReport = { + costAnalysis: { + costPerCommitAssessment: 'efficient', + costPerLineAssessment: 'efficient', + totalSessionCostUsd: 0.5, + }, + cacheEconomics: { cacheEfficiencyAssessment: 'good', cacheEfficiencyPct: 97 }, + toolUsage: { overallToolHealth: 'healthy' }, + thrashingSignals: { + thrashingAssessment: 'none', + bashNearDuplicates: [], + editReworkFiles: [], + }, + idleAnalysis: { idleAssessment: 'efficient', idlePct: 10 }, + promptQuality: { assessment: 'well_specified', frictionRate: 0.05 }, + overview: { contextAssessment: 'healthy', compactionCount: 0 }, + fileReadRedundancy: { redundancyAssessment: 'normal', readsPerUniqueFile: 1.5 }, + testProgression: { trajectory: 'improving' }, + }; + + it('returns healthy message when all metrics are good', () => { + const result = computeTakeaways(healthyReport); + expect(result).toHaveLength(1); + expect(result[0].severity).toBe('good'); + expect(result[0].title).toContain('healthy'); + }); + + it('detects cost red flags', () => { + const report = { + ...healthyReport, + costAnalysis: { + ...healthyReport.costAnalysis, + costPerCommitAssessment: 'red_flag', + totalSessionCostUsd: 15, + }, + }; + const result = computeTakeaways(report); + expect(result.some((t) => t.severity === 'danger' && t.title.includes('cost'))).toBe(true); + }); + + it('detects thrashing', () => { + const report = { + ...healthyReport, + thrashingSignals: { + thrashingAssessment: 'severe', + bashNearDuplicates: [{}], + editReworkFiles: [], + }, + }; + const result = computeTakeaways(report); + expect(result.some((t) => t.title.includes('thrashing'))).toBe(true); + }); + + it('limits to 4 takeaways', () => { + const report = { + ...healthyReport, + costAnalysis: { + ...healthyReport.costAnalysis, + costPerCommitAssessment: 'red_flag', + totalSessionCostUsd: 15, + }, + cacheEconomics: { cacheEfficiencyAssessment: 'concerning', cacheEfficiencyPct: 80 }, + toolUsage: { overallToolHealth: 'unreliable' }, + thrashingSignals: { + thrashingAssessment: 'severe', + bashNearDuplicates: [{}], + editReworkFiles: [], + }, + promptQuality: { assessment: 'underspecified', frictionRate: 0.5 }, + overview: { contextAssessment: 'critical', compactionCount: 3 }, + fileReadRedundancy: { redundancyAssessment: 'wasteful', readsPerUniqueFile: 4 }, + testProgression: { trajectory: 'regressing' }, + }; + const result = computeTakeaways(report); + expect(result.length).toBeLessThanOrEqual(4); + }); + + it('sorts danger before warning', () => { + const report = { + ...healthyReport, + cacheEconomics: { cacheEfficiencyAssessment: 'concerning', cacheEfficiencyPct: 80 }, + toolUsage: { overallToolHealth: 'unreliable' }, + }; + const result = computeTakeaways(report); + expect(result.length).toBeGreaterThanOrEqual(2); + expect(result[0].severity).toBe('danger'); + }); + }); +}); diff --git a/test/renderer/utils/sessionAnalyzer.test.ts b/test/renderer/utils/sessionAnalyzer.test.ts new file mode 100644 index 00000000..06064df4 --- /dev/null +++ b/test/renderer/utils/sessionAnalyzer.test.ts @@ -0,0 +1,1509 @@ +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.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[] = [ + 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); + }); + }); + + describe('skills invoked', () => { + it('tracks Skill tool calls', () => { + const messages: ParsedMessage[] = [ + createMockMessage({ + type: 'assistant', + toolCalls: [ + { + id: 'tc-1', + name: 'Skill', + input: { skill: 'brainstorming', args: '--verbose' }, + isTask: false, + }, + { id: 'tc-2', name: 'Skill', input: { skill: 'writing-plans' }, isTask: false }, + ], + }), + ]; + + const report = analyzeSession(createMockDetail({ messages })); + + expect(report.skillsInvoked).toHaveLength(2); + expect(report.skillsInvoked[0].skill).toBe('brainstorming'); + expect(report.skillsInvoked[0].argsPreview).toBe('--verbose'); + expect(report.skillsInvoked[1].skill).toBe('writing-plans'); + }); + }); + + describe('bash commands', () => { + it('tracks total, unique, and repeated commands', () => { + const messages: ParsedMessage[] = [ + createMockMessage({ + type: 'assistant', + toolCalls: [ + { id: 'tc-1', name: 'Bash', input: { command: 'pnpm test' }, isTask: false }, + { id: 'tc-2', name: 'Bash', input: { command: 'pnpm test' }, isTask: false }, + { id: 'tc-3', name: 'Bash', input: { command: 'git status' }, isTask: false }, + ], + }), + ]; + + const report = analyzeSession(createMockDetail({ messages })); + + expect(report.bashCommands.total).toBe(3); + expect(report.bashCommands.unique).toBe(2); + expect(report.bashCommands.repeated['pnpm test']).toBe(2); + }); + }); + + describe('subagents list', () => { + it('tracks Task tool dispatches', () => { + const messages: ParsedMessage[] = [ + createMockMessage({ + type: 'assistant', + toolCalls: [ + { + id: 'tc-1', + name: 'Task', + input: { + description: 'explore auth', + subagent_type: 'Explore', + run_in_background: true, + }, + isTask: true, + }, + ], + }), + ]; + + const report = analyzeSession(createMockDetail({ messages })); + + expect(report.subagentsList).toHaveLength(1); + expect(report.subagentsList[0].description).toBe('explore auth'); + expect(report.subagentsList[0].subagentType).toBe('Explore'); + expect(report.subagentsList[0].runInBackground).toBe(true); + }); + }); + + describe('lifecycle tasks', () => { + it('tracks TaskCreate subjects', () => { + const messages: ParsedMessage[] = [ + createMockMessage({ + type: 'assistant', + toolCalls: [ + { id: 'tc-1', name: 'TaskCreate', input: { subject: 'Add login page' }, isTask: false }, + { id: 'tc-2', name: 'TaskCreate', input: { subject: 'Write tests' }, isTask: false }, + ], + }), + ]; + + const report = analyzeSession(createMockDetail({ messages })); + + expect(report.lifecycleTasks).toEqual(['Add login page', 'Write tests']); + }); + }); + + describe('user questions', () => { + it('tracks AskUserQuestion calls', () => { + const messages: ParsedMessage[] = [ + createMockMessage({ + type: 'assistant', + toolCalls: [ + { + id: 'tc-1', + name: 'AskUserQuestion', + input: { + questions: [ + { + question: 'Which auth method?', + options: [{ label: 'JWT' }, { label: 'OAuth' }], + }, + ], + }, + isTask: false, + }, + ], + }), + ]; + + const report = analyzeSession(createMockDetail({ messages })); + + expect(report.userQuestions).toHaveLength(1); + expect(report.userQuestions[0].question).toBe('Which auth method?'); + expect(report.userQuestions[0].options).toEqual(['JWT', 'OAuth']); + }); + }); + + describe('out-of-scope findings', () => { + it('detects pre-existing and tech debt mentions', () => { + const messages: ParsedMessage[] = [ + createMockMessage({ + type: 'assistant', + content: 'This is a pre-existing issue that was there before our changes.', + }), + createMockMessage({ + type: 'assistant', + content: 'I noticed some tech debt in the authentication module.', + }), + ]; + + const report = analyzeSession(createMockDetail({ messages })); + + expect(report.outOfScopeFindings).toHaveLength(2); + expect(report.outOfScopeFindings[0].keyword).toBe('pre-existing'); + expect(report.outOfScopeFindings[1].keyword).toBe('tech debt'); + }); + }); + + describe('compaction', () => { + it('tracks compaction count and summary messages', () => { + const messages: ParsedMessage[] = [ + createMockMessage({ type: 'assistant', isCompactSummary: true }), + createMockMessage({ type: 'assistant', isCompactSummary: true }), + ]; + + const session = createMockSession(); + session.compactionCount = 2; + const report = analyzeSession(createMockDetail({ messages, session })); + + expect(report.compaction.count).toBe(2); + expect(report.compaction.compactSummaryCount).toBe(2); + expect(report.compaction.note).toContain('underwent compaction'); + }); + + it('reports no compaction', () => { + const report = analyzeSession(createMockDetail({})); + + expect(report.compaction.count).toBe(0); + expect(report.compaction.note).toContain('No compaction'); + }); + }); + + // ------------------------------------------------------------------------- + // Assessment computations + // ------------------------------------------------------------------------- + + describe('cost assessments', () => { + it('computes costPerCommitAssessment when commits exist', () => { + const messages: ParsedMessage[] = [ + createMockMessage({ + type: 'assistant', + model: 'claude-sonnet-4-20250514', + usage: { input_tokens: 50000, output_tokens: 10000 }, + toolCalls: [ + { + id: 'tc-1', + name: 'Bash', + input: { command: "git commit -m 'fix'" }, + isTask: false, + }, + ], + }), + ]; + + const report = analyzeSession(createMockDetail({ messages })); + expect(report.costAnalysis.costPerCommitAssessment).not.toBeNull(); + }); + + it('returns null assessments when no commits', () => { + const report = analyzeSession(createMockDetail()); + expect(report.costAnalysis.costPerCommitAssessment).toBeNull(); + expect(report.costAnalysis.costPerLineAssessment).toBeNull(); + }); + + it('returns null subagentCostShareAssessment when no cost', () => { + const report = analyzeSession(createMockDetail()); + expect(report.costAnalysis.subagentCostSharePct).toBeNull(); + expect(report.costAnalysis.subagentCostShareAssessment).toBeNull(); + }); + }); + + describe('cache assessments', () => { + it('computes cache efficiency assessment', () => { + const messages: ParsedMessage[] = [ + createMockMessage({ + type: 'assistant', + model: 'claude-sonnet-4-20250514', + usage: { + input_tokens: 100, + output_tokens: 50, + cache_creation_input_tokens: 100, + cache_read_input_tokens: 9900, + }, + }), + ]; + + const report = analyzeSession(createMockDetail({ messages })); + expect(report.cacheEconomics.cacheEfficiencyAssessment).toBe('good'); + }); + + it('returns concerning for low cache efficiency', () => { + 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: 500, + }, + }), + ]; + + const report = analyzeSession(createMockDetail({ messages })); + expect(report.cacheEconomics.cacheEfficiencyAssessment).toBe('concerning'); + }); + + it('returns null when no cache data', () => { + const messages: ParsedMessage[] = [ + createMockMessage({ + type: 'assistant', + model: 'claude-sonnet-4-20250514', + usage: { + input_tokens: 100, + output_tokens: 50, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }, + }), + ]; + + const report = analyzeSession(createMockDetail({ messages })); + expect(report.cacheEconomics.cacheEfficiencyAssessment).toBeNull(); + expect(report.cacheEconomics.cacheRatioAssessment).toBeNull(); + }); + }); + + describe('tool health assessments', () => { + it('computes per-tool assessment', () => { + const messages: ParsedMessage[] = [ + createMockMessage({ + type: 'assistant', + toolCalls: [ + { id: 'tc-1', name: 'Read', input: { file_path: '/a.ts' }, isTask: false }, + { id: 'tc-2', name: 'Read', input: { file_path: '/b.ts' }, isTask: false }, + ], + }), + createMockMessage({ + type: 'user', + isMeta: true, + content: [], + toolResults: [ + { toolUseId: 'tc-1', content: 'ok', isError: false }, + { toolUseId: 'tc-2', content: 'ok', isError: false }, + ], + }), + ]; + + const report = analyzeSession(createMockDetail({ messages })); + expect(report.toolUsage.successRates.Read.assessment).toBe('healthy'); + }); + + it('computes overall tool health', () => { + const report = analyzeSession(createMockDetail()); + expect(report.toolUsage.overallToolHealth).toBe('healthy'); + }); + }); + + describe('idle assessment', () => { + it('returns efficient for low idle', () => { + 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.idleAssessment).toBe('efficient'); + }); + + it('returns high_idle for mostly idle session', () => { + const messages: ParsedMessage[] = [ + createMockMessage({ + type: 'assistant', + timestamp: new Date('2024-01-01T10:00:00Z'), + }), + createMockMessage({ + type: 'user', + content: 'back', + timestamp: new Date('2024-01-01T11:00:00Z'), + }), + createMockMessage({ + type: 'assistant', + timestamp: new Date('2024-01-01T11:00:10Z'), + }), + ]; + + const report = analyzeSession(createMockDetail({ messages })); + expect(report.idleAnalysis.idleAssessment).toBe('high_idle'); + }); + }); + + describe('thrashing assessment', () => { + it('returns none when no signals', () => { + const report = analyzeSession(createMockDetail()); + expect(report.thrashingSignals.thrashingAssessment).toBe('none'); + }); + + it('returns mild or severe based on signal count', () => { + const makeEditMsg = (file: string, id: string) => + createMockMessage({ + type: 'assistant', + toolCalls: [{ id, name: 'Edit', input: { file_path: file }, isTask: false }], + }); + + // 3 edits on one file = 1 signal + 3 repeated bash = 1 signal = mild (2) + const messages: ParsedMessage[] = [ + makeEditMsg('/foo.ts', 'e1'), + makeEditMsg('/foo.ts', 'e2'), + makeEditMsg('/foo.ts', 'e3'), + ]; + + const report = analyzeSession(createMockDetail({ messages })); + expect(['mild', 'severe']).toContain(report.thrashingSignals.thrashingAssessment); + }); + }); + + describe('model switch pattern', () => { + it('detects opus_plan_mode', () => { + const messages: ParsedMessage[] = [ + createMockMessage({ + type: 'assistant', + model: 'claude-sonnet-4-20250514', + timestamp: new Date('2024-01-01T10:00:00Z'), + }), + createMockMessage({ + type: 'assistant', + model: 'claude-opus-4-20250514', + timestamp: new Date('2024-01-01T10:01:00Z'), + }), + createMockMessage({ + type: 'assistant', + model: 'claude-sonnet-4-20250514', + timestamp: new Date('2024-01-01T10:02:00Z'), + }), + ]; + + const report = analyzeSession(createMockDetail({ messages })); + expect(report.modelSwitches.switchPattern).toBe('opus_plan_mode'); + }); + + it('returns null when no switches', () => { + const messages: ParsedMessage[] = [ + createMockMessage({ + type: 'assistant', + model: 'claude-sonnet-4-20250514', + usage: { input_tokens: 100, output_tokens: 50 }, + }), + ]; + + const report = analyzeSession(createMockDetail({ messages })); + expect(report.modelSwitches.switchPattern).toBeNull(); + }); + }); + + describe('startup overhead assessment', () => { + it('returns normal for low overhead', () => { + const messages: ParsedMessage[] = [ + createMockMessage({ + type: 'assistant', + model: 'claude-sonnet-4-20250514', + usage: { input_tokens: 100, output_tokens: 50 }, + toolCalls: [{ id: 'tc-1', name: 'Read', input: { file_path: '/f.ts' }, isTask: false }], + }), + ]; + + const report = analyzeSession(createMockDetail({ messages })); + expect(report.startupOverhead.overheadAssessment).toBe('normal'); + }); + + it('returns heavy for high overhead', () => { + const messages: ParsedMessage[] = [ + // Lots of startup tokens, no work tools + createMockMessage({ + type: 'assistant', + model: 'claude-sonnet-4-20250514', + usage: { input_tokens: 50000, output_tokens: 10000 }, + toolCalls: [], + }), + // Small work message + createMockMessage({ + type: 'assistant', + model: 'claude-sonnet-4-20250514', + usage: { input_tokens: 100, output_tokens: 50 }, + toolCalls: [{ id: 'tc-1', name: 'Read', input: { file_path: '/f.ts' }, isTask: false }], + }), + ]; + + const report = analyzeSession(createMockDetail({ messages })); + expect(report.startupOverhead.overheadAssessment).toBe('heavy'); + }); + }); + + describe('file read redundancy assessment', () => { + it('returns normal for low redundancy', () => { + const messages: ParsedMessage[] = [ + createMockMessage({ + type: 'assistant', + toolCalls: [ + { id: 'tc-1', name: 'Read', input: { file_path: '/a.ts' }, isTask: false }, + { id: 'tc-2', name: 'Read', input: { file_path: '/b.ts' }, isTask: false }, + ], + }), + ]; + + const report = analyzeSession(createMockDetail({ messages })); + expect(report.fileReadRedundancy.redundancyAssessment).toBe('normal'); + }); + + it('returns wasteful for high redundancy', () => { + const messages: ParsedMessage[] = [ + createMockMessage({ + type: 'assistant', + toolCalls: [ + { id: 'tc-1', name: 'Read', input: { file_path: '/a.ts' }, isTask: false }, + { id: 'tc-2', name: 'Read', input: { file_path: '/a.ts' }, isTask: false }, + { id: 'tc-3', name: 'Read', input: { file_path: '/a.ts' }, isTask: false }, + { id: 'tc-4', name: 'Read', input: { file_path: '/a.ts' }, isTask: false }, + ], + }), + ]; + + const report = analyzeSession(createMockDetail({ messages })); + expect(report.fileReadRedundancy.redundancyAssessment).toBe('wasteful'); + }); + }); + + describe('model mismatch in subagents', () => { + it('detects mismatch for mechanical tasks on opus', () => { + const processes: Process[] = [ + { + id: 'agent-1', + filePath: '/path/to/agent-1.jsonl', + messages: [], + 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: 'rename all variables', + subagentType: 'code', + isParallel: false, + }, + ]; + + const report = analyzeSession(createMockDetail({ processes })); + // model is 'default (inherits parent)' which doesn't contain 'opus', so no mismatch + expect(report.subagentMetrics.byAgent[0].modelMismatch).toBeNull(); + }); + }); +});