Update CONTRIBUTING.md with new guidelines for PRs and AI-assisted contributions; remove outdated session analysis report and cost calculation design documents.
This commit is contained in:
parent
1233a6f155
commit
8f85b48863
5 changed files with 18 additions and 1489 deletions
|
|
@ -23,10 +23,27 @@ pnpm build
|
|||
```
|
||||
|
||||
## Pull Request Guidelines
|
||||
- Keep changes focused and small.
|
||||
- Keep changes focused and small — one purpose per PR.
|
||||
- Add/adjust tests for behavior changes.
|
||||
- Update docs when changing public behavior or setup.
|
||||
- Use clear PR titles and include a short validation checklist.
|
||||
- **Large changes (new features, new dependencies, large data additions) must have a discussion in an Issue first.** Do not open a large PR without prior agreement on the approach.
|
||||
- Avoid committing large hardcoded data blobs. If data can be fetched at runtime or generated at build time, prefer that approach.
|
||||
|
||||
## AI-Assisted Contributions
|
||||
|
||||
AI coding tools are welcome, but **you are responsible for what you submit**:
|
||||
|
||||
- **Review before submitting.** Read every line of AI-generated code and understand what it does. Do not submit raw, unreviewed AI output.
|
||||
- **Do not commit AI workflow artifacts.** Planning documents, session logs, step-by-step plans, or other outputs from AI tools (e.g. `docs/plans/`, `.speckit/`, etc.) do not belong in the repository.
|
||||
- **Test it yourself.** AI-generated code must be manually verified — run the app, confirm the feature works, check edge cases.
|
||||
- **Keep it intentional.** Every line in your PR should exist for a reason you can explain. If you can't explain why a piece of code is there, remove it.
|
||||
|
||||
## What Does NOT Belong in the Repo
|
||||
- Personal planning/workflow artifacts (AI session plans, task lists, etc.)
|
||||
- Large static data that could be fetched at runtime
|
||||
- Generated files that aren't part of the build output
|
||||
- Experimental features without prior discussion
|
||||
|
||||
## Commit Style
|
||||
- Prefer conventional commits (`feat:`, `fix:`, `chore:`, `docs:`).
|
||||
|
|
|
|||
|
|
@ -1,107 +0,0 @@
|
|||
# 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 |
|
||||
|
|
@ -1,875 +0,0 @@
|
|||
# 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<string, ModelTokenStats>;
|
||||
totals: TokenTotals;
|
||||
}
|
||||
|
||||
export interface ReportCostAnalysis {
|
||||
parentCostUsd: number;
|
||||
subagentCostUsd: number;
|
||||
totalSessionCostUsd: number;
|
||||
costByModel: Record<string, number>;
|
||||
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<string, number>;
|
||||
totalCalls: number;
|
||||
successRates: Record<string, ToolSuccessRate>;
|
||||
}
|
||||
|
||||
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<string, boolean>;
|
||||
}
|
||||
|
||||
export interface ReportThinkingBlocks {
|
||||
count: number;
|
||||
analyzedCount: number;
|
||||
signalSummary: Record<string, number>;
|
||||
notableBlocks: ThinkingBlockAnalysis[];
|
||||
}
|
||||
|
||||
export interface KeyEvent {
|
||||
timestamp: Date;
|
||||
label: string;
|
||||
deltaSeconds?: number;
|
||||
deltaHuman?: string;
|
||||
}
|
||||
|
||||
export interface ReportFileReadRedundancy {
|
||||
totalReads: number;
|
||||
uniqueFiles: number;
|
||||
readsPerUniqueFile: number;
|
||||
redundantFiles: Record<string, number>;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 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<string, number>;
|
||||
serviceTiers: Record<string, number>;
|
||||
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 `<usage>` 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> = {}): 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> = {}): 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 `<table>` 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 <div>No session data available. Open the session first.</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-y-auto p-6" style={{ backgroundColor: 'var(--color-surface)' }}>
|
||||
<h1 className="mb-6 text-lg font-semibold" style={{ color: 'var(--color-text)' }}>
|
||||
Session Analysis Report
|
||||
</h1>
|
||||
<div className="flex flex-col gap-4">
|
||||
<OverviewSection data={report.overview} />
|
||||
<CostSection data={report.costAnalysis} />
|
||||
<TokenSection data={report.tokenUsage} cacheEconomics={report.cacheEconomics} />
|
||||
<ToolSection data={report.toolUsage} />
|
||||
<SubagentSection data={report.subagentMetrics} />
|
||||
<ErrorSection data={report.errors} />
|
||||
<GitSection data={report.gitActivity} />
|
||||
<FrictionSection data={report.frictionSignals} thrashing={report.thrashingSignals} />
|
||||
<TimelineSection idle={report.idleAnalysis} modelSwitches={report.modelSwitches} keyEvents={report.keyEvents} />
|
||||
<QualitySection prompt={report.promptQuality} startup={report.startupOverhead} testProgression={report.testProgression} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
**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' && <SessionReportTab tab={tab} />}
|
||||
```
|
||||
|
||||
**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 && (
|
||||
<button
|
||||
onClick={() => openSessionReport(activeTabId)}
|
||||
onMouseEnter={() => setAnalyzeHover(true)}
|
||||
onMouseLeave={() => setAnalyzeHover(false)}
|
||||
className="rounded-md p-2 transition-colors"
|
||||
style={{
|
||||
color: analyzeHover ? 'var(--color-text)' : 'var(--color-text-muted)',
|
||||
backgroundColor: analyzeHover ? 'var(--color-surface-raised)' : 'transparent',
|
||||
}}
|
||||
title="Analyze Session"
|
||||
>
|
||||
<Activity className="size-4" />
|
||||
</button>
|
||||
)}
|
||||
```
|
||||
|
||||
**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"
|
||||
```
|
||||
|
|
@ -1,71 +0,0 @@
|
|||
# Unify Cost Calculation — Design Document
|
||||
|
||||
**Date:** 2026-02-23
|
||||
**Branch:** `feat/unify-cost-calculation`
|
||||
**Related:** PR #60 (Session Analysis Report), PR #65 (Cost Calculation Metric), Issue #72 (Plan Usage Tracking)
|
||||
|
||||
## Problem
|
||||
|
||||
Cost calculation exists in two places with different pricing data and logic:
|
||||
|
||||
1. **Main process** (`src/main/utils/jsonl.ts`): Uses LiteLLM-sourced `pricing.json` (206 models, tiered 200k-token pricing). Populates `SessionMetrics.costUsd` for the chat UI.
|
||||
2. **Renderer** (`src/renderer/utils/sessionAnalyzer.ts`): Uses a hardcoded 6-model pricing table with no tiered pricing. Generates per-model cost breakdown for the Session Report.
|
||||
|
||||
The two systems can produce different cost numbers for the same session and will drift further as models change.
|
||||
|
||||
## Solution
|
||||
|
||||
Create a single shared pricing module that both processes import.
|
||||
|
||||
### New Module: `src/shared/utils/pricing.ts`
|
||||
|
||||
**Exports:**
|
||||
|
||||
| Export | Description |
|
||||
|--------|-------------|
|
||||
| `ModelPricing` | Interface for per-model rates (input, output, cache read, cache creation, plus tiered variants) |
|
||||
| `getPricing(modelName: string): ModelPricing \| null` | Model lookup: exact match, lowercase, case-insensitive scan |
|
||||
| `calculateTieredCost(tokens, baseRate, tieredRate?): number` | Applies 200k-token tier threshold |
|
||||
| `calculateMessageCost(model, inputTokens, outputTokens, cacheReadTokens, cacheCreationTokens): number` | Computes cost for a single API call |
|
||||
|
||||
**Pricing data:** Static `import pricingData from '../../../resources/pricing.json'` with `resolveJsonModule: true`. Replaces `fs.readFileSync` runtime loading.
|
||||
|
||||
### Consumer Changes
|
||||
|
||||
**`src/main/utils/jsonl.ts`:**
|
||||
- Remove: `ModelPricing` interface, `loadPricingData()`, `calculateTieredCost()`, `getPricing()`, `fs`/`path` imports
|
||||
- Keep: `calculateMetrics()` function
|
||||
- Change: inline cost loop body → call `calculateMessageCost()` from `@shared/utils/pricing`
|
||||
|
||||
**`src/renderer/utils/sessionAnalyzer.ts`:**
|
||||
- Remove: `MODEL_PRICING` table (~40 lines), `DEFAULT_PRICING`, local `getPricing()`, local `costUsd()`
|
||||
- Change: calls at lines 476 and 900 → `calculateMessageCost()` from `@shared/utils/pricing`
|
||||
|
||||
**Tests:**
|
||||
- `test/main/utils/costCalculation.test.ts` → update to test shared module functions
|
||||
- `test/renderer/utils/sessionAnalyzer.test.ts` → mock `@shared/utils/pricing` instead of local functions
|
||||
- New `test/shared/utils/pricing.test.ts` for the shared module
|
||||
|
||||
### Pricing JSON Import Strategy
|
||||
|
||||
- `pricing.json` stays in `resources/` for Electron's `extraResources` packaging
|
||||
- Both Vite (renderer) and electron-vite (main) resolve the JSON import at compile time
|
||||
- Remove the `fs.readFileSync` dev/prod path logic from `jsonl.ts`
|
||||
|
||||
### Fallback Behavior
|
||||
|
||||
- `getPricing()` returns `null` for unknown models
|
||||
- `calculateMessageCost()` returns `0` for unknown models (matches current `jsonl.ts` behavior)
|
||||
- Session analyzer callers can apply a default if needed
|
||||
|
||||
### What Changes for Users
|
||||
|
||||
- Report costs become more accurate (tiered pricing, 206 models instead of 6)
|
||||
- Cost numbers between chat view and Session Report now agree exactly
|
||||
- Small UI change: Visible Context header adds a "parent only · view full cost" action when available
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Plan usage tracking (see Issue #72 — pending community feedback)
|
||||
- New UI surfaces for cost display
|
||||
- Changes to the `costFormatting.ts` shared utility
|
||||
|
|
@ -1,435 +0,0 @@
|
|||
# Unify Cost Calculation Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Replace dual cost calculation systems with a single shared pricing module used by both main and renderer processes.
|
||||
|
||||
**Architecture:** Create `src/shared/utils/pricing.ts` that statically imports `resources/pricing.json` and exports all pricing functions. Both `jsonl.ts` (main) and `sessionAnalyzer.ts` (renderer) consume this module instead of maintaining their own pricing logic.
|
||||
|
||||
**Tech Stack:** TypeScript, Vitest, electron-vite (resolveJsonModule)
|
||||
|
||||
---
|
||||
|
||||
## Tasks
|
||||
|
||||
### Task 1: Create the shared pricing module with tests
|
||||
|
||||
**Files:**
|
||||
- Create: `src/shared/utils/pricing.ts`
|
||||
- Test: `test/shared/utils/pricing.test.ts`
|
||||
|
||||
**Step 1: Write the failing tests**
|
||||
|
||||
Create `test/shared/utils/pricing.test.ts`:
|
||||
|
||||
```typescript
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
getPricing,
|
||||
calculateTieredCost,
|
||||
calculateMessageCost,
|
||||
getDisplayPricing,
|
||||
} from '@shared/utils/pricing';
|
||||
|
||||
describe('Shared Pricing Module', () => {
|
||||
describe('getPricing', () => {
|
||||
it('should find pricing by exact model name', () => {
|
||||
// Use a model known to exist in pricing.json
|
||||
const pricing = getPricing('claude-3-5-sonnet-20241022');
|
||||
expect(pricing).not.toBeNull();
|
||||
expect(pricing!.input_cost_per_token).toBeGreaterThan(0);
|
||||
expect(pricing!.output_cost_per_token).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should find pricing case-insensitively', () => {
|
||||
const pricing = getPricing('Claude-3-5-Sonnet-20241022');
|
||||
expect(pricing).not.toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for unknown models', () => {
|
||||
const pricing = getPricing('totally-fake-model-xyz');
|
||||
expect(pricing).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateTieredCost', () => {
|
||||
it('should use base rate for tokens below 200k', () => {
|
||||
const cost = calculateTieredCost(100_000, 0.000003);
|
||||
expect(cost).toBeCloseTo(0.3, 6);
|
||||
});
|
||||
|
||||
it('should apply tiered rate above 200k', () => {
|
||||
const cost = calculateTieredCost(250_000, 0.000003, 0.000006);
|
||||
// (200000 * 0.000003) + (50000 * 0.000006) = 0.6 + 0.3 = 0.9
|
||||
expect(cost).toBeCloseTo(0.9, 6);
|
||||
});
|
||||
|
||||
it('should use base rate when no tiered rate provided', () => {
|
||||
const cost = calculateTieredCost(250_000, 0.000015);
|
||||
expect(cost).toBeCloseTo(3.75, 6);
|
||||
});
|
||||
|
||||
it('should return 0 for zero or negative tokens', () => {
|
||||
expect(calculateTieredCost(0, 0.000003)).toBe(0);
|
||||
expect(calculateTieredCost(-100, 0.000003)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateMessageCost', () => {
|
||||
it('should compute cost for a known model', () => {
|
||||
// claude-3-5-sonnet-20241022: input=0.000003, output=0.000015
|
||||
const cost = calculateMessageCost('claude-3-5-sonnet-20241022', 1000, 500, 0, 0);
|
||||
// (1000 * 0.000003) + (500 * 0.000015) = 0.003 + 0.0075 = 0.0105
|
||||
expect(cost).toBeCloseTo(0.0105, 6);
|
||||
});
|
||||
|
||||
it('should return 0 for unknown models', () => {
|
||||
const cost = calculateMessageCost('unknown-model', 1000, 500, 0, 0);
|
||||
expect(cost).toBe(0);
|
||||
});
|
||||
|
||||
it('should include cache token costs', () => {
|
||||
const cost = calculateMessageCost('claude-3-5-sonnet-20241022', 1000, 500, 300, 200);
|
||||
expect(cost).toBeGreaterThan(0.0105); // more than just input+output
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDisplayPricing', () => {
|
||||
it('should return per-million rates for a known model', () => {
|
||||
const dp = getDisplayPricing('claude-3-5-sonnet-20241022');
|
||||
expect(dp).not.toBeNull();
|
||||
expect(dp!.input).toBeCloseTo(3.0, 1); // $3/M input
|
||||
expect(dp!.output).toBeCloseTo(15.0, 1); // $15/M output
|
||||
});
|
||||
|
||||
it('should return null for unknown models', () => {
|
||||
expect(getDisplayPricing('unknown-model')).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
**Step 2: Run tests to verify they fail**
|
||||
|
||||
Run: `pnpm vitest run test/shared/utils/pricing.test.ts`
|
||||
Expected: FAIL — module does not exist
|
||||
|
||||
**Step 3: Create the shared pricing module**
|
||||
|
||||
Create `src/shared/utils/pricing.ts`:
|
||||
|
||||
```typescript
|
||||
import pricingData from '../../../resources/pricing.json';
|
||||
|
||||
export interface LiteLLMPricing {
|
||||
input_cost_per_token: number;
|
||||
output_cost_per_token: number;
|
||||
cache_creation_input_token_cost?: number;
|
||||
cache_read_input_token_cost?: number;
|
||||
input_cost_per_token_above_200k_tokens?: number;
|
||||
output_cost_per_token_above_200k_tokens?: number;
|
||||
cache_creation_input_token_cost_above_200k_tokens?: number;
|
||||
cache_read_input_token_cost_above_200k_tokens?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface DisplayPricing {
|
||||
input: number;
|
||||
output: number;
|
||||
cache_read: number;
|
||||
cache_creation: number;
|
||||
}
|
||||
|
||||
const TIER_THRESHOLD = 200_000;
|
||||
|
||||
const pricingMap = pricingData as Record<string, unknown>;
|
||||
|
||||
function tryGetPricing(key: string): LiteLLMPricing | null {
|
||||
const entry = pricingMap[key];
|
||||
if (
|
||||
entry &&
|
||||
typeof entry === 'object' &&
|
||||
'input_cost_per_token' in entry &&
|
||||
'output_cost_per_token' in entry
|
||||
) {
|
||||
return entry as LiteLLMPricing;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getPricing(modelName: string): LiteLLMPricing | null {
|
||||
const exact = tryGetPricing(modelName);
|
||||
if (exact) return exact;
|
||||
|
||||
const lowerName = modelName.toLowerCase();
|
||||
const lower = tryGetPricing(lowerName);
|
||||
if (lower) return lower;
|
||||
|
||||
for (const key of Object.keys(pricingMap)) {
|
||||
if (key.toLowerCase() === lowerName) {
|
||||
const match = tryGetPricing(key);
|
||||
if (match) return match;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function calculateTieredCost(
|
||||
tokens: number,
|
||||
baseRate: number,
|
||||
tieredRate?: number
|
||||
): number {
|
||||
if (tokens <= 0) return 0;
|
||||
if (!tieredRate || tokens <= TIER_THRESHOLD) {
|
||||
return tokens * baseRate;
|
||||
}
|
||||
const costBelow = TIER_THRESHOLD * baseRate;
|
||||
const costAbove = (tokens - TIER_THRESHOLD) * tieredRate;
|
||||
return costBelow + costAbove;
|
||||
}
|
||||
|
||||
export function calculateMessageCost(
|
||||
modelName: string,
|
||||
inputTokens: number,
|
||||
outputTokens: number,
|
||||
cacheReadTokens: number,
|
||||
cacheCreationTokens: number
|
||||
): number {
|
||||
const pricing = getPricing(modelName);
|
||||
if (!pricing) return 0;
|
||||
|
||||
const inputCost = calculateTieredCost(
|
||||
inputTokens,
|
||||
pricing.input_cost_per_token,
|
||||
pricing.input_cost_per_token_above_200k_tokens
|
||||
);
|
||||
const outputCost = calculateTieredCost(
|
||||
outputTokens,
|
||||
pricing.output_cost_per_token,
|
||||
pricing.output_cost_per_token_above_200k_tokens
|
||||
);
|
||||
const cacheCreationCost = calculateTieredCost(
|
||||
cacheCreationTokens,
|
||||
pricing.cache_creation_input_token_cost ?? 0,
|
||||
pricing.cache_creation_input_token_cost_above_200k_tokens
|
||||
);
|
||||
const cacheReadCost = calculateTieredCost(
|
||||
cacheReadTokens,
|
||||
pricing.cache_read_input_token_cost ?? 0,
|
||||
pricing.cache_read_input_token_cost_above_200k_tokens
|
||||
);
|
||||
|
||||
return inputCost + outputCost + cacheCreationCost + cacheReadCost;
|
||||
}
|
||||
|
||||
export function getDisplayPricing(modelName: string): DisplayPricing | null {
|
||||
const pricing = getPricing(modelName);
|
||||
if (!pricing) return null;
|
||||
|
||||
return {
|
||||
input: pricing.input_cost_per_token * 1_000_000,
|
||||
output: pricing.output_cost_per_token * 1_000_000,
|
||||
cache_read: (pricing.cache_read_input_token_cost ?? 0) * 1_000_000,
|
||||
cache_creation: (pricing.cache_creation_input_token_cost ?? 0) * 1_000_000,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
**Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `pnpm vitest run test/shared/utils/pricing.test.ts`
|
||||
Expected: PASS
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/shared/utils/pricing.ts test/shared/utils/pricing.test.ts
|
||||
git commit -m "feat: add shared pricing module with LiteLLM data"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Wire jsonl.ts to use the shared pricing module
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/main/utils/jsonl.ts:219-400` (remove pricing functions, update calculateMetrics)
|
||||
- Test: `test/main/utils/costCalculation.test.ts` (update to remove fs mocking)
|
||||
|
||||
**Step 1: Update the cost calculation test to use static imports**
|
||||
|
||||
The existing tests mock `fs.readFileSync` to provide pricing data. Since the shared module uses a static JSON import, the tests should instead test against real pricing data or mock the shared module.
|
||||
|
||||
Update `test/main/utils/costCalculation.test.ts`:
|
||||
- Remove `import * as fs from 'fs'` and `vi.mock('fs')`
|
||||
- Remove `mockPricingData` and the `beforeEach` that mocks `fs.readFileSync`
|
||||
- Update model names in tests to match models that exist in `resources/pricing.json` (the existing `claude-3-5-sonnet-20241022` and `claude-3-opus-20240229` should already be there)
|
||||
- Update expected cost values to match the actual rates from `pricing.json` (verify they match the existing mock data — they should be identical since the mock was based on real rates)
|
||||
- Remove the "pricing data load failure" test (line 409-449) — there's no runtime file loading to fail anymore
|
||||
- Keep all other test cases and assertions as-is
|
||||
|
||||
**Step 2: Run updated tests to verify they fail**
|
||||
|
||||
Run: `pnpm vitest run test/main/utils/costCalculation.test.ts`
|
||||
Expected: FAIL — jsonl.ts still has old imports
|
||||
|
||||
**Step 3: Update jsonl.ts**
|
||||
|
||||
In `src/main/utils/jsonl.ts`:
|
||||
- Remove lines 219-320: the `fs`/`path` imports, `ModelPricing` interface, `TIER_THRESHOLD`, `pricingCache`, `loadPricingData()`, `calculateTieredCost()`, `getPricing()`
|
||||
- Add import at top of file: `import { calculateMessageCost } from '@shared/utils/pricing';`
|
||||
- In `calculateMetrics()` (lines 354-400), replace the inline cost calculation block (lines 374-398) with:
|
||||
|
||||
```typescript
|
||||
if (msg.model) {
|
||||
costUsd += calculateMessageCost(
|
||||
msg.model,
|
||||
msgInputTokens,
|
||||
msgOutputTokens,
|
||||
msgCacheReadTokens,
|
||||
msgCacheCreationTokens
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
- Remove the unused `modelName` variable (line 338) and the block that sets it (lines 370-372)
|
||||
|
||||
**Step 4: Run tests to verify they pass**
|
||||
|
||||
Run: `pnpm vitest run test/main/utils/costCalculation.test.ts`
|
||||
Expected: PASS
|
||||
|
||||
**Step 5: Run full test suite to check for regressions**
|
||||
|
||||
Run: `pnpm test`
|
||||
Expected: All tests pass
|
||||
|
||||
**Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/main/utils/jsonl.ts test/main/utils/costCalculation.test.ts
|
||||
git commit -m "refactor: wire jsonl.ts to shared pricing module"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Wire sessionAnalyzer.ts and CostSection.tsx to use the shared pricing module
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/renderer/utils/sessionAnalyzer.ts:32,60-130` (remove local pricing)
|
||||
- Modify: `src/renderer/types/sessionReport.ts:23-28` (update ModelPricing type)
|
||||
- Modify: `src/renderer/components/report/sections/CostSection.tsx:3,10,39-46,204` (update imports and usage)
|
||||
|
||||
**Step 1: Run existing session analyzer tests as baseline**
|
||||
|
||||
Run: `pnpm vitest run test/renderer/utils/sessionAnalyzer.test.ts`
|
||||
Expected: PASS (baseline)
|
||||
|
||||
**Step 2: Update sessionAnalyzer.ts**
|
||||
|
||||
In `src/renderer/utils/sessionAnalyzer.ts`:
|
||||
- Remove the `ModelPricing` import from `@renderer/types/sessionReport` (line 32)
|
||||
- Remove lines 60-130: `MODEL_PRICING` table, `DEFAULT_PRICING`, `getPricing()`, `costUsd()`
|
||||
- Add import: `import { calculateMessageCost, getDisplayPricing } from '@shared/utils/pricing';`
|
||||
- Export `getDisplayPricing` as `getPricing` for backward compat with CostSection: `export { getDisplayPricing as getPricing } from '@shared/utils/pricing';`
|
||||
- Replace `costUsd(model, inpTok, outTok, cr, cc)` at line 476 with `calculateMessageCost(model, inpTok, outTok, cr, cc)`
|
||||
- Replace `costUsd(subagentModel, ...)` at line 900 with `calculateMessageCost(subagentModel, proc.metrics.inputTokens, proc.metrics.outputTokens, proc.metrics.cacheReadTokens, proc.metrics.cacheCreationTokens)`
|
||||
|
||||
**Step 3: Update sessionReport.ts ModelPricing type**
|
||||
|
||||
In `src/renderer/types/sessionReport.ts`:
|
||||
- Replace the existing `ModelPricing` interface (lines 23-28) with a re-export from the shared module:
|
||||
|
||||
```typescript
|
||||
export type { DisplayPricing as ModelPricing } from '@shared/utils/pricing';
|
||||
```
|
||||
|
||||
This keeps backward compatibility — `CostSection.tsx` imports `ModelPricing` from here and expects `{ input, output, cache_read, cache_creation }` which matches `DisplayPricing`.
|
||||
|
||||
**Step 4: Update CostSection.tsx**
|
||||
|
||||
In `src/renderer/components/report/sections/CostSection.tsx`:
|
||||
- Line 3: Change `import { getPricing } from '@renderer/utils/sessionAnalyzer'` to `import { getPricing } from '@renderer/utils/sessionAnalyzer'` — no change needed if we re-export from sessionAnalyzer. Verify the import still resolves.
|
||||
- The `ModelPricing` import from `@renderer/types/sessionReport` (line 10) continues to work via the re-export.
|
||||
- The `CostBreakdownCard` (lines 34-46) uses `pricing.input`, `pricing.output`, etc. as per-million rates — this matches `DisplayPricing` from `getDisplayPricing()`.
|
||||
|
||||
**Step 5: Run session analyzer tests**
|
||||
|
||||
Run: `pnpm vitest run test/renderer/utils/sessionAnalyzer.test.ts`
|
||||
Expected: PASS
|
||||
|
||||
**Step 6: Run full test suite**
|
||||
|
||||
Run: `pnpm test`
|
||||
Expected: All tests pass
|
||||
|
||||
**Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add src/renderer/utils/sessionAnalyzer.ts src/renderer/types/sessionReport.ts src/renderer/components/report/sections/CostSection.tsx
|
||||
git commit -m "refactor: wire session analyzer and CostSection to shared pricing"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Typecheck, lint, and verify the app runs
|
||||
|
||||
**Files:**
|
||||
- No new files — verification only
|
||||
|
||||
**Step 1: Run typecheck**
|
||||
|
||||
Run: `pnpm typecheck`
|
||||
Expected: No errors
|
||||
|
||||
**Step 2: Run linter**
|
||||
|
||||
Run: `pnpm lint:fix`
|
||||
Expected: Clean or auto-fixed
|
||||
|
||||
**Step 3: Run full test suite**
|
||||
|
||||
Run: `pnpm test`
|
||||
Expected: All tests pass
|
||||
|
||||
**Step 4: Run the app and verify cost display**
|
||||
|
||||
Run: `pnpm dev`
|
||||
- Open a session with known token usage
|
||||
- Verify `TokenUsageDisplay` shows cost in the chat view
|
||||
- Open the Session Report tab and verify cost-by-model breakdown renders
|
||||
- Verify CostBreakdownCard expands with per-token-type rates
|
||||
- Confirm chat view cost and report cost show the same total
|
||||
|
||||
**Step 5: Commit any fixes from verification**
|
||||
|
||||
If any fixes were needed, commit them:
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "fix: address typecheck/lint issues from cost unification"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Clean up dead code from package.json extraResources
|
||||
|
||||
**Files:**
|
||||
- Modify: `package.json` (optional — evaluate if `extraResources` for pricing.json is still needed)
|
||||
|
||||
**Step 1: Check if pricing.json is still loaded at runtime anywhere**
|
||||
|
||||
Search for any remaining `fs.readFileSync` or runtime references to `pricing.json`:
|
||||
|
||||
Run: `grep -r "pricing.json" src/`
|
||||
Expected: Only the static import in `src/shared/utils/pricing.ts`
|
||||
|
||||
**Step 2: Evaluate extraResources**
|
||||
|
||||
If no runtime file loading remains, the `extraResources` entry for `pricing.json` in `package.json` is dead config. However, removing it is low-risk and low-priority — it just means the file gets copied to the app bundle uselessly. Leave it for now unless it causes issues. Document the decision.
|
||||
|
||||
**Step 3: Final commit**
|
||||
|
||||
```bash
|
||||
git add docs/plans/2026-02-23-unify-cost-calculation.md
|
||||
git commit -m "docs: finalize implementation plan for cost unification"
|
||||
```
|
||||
Loading…
Reference in a new issue