diff --git a/src/copilot_usage/parser.py b/src/copilot_usage/parser.py index 7cd7072..bd2f551 100644 --- a/src/copilot_usage/parser.py +++ b/src/copilot_usage/parser.py @@ -334,6 +334,9 @@ def build_session_summary( model_calls=total_turn_starts, user_messages=user_message_count, is_active=True, + active_model_calls=total_turn_starts, + active_user_messages=user_message_count, + active_output_tokens=total_output_tokens, ) diff --git a/tests/copilot_usage/test_parser.py b/tests/copilot_usage/test_parser.py index 4578718..795950f 100644 --- a/tests/copilot_usage/test_parser.py +++ b/tests/copilot_usage/test_parser.py @@ -515,6 +515,27 @@ def test_last_resume_time_none_for_active(self, tmp_path: Path) -> None: summary = build_session_summary(events, session_dir=sdir) assert summary.last_resume_time is None + def test_active_fields_populated(self, tmp_path: Path) -> None: + """Pure active session populates active_model_calls/user_messages/output_tokens.""" + p = tmp_path / "s" / "events.jsonl" + _write_events( + p, + _START_EVENT, + _USER_MSG, + _TURN_START_1, + _ASSISTANT_MSG, + _USER_MSG, # second user message (same fixture reused) + _TURN_START_2, + _ASSISTANT_MSG_2, + _TOOL_EXEC, + ) + events = parse_events(p) + summary = build_session_summary(events, session_dir=p.parent) + assert summary.is_active is True + assert summary.active_model_calls == 2 + assert summary.active_user_messages == 2 + assert summary.active_output_tokens == 350 # 150 + 200 + # --------------------------------------------------------------------------- # build_session_summary — resumed session (shutdown followed by more events) diff --git a/tests/copilot_usage/test_report.py b/tests/copilot_usage/test_report.py index 6615e18..30e8e2a 100644 --- a/tests/copilot_usage/test_report.py +++ b/tests/copilot_usage/test_report.py @@ -1262,6 +1262,42 @@ def test_active_section_uses_last_resume_time(self) -> None: # Should show minutes (from last_resume_time), NOT days (from start_time) assert "3d" not in output and "72h" not in output + def test_active_section_shows_nonzero_activity(self) -> None: + """Active section renders the actual active_* field values, not zero.""" + now = datetime.now(tz=UTC) + session = SessionSummary( + session_id="pure-active-abcdef", + name="Pure Active", + model="claude-sonnet-4", + start_time=now - timedelta(minutes=10), + is_active=True, + user_messages=4, + model_calls=3, + active_model_calls=3, + active_user_messages=4, + active_output_tokens=1500, + model_metrics={ + "claude-sonnet-4": ModelMetrics( + usage=TokenUsage(outputTokens=1500), + ) + }, + ) + import re + + output = _capture_full_summary([session]) + assert "Active Sessions" in output + # Strip ANSI codes and split on │ to validate the correct columns + clean = re.sub(r"\x1b\[[0-9;]*m", "", output) + lines = clean.splitlines() + pure_active_line = next(line for line in lines if "Pure Active" in line) + # Active Sessions columns: Name | Model | Model Calls | User Msgs | Output Tokens | Running Time + cols = [c.strip() for c in pure_active_line.split("│")] + assert cols[3] == "3", f"Model Calls column: expected '3', got '{cols[3]}'" + assert cols[4] == "4", f"User Msgs column: expected '4', got '{cols[4]}'" + assert cols[5] == "1.5K", ( + f"Output Tokens column: expected '1.5K', got '{cols[5]}'" + ) + # --------------------------------------------------------------------------- # render_cost_view capture helper diff --git a/tests/e2e/fixtures/pure-active-session/events.jsonl b/tests/e2e/fixtures/pure-active-session/events.jsonl new file mode 100644 index 0000000..8ab6b56 --- /dev/null +++ b/tests/e2e/fixtures/pure-active-session/events.jsonl @@ -0,0 +1,10 @@ +{"type":"session.start","data":{"sessionId":"pure-active-0000-0000-000000000001","version":1,"producer":"copilot-agent","copilotVersion":"1.0.2","startTime":"2026-03-07T09:00:00.000Z","context":{"cwd":"/Users/testuser/active-project"}},"id":"pa-start","timestamp":"2026-03-07T09:00:00.000Z","parentId":null} +{"type":"user.message","data":{"content":"First user message.","transformedContent":"First user message.","attachments":[],"interactionId":"pa-int-1"},"id":"pa-user1","timestamp":"2026-03-07T09:01:00.000Z","parentId":"pa-start"} +{"type":"assistant.turn_start","data":{"turnId":"0","interactionId":"pa-int-1"},"id":"pa-turn-start-1","timestamp":"2026-03-07T09:01:01.000Z","parentId":"pa-user1"} +{"type":"assistant.message","data":{"messageId":"pa-msg-1","content":"First assistant reply.","toolRequests":[],"interactionId":"pa-int-1","outputTokens":300},"id":"pa-asst1","timestamp":"2026-03-07T09:01:10.000Z","parentId":"pa-turn-start-1"} +{"type":"assistant.turn_end","data":{"turnId":"0"},"id":"pa-turn-end-1","timestamp":"2026-03-07T09:01:11.000Z","parentId":"pa-asst1"} +{"type":"user.message","data":{"content":"Second user message.","transformedContent":"Second user message.","attachments":[],"interactionId":"pa-int-2"},"id":"pa-user2","timestamp":"2026-03-07T09:02:00.000Z","parentId":"pa-turn-end-1"} +{"type":"assistant.turn_start","data":{"turnId":"1","interactionId":"pa-int-2"},"id":"pa-turn-start-2","timestamp":"2026-03-07T09:02:01.000Z","parentId":"pa-user2"} +{"type":"assistant.message","data":{"messageId":"pa-msg-2","content":"Second assistant reply.","toolRequests":[],"interactionId":"pa-int-2","outputTokens":400},"id":"pa-asst2","timestamp":"2026-03-07T09:02:10.000Z","parentId":"pa-turn-start-2"} +{"type":"tool.execution_start","data":{"toolCallId":"pa-tc-1","toolName":"bash","arguments":{"command":"echo hello"}},"id":"pa-tool-start","timestamp":"2026-03-07T09:02:11.000Z","parentId":"pa-asst2"} +{"type":"tool.execution_complete","data":{"toolCallId":"pa-tc-1","model":"claude-sonnet-4","interactionId":"pa-int-2","success":true},"id":"pa-tool-end","timestamp":"2026-03-07T09:02:12.000Z","parentId":"pa-tool-start"} diff --git a/tests/e2e/test_e2e.py b/tests/e2e/test_e2e.py index 3cf6e1a..1f560bd 100644 --- a/tests/e2e/test_e2e.py +++ b/tests/e2e/test_e2e.py @@ -27,10 +27,10 @@ def _wide_terminal(monkeypatch: pytest.MonkeyPatch) -> None: class TestSummaryE2E: """Tests for the ``summary`` command.""" - def test_finds_eight_sessions(self) -> None: + def test_finds_all_sessions(self) -> None: result = CliRunner().invoke(main, ["summary", "--path", str(FIXTURES)]) assert result.exit_code == 0 - assert "8 sessions" in result.output + assert "9 sessions" in result.output def test_total_premium_requests(self) -> None: result = CliRunner().invoke(main, ["summary", "--path", str(FIXTURES)]) @@ -62,12 +62,12 @@ def test_date_filtering_excludes_sessions(self) -> None: def test_model_calls_shown(self) -> None: result = CliRunner().invoke(main, ["summary", "--path", str(FIXTURES)]) assert result.exit_code == 0 - assert "20 model calls" in result.output + assert "22 model calls" in result.output def test_user_messages_shown(self) -> None: result = CliRunner().invoke(main, ["summary", "--path", str(FIXTURES)]) assert result.exit_code == 0 - assert "14 user messages" in result.output + assert "16 user messages" in result.output # --------------------------------------------------------------------------- @@ -182,8 +182,8 @@ def test_summary_survives_corrupt_lines(self) -> None: """Summary still works when events.jsonl has malformed JSON lines.""" result = CliRunner().invoke(main, ["summary", "--path", str(FIXTURES)]) assert result.exit_code == 0 - # 8 sessions total - assert "8 sessions" in result.output + # 9 sessions total + assert "9 sessions" in result.output def test_corrupt_session_appears_in_summary(self) -> None: """The corrupt session is parsed (valid lines kept) and shown.""" @@ -245,7 +245,7 @@ def test_summary_date_filter_includes_all(self) -> None: ["summary", "--path", str(FIXTURES), "--since", "2020-01-01"], ) assert result.exit_code == 0 - assert "8 sessions" in result.output + assert "9 sessions" in result.output # --------------------------------------------------------------------------- @@ -536,6 +536,52 @@ def test_has_recent_events(self) -> None: assert "Recent Events" in result.output +# --------------------------------------------------------------------------- +# pure active session with activity (regression for #154) +# --------------------------------------------------------------------------- + + +class TestPureActiveSessionActivityE2E: + """Regression: pure active session must show non-zero activity counts.""" + + def test_live_shows_pure_active_session(self) -> None: + """Pure active session appears in the live view.""" + import re + + result = CliRunner().invoke(main, ["live", "--path", str(FIXTURES)]) + assert result.exit_code == 0 + clean = re.sub(r"\x1b\[[0-9;]*m", "", result.output) + lines = clean.splitlines() + active_line = next(line for line in lines if "pure-act" in line) + # Live table columns: Session ID | Name | Model | Running | Messages | Output Tokens | CWD + cols = [c.strip() for c in active_line.split("│")] + assert cols[5] == "2", f"Messages column: expected '2', got '{cols[5]}'" + + def test_session_detail_shows_active(self) -> None: + """Pure active session detail shows active status.""" + result = CliRunner().invoke( + main, ["session", "pure-active", "--path", str(FIXTURES)] + ) + assert result.exit_code == 0 + assert "active" in result.output.lower() + + def test_session_detail_shows_user_messages(self) -> None: + """Pure active session detail shows 2 user messages.""" + result = CliRunner().invoke( + main, ["session", "pure-active", "--path", str(FIXTURES)] + ) + assert result.exit_code == 0 + assert "2 user messages" in result.output + + def test_session_detail_shows_model_calls(self) -> None: + """Pure active session detail shows 2 model calls.""" + result = CliRunner().invoke( + main, ["session", "pure-active", "--path", str(FIXTURES)] + ) + assert result.exit_code == 0 + assert "2 model calls" in result.output + + # --------------------------------------------------------------------------- # shutdown aggregation regression # ---------------------------------------------------------------------------