diff --git a/README.md b/README.md index 1743fce..899ef2b 100644 --- a/README.md +++ b/README.md @@ -110,7 +110,7 @@ $ copilot-usage cost │ Session Gamma │ claude-opus-4.6 │ 8 │ 10 │ 2 │ 400 │ │ ↳ Since last shutdown │ claude-opus-4.6 │ N/A │ N/A │ 1 │ 150 │ ├──────────────────────────┼────────────────────┼──────────┼──────────────┼─────────────┼───────────────┤ -│ Grand Total │ │ 835 │ 802 │ 9 │ 301.5K │ +│ Grand Total │ │ 835 │ 802 │ 8 │ 301.5K │ └──────────────────────────┴────────────────────┴──────────┴──────────────┴─────────────┴───────────────┘ ``` diff --git a/src/copilot_usage/report.py b/src/copilot_usage/report.py index a92ebf6..7f01f18 100644 --- a/src/copilot_usage/report.py +++ b/src/copilot_usage/report.py @@ -611,13 +611,18 @@ def _render_totals(console: Console, sessions: list[SessionSummary]) -> None: console.print(Panel("\n".join(lines), title="Totals", border_style="cyan")) -def _render_model_table(console: Console, sessions: list[SessionSummary]) -> None: +def _render_model_table( + console: Console, + sessions: list[SessionSummary], + *, + title: str = "Per-Model Breakdown", +) -> None: """Render the per-model breakdown table.""" merged = _aggregate_model_metrics(sessions) if not merged: return - table = Table(title="Per-Model Breakdown", border_style="cyan") + table = Table(title=title, border_style="cyan") table.add_column("Model", style="bold") table.add_column("Requests", justify="right") table.add_column("Premium Cost", justify="right") @@ -642,6 +647,8 @@ def _render_model_table(console: Console, sessions: list[SessionSummary]) -> Non def _render_session_table( console: Console, sessions: list[SessionSummary], + *, + title: str = "Sessions", ) -> None: """Render the per-session table sorted by start time (newest first).""" if not sessions: @@ -653,7 +660,7 @@ def _render_session_table( reverse=True, ) - table = Table(title="Sessions", border_style="cyan") + table = Table(title=title, border_style="cyan") table.add_column("Name", style="bold", max_width=40) table.add_column("Model") table.add_column("Premium", justify="right") @@ -759,67 +766,10 @@ def _render_historical_section( ) # Per-model table - merged = _aggregate_model_metrics(historical) - if merged: - table = Table(title="Per-Model Breakdown", border_style="cyan") - table.add_column("Model", style="bold") - table.add_column("Requests", justify="right") - table.add_column("Premium Cost", justify="right") - table.add_column("Input Tokens", justify="right") - table.add_column("Output Tokens", justify="right") - table.add_column("Cache Read", justify="right") - - for model_name in sorted(merged): - mm = merged[model_name] - table.add_row( - model_name, - str(mm.requests.count), - str(mm.requests.cost), - format_tokens(mm.usage.inputTokens), - format_tokens(mm.usage.outputTokens), - format_tokens(mm.usage.cacheReadTokens), - ) - console.print(table) + _render_model_table(console, historical) # Per-session table - sorted_sessions = sorted( - historical, - key=lambda s: s.start_time.isoformat() if s.start_time else "", - reverse=True, - ) - session_table = Table(title="Sessions (Shutdown Data)", border_style="cyan") - session_table.add_column("Name", style="bold", max_width=40) - session_table.add_column("Model") - session_table.add_column("Premium", justify="right") - session_table.add_column("Model Calls", justify="right") - session_table.add_column("User Msgs", justify="right") - session_table.add_column("Output Tokens", justify="right") - session_table.add_column("Status") - - for s in sorted_sessions: - name = s.name or s.session_id[:12] - model = s.model or "—" - output_tokens = sum(mm.usage.outputTokens for mm in s.model_metrics.values()) - status = ( - Text("Active 🟢", style="yellow") - if s.is_active - else Text("Completed", style="dim") - ) - pr_display = ( - str(s.total_premium_requests) if s.total_premium_requests > 0 else "—" - ) - - session_table.add_row( - name, - model, - pr_display, - str(s.model_calls), - str(s.user_messages), - format_tokens(output_tokens), - status, - ) - - console.print(session_table) + _render_session_table(console, historical, title="Sessions (Shutdown Data)") def _render_active_section( @@ -966,7 +916,6 @@ def render_cost_view( str(s.active_model_calls), format_tokens(s.active_output_tokens), ) - grand_model_calls += s.active_model_calls grand_output += s.active_output_tokens table.add_section() diff --git a/tests/copilot_usage/test_report.py b/tests/copilot_usage/test_report.py index e596f05..aeb3f7e 100644 --- a/tests/copilot_usage/test_report.py +++ b/tests/copilot_usage/test_report.py @@ -1239,3 +1239,85 @@ def test_multi_model_session(self) -> None: assert "claude-sonnet-4" in output assert "claude-haiku-4.5" in output assert "Grand Total" in output + + def test_resumed_session_no_double_count(self) -> None: + """Regression: active_model_calls must not be added to grand_model_calls.""" + import re + + session = SessionSummary( + session_id="resume-5555-abcde", + name="Resumed", + model="claude-opus-4.6", + start_time=datetime(2025, 1, 15, 10, 0, tzinfo=UTC), + is_active=True, + model_calls=10, + user_messages=4, + active_model_calls=3, + active_output_tokens=200, + model_metrics={ + "claude-opus-4.6": ModelMetrics( + requests=RequestMetrics(count=7, cost=21), + usage=TokenUsage(outputTokens=1000), + ) + }, + ) + output = _capture_cost_view([session]) + # Grand Total Model Calls should be 10, not 13 + clean = re.sub(r"\x1b\[[0-9;]*m", "", output) + # Match: Grand Total │ │ Req │ Prem │ ModelCalls │ + grand_match = re.search( + r"Grand Total\s*│[^│]*│\s*\d+\s*│\s*\d+\s*│\s*(\d+)\s*│", clean + ) + assert grand_match is not None, "Grand Total row not found" + assert grand_match.group(1) == "10" + + +class TestRenderFullSummaryHelperReuse: + """Verify _render_historical_section delegates to shared table helpers.""" + + def test_historical_session_table_title(self) -> None: + """Historical section must use Sessions (Shutdown Data) title.""" + session = SessionSummary( + session_id="hist-7777-abcdef", + name="HistReuse", + model="claude-sonnet-4", + start_time=datetime(2025, 1, 15, 10, 0, tzinfo=UTC), + is_active=False, + total_premium_requests=5, + user_messages=2, + model_calls=3, + model_metrics={ + "claude-sonnet-4": ModelMetrics( + requests=RequestMetrics(count=3, cost=5), + usage=TokenUsage( + inputTokens=300, outputTokens=600, cacheReadTokens=50 + ), + ) + }, + ) + output = _capture_full_summary([session]) + assert "Sessions (Shutdown Data)" in output + + def test_historical_model_table_present(self) -> None: + """Historical section must contain per-model breakdown table.""" + session = SessionSummary( + session_id="hist-8888-abcdef", + name="ModelTbl", + model="claude-sonnet-4", + start_time=datetime(2025, 1, 15, 10, 0, tzinfo=UTC), + is_active=False, + total_premium_requests=5, + user_messages=2, + model_calls=3, + model_metrics={ + "claude-sonnet-4": ModelMetrics( + requests=RequestMetrics(count=3, cost=5), + usage=TokenUsage( + inputTokens=300, outputTokens=600, cacheReadTokens=50 + ), + ) + }, + ) + output = _capture_full_summary([session]) + assert "Per-Model Breakdown" in output + assert "claude-sonnet-4" in output