"""Tests for session analyzer — digest builder and LLM-based analysis.""" import io import json import subprocess import time from pathlib import Path from unittest.mock import MagicMock, patch import pytest from headroom.learn.analyzer import ( SessionAnalyzer, _build_digest, _call_cli_llm, _call_llm, _detect_default_model, _parse_llm_response, _resolve_timeout_secs, _strip_fenced_json, ) from headroom.learn.models import ( AnalysisResult, ErrorCategory, ProjectInfo, RecommendationTarget, SessionData, SessionEvent, ToolCall, ) def _project() -> ProjectInfo: return ProjectInfo( name="test-project", project_path=Path("/tmp/test-project"), data_path=Path("/tmp/test-data"), ) def _tc( name: str = "Bash", input_data: dict | None = None, output: str = "ok", is_error: bool = False, error_category: ErrorCategory = ErrorCategory.UNKNOWN, msg_index: int = 0, output_bytes: int = 0, ) -> ToolCall: return ToolCall( name=name, tool_call_id=f"tc_{msg_index}", input_data=input_data or {}, output=output, is_error=is_error, error_category=error_category, msg_index=msg_index, output_bytes=output_bytes or len(output), ) # ============================================================================= # Digest Builder Tests # ============================================================================= class TestDigestBuilder: def test_includes_project_info(self): project = _project() sessions = [SessionData(session_id="s1", tool_calls=[_tc()])] digest = _build_digest(project, sessions) assert "test-project" in digest assert "/tmp/test-project" in digest def test_includes_session_stats(self): sessions = [ SessionData( session_id="abc123", tool_calls=[_tc(msg_index=0), _tc(msg_index=1, is_error=True, output="Error!")], ) ] digest = _build_digest(_project(), sessions) assert "abc123" in digest assert "2 calls" in digest assert "1 failure" in digest def test_includes_tool_call_details(self): sessions = [ SessionData( session_id="s1", tool_calls=[ _tc( name="Read", input_data={"file_path": "/src/foo.py"}, output="contents", msg_index=0, ), _tc( name="Bash", input_data={"command": "python3 run.py"}, output="ModuleNotFoundError", is_error=True, error_category=ErrorCategory.MODULE_NOT_FOUND, msg_index=1, ), ], ) ] digest = _build_digest(_project(), sessions) assert "/src/foo.py" in digest assert "python3 run.py" in digest assert "ERROR" in digest assert "ModuleNotFoundError" in digest def test_includes_user_messages(self): tc = _tc(msg_index=0) events = [ SessionEvent(type="tool_call", msg_index=0, tool_call=tc), SessionEvent(type="user_message", msg_index=1, text="Use uv run instead"), ] sessions = [SessionData(session_id="s1", tool_calls=[tc], events=events)] digest = _build_digest(_project(), sessions) assert "USER:" in digest assert "Use uv run instead" in digest def test_includes_subagent_summaries(self): events = [ SessionEvent( type="agent_summary", msg_index=0, agent_tool_count=150, agent_tokens=60000, agent_prompt="Explore all test files", ), ] sessions = [SessionData(session_id="s1", events=events)] digest = _build_digest(_project(), sessions) assert "SUBAGENT" in digest assert "150 tool calls" in digest assert "Explore all test files" in digest def test_includes_interruptions(self): events = [ SessionEvent( type="interruption", msg_index=0, text="[Request interrupted by user]", ), ] sessions = [SessionData(session_id="s1", events=events)] digest = _build_digest(_project(), sessions) assert "INTERRUPTED" in digest def test_empty_sessions(self): digest = _build_digest(_project(), []) assert "0 sessions" in digest or "test-project" in digest # ============================================================================= # Prior Patterns Injection Tests # ============================================================================= _MARKER_BLOCK = ( "\n" "## Headroom Learned Patterns\n" "*Auto-generated by `headroom learn` on 2026-04-01 — do not edit manually*\n" "\n" "### Large Files\n" "- `src/App.tsx` is very large (~40k tokens) — use offset/limit reads\n" "- `src/lib.rs` frequently exceeds 10k tokens\n" "\n" "" ) def _project_with_files( tmp_path: Path, claude_md_text: str | None, memory_md_text: str | None ) -> ProjectInfo: """Build a ProjectInfo pointing at temp CLAUDE.md / MEMORY.md files.""" proj_dir = tmp_path / "proj" proj_dir.mkdir() data_dir = tmp_path / "data" (data_dir / "memory").mkdir(parents=True) context_file: Path | None = None if claude_md_text is not None: context_file = proj_dir / "CLAUDE.md" context_file.write_text(claude_md_text) memory_file: Path | None = None if memory_md_text is not None: memory_file = data_dir / "memory" / "MEMORY.md" memory_file.write_text(memory_md_text) return ProjectInfo( name="proj", project_path=proj_dir, data_path=data_dir, context_file=context_file, memory_file=memory_file, ) class TestPriorPatternsInjection: """The digest should include the prior marker block so the LLM can emit COMPLETE updated sections instead of condensed deltas that reference now-dropped siblings (the "X is also large — same rule as Y, Z" bug).""" def test_digest_includes_prior_block_from_claude_md(self, tmp_path): project = _project_with_files( tmp_path, claude_md_text=f"# Project\n\n{_MARKER_BLOCK}\n", memory_md_text=None ) digest = _build_digest(project, []) assert "Prior Learned Patterns" in digest assert "### Large Files" in digest assert "App.tsx" in digest def test_digest_includes_prior_block_from_memory_md(self, tmp_path): project = _project_with_files( tmp_path, claude_md_text=None, memory_md_text=f"{_MARKER_BLOCK}\n" ) digest = _build_digest(project, []) assert "Prior Learned Patterns" in digest assert "MEMORY.md" in digest assert "### Large Files" in digest def test_digest_omits_section_when_no_files_exist(self, tmp_path): project = _project_with_files(tmp_path, claude_md_text=None, memory_md_text=None) digest = _build_digest(project, []) assert "Prior Learned Patterns" not in digest assert "