diff --git a/src/session_analytics/cli.py b/src/session_analytics/cli.py index c0ce7cb..05006b6 100644 --- a/src/session_analytics/cli.py +++ b/src/session_analytics/cli.py @@ -139,11 +139,18 @@ def _format_sequences(data: dict) -> list[str]: desc = "Detailed sequences (Bash→commands, Skill→skills, Task→agents)" else: desc = "Tool chains showing workflow patterns (Read → Edit, etc.)" - lines = [ - desc, - "", - "Sequences:", - ] + + total = data.get("total_patterns", len(data.get("sequences", []))) + shown = len(data.get("sequences", [])) + + lines = [desc, ""] + + # Show truncation info if results are limited + if total > shown: + lines.append(f"Showing {shown} of {total} total patterns") + lines.append("") + + lines.append("Sequences:") for seq in data.get("sequences", []): lines.append(f" {seq['pattern']}: {seq['count']}") return lines @@ -622,13 +629,22 @@ def format_metric(name: str, metric: dict) -> str: def _format_compactions(data: dict) -> list[str]: # Count unique sessions unique_sessions = len({c["session_id"] for c in data.get("compactions", [])}) + total_count = data.get("total_compaction_count", data["compaction_count"]) + shown_count = data["compaction_count"] + lines = [ f"Compaction events (context resets) - last {data.get('days', 7)} days", "", - f"Total compactions: {data['compaction_count']}", - f"Sessions affected: {unique_sessions}", - "", ] + + # Show truncation info if results are limited + if total_count > shown_count: + lines.append(f"Showing {shown_count} of {total_count} total compactions") + else: + lines.append(f"Total compactions: {shown_count}") + lines.append(f"Sessions affected: {unique_sessions}") + lines.append("") + if data.get("compactions"): lines.append("Recent compactions:") for c in data["compactions"][:10]: @@ -785,10 +801,15 @@ def cmd_sequences(args): min_count=args.min_count, expand=args.expand, ) + # Apply limit to match MCP behavior + limit = getattr(args, "limit", 50) + limited_patterns = sequence_patterns[:limit] if limit > 0 else sequence_patterns result = { "days": args.days, "expanded": args.expand, - "sequences": [{"pattern": p.pattern_key, "count": p.count} for p in sequence_patterns], + "limit": limit, + "total_patterns": len(sequence_patterns), + "sequences": [{"pattern": p.pattern_key, "count": p.count} for p in limited_patterns], } print(format_output(result, args.json)) @@ -1141,6 +1162,7 @@ def cmd_compactions(args): storage, days=args.days, session_id=getattr(args, "session_id", None), + limit=getattr(args, "limit", 50), ) print(format_output(result, args.json)) @@ -1176,6 +1198,7 @@ def cmd_efficiency(args): storage, days=args.days, project=getattr(args, "project", None), + limit=getattr(args, "limit", 50), ) print(format_output(result, args.json)) @@ -1459,6 +1482,7 @@ def main(): action="store_true", help="Expand Bash→commands, Skill→skills, Task→agents", ) + sub.add_argument("--limit", type=int, default=50, help="Max patterns to return (default: 50)") sub.set_defaults(func=cmd_sequences) # permissions @@ -1657,6 +1681,7 @@ def main(): sub = subparsers.add_parser("compactions", help="Show compaction events (context resets)") sub.add_argument("--days", type=int, default=7, help="Days to analyze (default: 7)") sub.add_argument("--session-id", help="Filter to specific session ID") + sub.add_argument("--limit", type=int, default=50, help="Max events to return (default: 50)") sub.set_defaults(func=cmd_compactions) # pre-compaction @@ -1679,6 +1704,7 @@ def main(): sub = subparsers.add_parser("efficiency", help="Show session context efficiency metrics") sub.add_argument("--days", type=int, default=7, help="Days to analyze (default: 7)") sub.add_argument("--project", help="Project path filter") + sub.add_argument("--limit", type=int, default=50, help="Max sessions to return (default: 50)") sub.set_defaults(func=cmd_efficiency) # benchmark (Issue #63) diff --git a/src/session_analytics/guide.md b/src/session_analytics/guide.md index b2afd14..d8c988d 100644 --- a/src/session_analytics/guide.md +++ b/src/session_analytics/guide.md @@ -36,7 +36,7 @@ identify permission gaps. | Tool | Purpose | |------|---------| -| `get_tool_sequences(days?, min_count?, length?)` | Common tool chains (e.g., Read → Edit → Bash) | +| `get_tool_sequences(days?, min_count?, length?, limit?)` | Common tool chains (e.g., Read → Edit → Bash) | | `sample_sequences(pattern, limit?, context_events?)` | Random samples of a pattern with surrounding context | | `get_permission_gaps(days?, min_count?)` | Commands not covered by settings.json (supports glob patterns) | | `get_insights(days?, refresh?)` | Pre-computed patterns for /improve-workflow | @@ -68,11 +68,22 @@ Each session includes `classification_factors` explaining WHY it was categorized - `trigger`: The threshold that was exceeded (e.g., "error_rate > 15%") - Relevant metrics (error_rate, edit_rate, etc.) +Each session also includes `efficiency` metrics: +- `compaction_count`: Number of context resets +- `total_result_mb`: Total tool result size +- `files_read_multiple_times`: Indicator of rework +- `burn_rate`: "high", "medium", or "low" based on compactions/hour + ### Trend Analysis | Tool | Purpose | |------|---------| -| `analyze_trends(days?, compare_to?)` | Token/event trends with growth rates | +| `analyze_trends(days?, compare_to?)` | Token/event trends with efficiency metrics | + +Returns both core metrics (`events`, `sessions`, `errors`, `tokens`) and `efficiency` metrics: +- `avg_compactions_per_session`: Context resets per session (lower is better) +- `avg_result_mb_per_session`: Context consumption per session +- `files_read_multiple_times`: Rework indicator ### Session Messages @@ -120,10 +131,10 @@ Each session includes `classification_factors` explaining WHY it was categorized | Tool | Purpose | |------|---------| -| `get_compaction_events(days?, session_id?)` | List compaction events (context resets) | +| `get_compaction_events(days?, session_id?, limit?)` | List compaction events (context resets) | | `get_pre_compaction_events(session_id, compaction_timestamp, limit?)` | Events before a compaction for analysis | | `get_large_tool_results(days?, min_size_kb?, limit?)` | Find tool results consuming context space | -| `get_session_efficiency(days?, project?)` | Session efficiency metrics and burn rate | +| `get_session_efficiency(days?, project?, limit?)` | Session efficiency metrics and burn rate | **Context efficiency** helps identify why sessions hit context limits: - **Compactions**: Context resets when Claude summarizes conversation @@ -251,8 +262,11 @@ get_permission_gaps(min_count=5) Add suggestions to `permissions.allow` in your settings. -**Note:** Supports glob pattern matching. Patterns like `Bash(make*)` will correctly -match commands `make`, `make-test`, etc. using fnmatch. +**Notes:** +- Supports glob pattern matching. Patterns like `Bash(make*)` will correctly + match commands `make`, `make-test`, etc. using fnmatch. +- Automatically filters non-actionable commands (shell builtins like `pwd`, `cd`, `echo`, + control flow like `for`, `if`, and info commands like `hostname`, `whoami`) to reduce noise. ### Git Integration diff --git a/src/session_analytics/patterns.py b/src/session_analytics/patterns.py index dbc97fd..2f6eb75 100644 --- a/src/session_analytics/patterns.py +++ b/src/session_analytics/patterns.py @@ -701,6 +701,55 @@ def _command_matches_patterns(cmd: str, base_commands: set[str], glob_patterns: return False +# Commands that don't need allowlisting - shell builtins, context commands, +# and other non-actionable patterns that create noise in permission gap analysis +NON_ACTIONABLE_COMMANDS = frozenset( + { + # Shell builtins / context commands + "pwd", + "cd", + "echo", + "true", + "false", + "exit", + "return", + "export", + "source", + ".", + # Comment prefixes (from multi-line commands) + "#", + # Control flow (from multi-line commands) + "for", + "while", + "if", + "then", + "else", + "fi", + "do", + "done", + "case", + "esac", + # System info commands (informational, not dangerous) + "hostname", + "whoami", + "id", + "uname", + "date", + "uptime", + # File viewers (read-only, typically safe) + "bat", + "less", + "more", + # Variable assignments (captured as commands) + "set", + "unset", + "local", + "declare", + "readonly", + } +) + + def compute_permission_gaps( storage: SQLiteStorage, days: int = 7, @@ -712,6 +761,9 @@ def compute_permission_gaps( Uses fnmatch for glob pattern matching, so patterns like Bash(make*) will correctly match commands like 'make', 'make-test', etc. + Filters out non-actionable commands (shell builtins, context commands) + that would create noise in the results. + Args: storage: Storage instance days: Number of days to analyze @@ -741,6 +793,9 @@ def compute_permission_gaps( patterns = [] for row in rows: cmd = row["command"] + # Skip non-actionable commands (builtins, context commands) + if cmd in NON_ACTIONABLE_COMMANDS: + continue if not _command_matches_patterns(cmd, base_commands, glob_patterns): patterns.append( Pattern( @@ -1179,6 +1234,39 @@ def get_period_metrics(start: datetime, end: datetime) -> dict: ) tokens = token_usage[0] if token_usage else {"input_tokens": 0, "output_tokens": 0} + # Efficiency metrics: compactions and result bytes + efficiency = storage.execute_query( + """ + SELECT + SUM(CASE WHEN entry_type = 'compaction' THEN 1 ELSE 0 END) as compaction_count, + COALESCE(SUM(result_size_bytes), 0) as total_result_bytes + FROM events + WHERE timestamp >= ? AND timestamp < ? + """, + (start, end), + ) + eff = efficiency[0] if efficiency else {"compaction_count": 0, "total_result_bytes": 0} + compaction_count = eff["compaction_count"] or 0 + total_result_bytes = eff["total_result_bytes"] or 0 + + # Files read multiple times (rework indicator) + multi_read = storage.execute_query( + """ + SELECT COUNT(*) as multi_read_files + FROM ( + SELECT file_path + FROM events + WHERE timestamp >= ? AND timestamp < ? + AND tool_name = 'Read' + AND file_path IS NOT NULL + GROUP BY session_id, file_path + HAVING COUNT(*) > 1 + ) + """, + (start, end), + ) + files_read_multiple = multi_read[0]["multi_read_files"] if multi_read else 0 + return { "total_events": total_events, "sessions": sessions, @@ -1187,6 +1275,15 @@ def get_period_metrics(start: datetime, end: datetime) -> dict: "top_tools": top_tools, "input_tokens": tokens["input_tokens"] or 0, "output_tokens": tokens["output_tokens"] or 0, + # Efficiency metrics + "compaction_count": compaction_count, + "total_result_bytes": total_result_bytes, + "files_read_multiple_times": files_read_multiple, + # Per-session averages + "avg_compactions_per_session": compaction_count / sessions if sessions > 0 else 0, + "avg_result_mb_per_session": (total_result_bytes / 1024 / 1024) / sessions + if sessions > 0 + else 0, } def calculate_change(current: float, previous: float) -> dict: @@ -1257,5 +1354,23 @@ def calculate_change(current: float, previous: float) -> dict: current_metrics["output_tokens"], previous_metrics["output_tokens"] ), }, + # Issue #78: Efficiency metrics for workflow improvement tracking + "efficiency": { + "compactions": calculate_change( + current_metrics["compaction_count"], previous_metrics["compaction_count"] + ), + "avg_compactions_per_session": calculate_change( + current_metrics["avg_compactions_per_session"], + previous_metrics["avg_compactions_per_session"], + ), + "files_read_multiple_times": calculate_change( + current_metrics["files_read_multiple_times"], + previous_metrics["files_read_multiple_times"], + ), + "avg_result_mb_per_session": calculate_change( + current_metrics["avg_result_mb_per_session"], + previous_metrics["avg_result_mb_per_session"], + ), + }, "tool_changes": tool_changes[:10], } diff --git a/src/session_analytics/queries.py b/src/session_analytics/queries.py index cef61cd..ae5a974 100644 --- a/src/session_analytics/queries.py +++ b/src/session_analytics/queries.py @@ -1100,7 +1100,7 @@ def classify_sessions( where_clause = " AND ".join(where_parts) - # Get activity stats per session + # Get activity stats per session (including efficiency metrics for #79) # Safe: where_clause is built from hardcoded condition strings above rows = storage.execute_query( f""" @@ -1115,6 +1115,8 @@ def classify_sessions( SUM(CASE WHEN tool_name = 'Bash' AND command IN ('git', 'gh') THEN 1 ELSE 0 END) as git_count, SUM(CASE WHEN tool_name = 'Bash' AND command IN ('make', 'cargo', 'npm', 'pytest') THEN 1 ELSE 0 END) as build_count, SUM(CASE WHEN is_error = 1 THEN 1 ELSE 0 END) as error_count, + SUM(CASE WHEN entry_type = 'compaction' THEN 1 ELSE 0 END) as compaction_count, + COALESCE(SUM(result_size_bytes), 0) as total_result_bytes, MIN(timestamp) as first_seen, MAX(timestamp) as last_seen FROM events @@ -1126,6 +1128,29 @@ def classify_sessions( tuple(params), ) + # Get files read multiple times per session + session_ids = [row["session_id"] for row in rows] + files_read_multiple: dict[str, int] = {} + if session_ids: + placeholders = ",".join("?" * len(session_ids)) + multi_read_rows = storage.execute_query( + f""" + SELECT session_id, COUNT(*) as multi_read_files + FROM ( + SELECT session_id, file_path, COUNT(*) as read_count + FROM events + WHERE session_id IN ({placeholders}) + AND tool_name = 'Read' + AND file_path IS NOT NULL + GROUP BY session_id, file_path + HAVING COUNT(*) > 1 + ) + GROUP BY session_id + """, + tuple(session_ids), + ) + files_read_multiple = {r["session_id"]: r["multi_read_files"] for r in multi_read_rows} + classifications = [] category_counts = { "debugging": 0, @@ -1200,6 +1225,36 @@ def classify_sessions( category_counts[category] += 1 + # Issue #79: Add efficiency metrics + compaction_count = row["compaction_count"] or 0 + total_bytes = row["total_result_bytes"] or 0 + multi_read_files = files_read_multiple.get(row["session_id"], 0) + + # Calculate burn_rate based on compactions per hour + first_seen = row["first_seen"] + last_seen = row["last_seen"] + if first_seen and last_seen: + try: + # Parse timestamps and calculate duration (datetime already imported at module level) + first_dt = datetime.fromisoformat(first_seen.replace("Z", "+00:00")) + last_dt = datetime.fromisoformat(last_seen.replace("Z", "+00:00")) + duration_hours = (last_dt - first_dt).total_seconds() / 3600 + compactions_per_hour = ( + compaction_count / duration_hours if duration_hours > 0 else 0 + ) + except (ValueError, TypeError): + compactions_per_hour = 0 + else: + compactions_per_hour = 0 + + # Classify burn rate: high (>2/hr), medium (0.5-2/hr), low (<0.5/hr) + if compactions_per_hour > 2: + burn_rate = "high" + elif compactions_per_hour > 0.5: + burn_rate = "medium" + else: + burn_rate = "low" + classifications.append( { "session_id": row["session_id"], @@ -1215,6 +1270,12 @@ def classify_sessions( "git_count": row["git_count"] or 0, "error_count": error_count, }, + "efficiency": { + "compaction_count": compaction_count, + "total_result_mb": round(total_bytes / 1024 / 1024, 2), + "files_read_multiple_times": multi_read_files, + "burn_rate": burn_rate, + }, "first_seen": _format_timestamp(row["first_seen"]), "last_seen": _format_timestamp(row["last_seen"]), } @@ -2037,6 +2098,7 @@ def get_compaction_events( storage: SQLiteStorage, days: int = 7, session_id: str | None = None, + limit: int = 50, ) -> dict: """List compaction events where conversation history was truncated. @@ -2047,6 +2109,7 @@ def get_compaction_events( storage: Storage instance days: Number of days to analyze (default: 7) session_id: Optional filter for specific session + limit: Maximum events to return (default: 50) Returns: Dict with compaction events and their timestamps @@ -2062,6 +2125,20 @@ def get_compaction_events( where_clause = " AND ".join(where_parts) + # First get total count + count_row = storage.execute_query( + f"SELECT COUNT(*) as total FROM events WHERE {where_clause}", + tuple(params), + ) + total_count = count_row[0]["total"] if count_row else 0 + + # Then get limited results + query_params = list(params) + limit_clause = "" + if limit > 0: + limit_clause = "LIMIT ?" + query_params.append(limit) + rows = storage.execute_query( f""" SELECT @@ -2073,8 +2150,9 @@ def get_compaction_events( FROM events WHERE {where_clause} ORDER BY timestamp DESC + {limit_clause} """, - tuple(params), + tuple(query_params), ) compactions = [ @@ -2091,6 +2169,8 @@ def get_compaction_events( return { "days": days, "session_id": session_id, + "limit": limit, + "total_compaction_count": total_count, "compaction_count": len(compactions), "compactions": compactions, } @@ -2242,6 +2322,7 @@ def get_session_efficiency( storage: SQLiteStorage, days: int = 7, project: str | None = None, + limit: int = 50, ) -> dict: """Analyze session efficiency: burn rate, compactions, read patterns. @@ -2257,6 +2338,7 @@ def get_session_efficiency( storage: Storage instance days: Number of days to analyze (default: 7) project: Optional project filter + limit: Maximum sessions to return (default: 50) Returns: Dict with efficiency metrics per session @@ -2265,6 +2347,12 @@ def get_session_efficiency( where_clause, params = build_where_clause(cutoff=cutoff, project=project) # Get session-level efficiency metrics + query_params = list(params) + limit_clause = "" + if limit > 0: + limit_clause = "LIMIT ?" + query_params.append(limit) + rows = storage.execute_query( f""" SELECT @@ -2286,9 +2374,9 @@ def get_session_efficiency( GROUP BY session_id HAVING COUNT(*) >= 10 ORDER BY compaction_count DESC, input_tokens DESC - LIMIT 50 + {limit_clause} """, - params, + tuple(query_params), ) # Get files read multiple times per session @@ -2350,6 +2438,7 @@ def get_session_efficiency( return { "days": days, "project": project, + "limit": limit, "session_count": len(sessions), "sessions": sessions, } diff --git a/src/session_analytics/server.py b/src/session_analytics/server.py index 6a4a742..ab6d314 100644 --- a/src/session_analytics/server.py +++ b/src/session_analytics/server.py @@ -210,7 +210,11 @@ def get_token_usage(days: int = 7, project: str | None = None, by: str = "day") @mcp.tool() def get_tool_sequences( - days: int = 7, min_count: int = 3, length: int = 2, expand: bool = False + days: int = 7, + min_count: int = 3, + length: int = 2, + expand: bool = False, + limit: int = 50, ) -> dict: """Get common tool patterns (sequences). @@ -219,6 +223,7 @@ def get_tool_sequences( min_count: Minimum occurrences to include (default: 3) length: Sequence length (default: 2) expand: Expand Bash→commands, Skill→skill names, Task→subagent types (default: False) + limit: Maximum patterns to return (default: 50) Returns: Common tool sequences @@ -227,13 +232,17 @@ def get_tool_sequences( sequence_patterns = patterns.compute_sequence_patterns( storage, days=days, sequence_length=length, min_count=min_count, expand=expand ) + # Apply limit to prevent large responses + limited_patterns = sequence_patterns[:limit] if limit > 0 else sequence_patterns return { "status": "ok", "days": days, "min_count": min_count, "sequence_length": length, "expanded": expand, - "sequences": [{"pattern": p.pattern_key, "count": p.count} for p in sequence_patterns], + "limit": limit, + "total_patterns": len(sequence_patterns), + "sequences": [{"pattern": p.pattern_key, "count": p.count} for p in limited_patterns], } @@ -833,6 +842,7 @@ def get_bus_events( def get_compaction_events( days: int = 7, session_id: str | None = None, + limit: int = 50, ) -> dict: """List compaction events (context resets) across sessions. @@ -842,12 +852,13 @@ def get_compaction_events( Args: days: Number of days to analyze (default: 7) session_id: Filter to specific session + limit: Maximum events to return (default: 50) Returns: List of compaction events with timestamps and session info """ queries.ensure_fresh_data(storage, days=days) - result = queries.get_compaction_events(storage, days=days, session_id=session_id) + result = queries.get_compaction_events(storage, days=days, session_id=session_id, limit=limit) return {"status": "ok", **result} @@ -910,6 +921,7 @@ def get_large_tool_results( def get_session_efficiency( days: int = 7, project: str | None = None, + limit: int = 50, ) -> dict: """Analyze context efficiency and burn rate across sessions. @@ -922,12 +934,13 @@ def get_session_efficiency( Args: days: Number of days to analyze (default: 7) project: Optional project path filter + limit: Maximum sessions to return (default: 50) Returns: Session efficiency metrics sorted by total bytes consumed """ queries.ensure_fresh_data(storage, days=days) - result = queries.get_session_efficiency(storage, days=days, project=project) + result = queries.get_session_efficiency(storage, days=days, project=project, limit=limit) return {"status": "ok", **result} diff --git a/tests/test_patterns.py b/tests/test_patterns.py index 7f707b2..3412d3e 100644 --- a/tests/test_patterns.py +++ b/tests/test_patterns.py @@ -231,6 +231,71 @@ def test_permission_gaps_uses_fnmatch(self, pattern_storage): # git has no matching pattern, should still be a gap assert "git" in pattern_keys + def test_permission_gaps_filters_non_actionable_commands(self, storage): + """Test that non-actionable commands are filtered from permission gaps. + + Commands like pwd, cd, echo, and shell builtins should not appear + in permission gap results because they are not actionable. + """ + from session_analytics.patterns import NON_ACTIONABLE_COMMANDS + + now = datetime.now() + + # Add events for various non-actionable commands + events = [ + Event( + id=None, + uuid=f"e-{i}", + timestamp=now - timedelta(hours=i), + session_id="s1", + project_path="-test", + entry_type="tool_use", + tool_name="Bash", + command=cmd, + ) + for i, cmd in enumerate( + # Repeat non-actionable commands to exceed threshold + ["pwd"] * 10 + + ["cd"] * 10 + + ["echo"] * 10 + + ["#"] * 5 + + ["for"] * 5 + + ["hostname"] * 5 + # And one actionable command + + ["cargo"] * 10 + ) + ] + storage.add_events_batch(events) + + with tempfile.TemporaryDirectory() as tmpdir: + settings_path = Path(tmpdir) / "settings.json" + settings_path.write_text('{"permissions": {"allow": []}}') + + patterns = compute_permission_gaps( + storage, days=7, threshold=3, settings_path=settings_path + ) + + pattern_keys = {p.pattern_key for p in patterns} + + # Non-actionable commands should be filtered out + assert "pwd" not in pattern_keys + assert "cd" not in pattern_keys + assert "echo" not in pattern_keys + assert "#" not in pattern_keys + assert "for" not in pattern_keys + assert "hostname" not in pattern_keys + + # Actionable command should still appear + assert "cargo" in pattern_keys + + # Verify the constant has the expected commands + assert "pwd" in NON_ACTIONABLE_COMMANDS + assert "cd" in NON_ACTIONABLE_COMMANDS + assert "echo" in NON_ACTIONABLE_COMMANDS + assert "#" in NON_ACTIONABLE_COMMANDS + assert "for" in NON_ACTIONABLE_COMMANDS + assert "hostname" in NON_ACTIONABLE_COMMANDS + class TestComputeAllPatterns: """Tests for computing all patterns.""" diff --git a/tests/test_server.py b/tests/test_server.py index cdf00b6..7db5e72 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -8,6 +8,7 @@ detect_parallel_sessions, find_related_sessions, get_command_frequency, + get_compaction_events, get_file_activity, get_handoff_context, get_insights, @@ -16,6 +17,7 @@ get_permission_gaps, get_projects, get_session_commits, + get_session_efficiency, get_session_events, get_session_messages, get_session_signals, @@ -289,3 +291,76 @@ def test_get_mcp_usage(): assert "total_mcp_calls" in result assert "servers" in result assert isinstance(result["servers"], list) + + +# Issue #77: Limit parameters for verbose endpoints + + +def test_get_tool_sequences_limit(): + """Test that get_tool_sequences respects limit parameter.""" + result = get_tool_sequences.fn(days=7, limit=5) + assert result["status"] == "ok" + assert result["limit"] == 5 + assert "total_patterns" in result + assert len(result["sequences"]) <= 5 + + +def test_get_compaction_events(): + """Test that get_compaction_events returns compaction data.""" + result = get_compaction_events.fn(days=7, limit=10) + assert result["status"] == "ok" + assert result["limit"] == 10 + assert "total_compaction_count" in result + assert "compaction_count" in result + assert "compactions" in result + assert isinstance(result["compactions"], list) + assert len(result["compactions"]) <= 10 + + +def test_get_session_efficiency(): + """Test that get_session_efficiency returns efficiency metrics.""" + result = get_session_efficiency.fn(days=7, limit=10) + assert result["status"] == "ok" + assert result["limit"] == 10 + assert "session_count" in result + assert "sessions" in result + assert isinstance(result["sessions"], list) + + +# Issue #78: Efficiency metrics in analyze_trends + + +def test_analyze_trends_efficiency(): + """Test that analyze_trends includes efficiency metrics.""" + result = analyze_trends.fn(days=7, compare_to="previous") + assert result["status"] == "ok" + assert "efficiency" in result + efficiency = result["efficiency"] + assert "compactions" in efficiency + assert "avg_compactions_per_session" in efficiency + assert "files_read_multiple_times" in efficiency + assert "avg_result_mb_per_session" in efficiency + # Each should have current/previous/change_pct structure + assert "current" in efficiency["compactions"] + assert "previous" in efficiency["compactions"] + assert "change_pct" in efficiency["compactions"] + + +# Issue #79: Efficiency metrics in classify_sessions + + +def test_classify_sessions_efficiency(): + """Test that classify_sessions includes efficiency metrics.""" + result = classify_sessions.fn(days=7) + assert result["status"] == "ok" + assert "sessions" in result + # Check that sessions have efficiency data (if any sessions exist) + if result["sessions"]: + session = result["sessions"][0] + assert "efficiency" in session + efficiency = session["efficiency"] + assert "compaction_count" in efficiency + assert "total_result_mb" in efficiency + assert "files_read_multiple_times" in efficiency + assert "burn_rate" in efficiency + assert efficiency["burn_rate"] in ["high", "medium", "low"]