Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/copilot_usage/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)


Expand Down
21 changes: 21 additions & 0 deletions tests/copilot_usage/test_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
36 changes: 36 additions & 0 deletions tests/copilot_usage/test_report.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions tests/e2e/fixtures/pure-active-session/events.jsonl
Original file line number Diff line number Diff line change
@@ -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"}
60 changes: 53 additions & 7 deletions tests/e2e/test_e2e.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)])
Expand Down Expand Up @@ -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


# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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


# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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
# ---------------------------------------------------------------------------
Expand Down
Loading