## Skills Library - Browse and install 36+ community skills from Anthropic & Superpowers - Local skill templates (Rails, React, testing) - Create custom project-specific skills - Beautiful UI with search and filtering - /claude-os-skills command for CLI management ## Session Insights - Parse Claude Code native .jsonl session files - Extract patterns, decisions, blockers automatically - Track tool usage and file changes - Session summary generation for LLM processing ## Documentation - Updated README with v2.3 features - Updated API_REFERENCE with new endpoints - Added SKILLS_GUIDE.md and SESSIONS_GUIDE.md - Updated GitHub Pages (docs/index.html) - Reorganized docs (internal_docs for private) ## Bug Fixes - Fixed httpx client closure in community skill install - Fixed frontend request format for skill install 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
260 lines
8.1 KiB
Python
260 lines
8.1 KiB
Python
"""
|
|
Tests for session parser and insight extractor.
|
|
"""
|
|
|
|
import json
|
|
import pytest
|
|
from pathlib import Path
|
|
from app.core.session_parser import (
|
|
SessionParser,
|
|
get_project_sessions_dir,
|
|
list_session_files,
|
|
Message,
|
|
ToolCall,
|
|
FileChange,
|
|
SessionData
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def sample_session_file(tmp_path):
|
|
"""Create a sample session file for testing."""
|
|
session_file = tmp_path / "test-session.jsonl"
|
|
|
|
entries = [
|
|
{
|
|
"type": "summary",
|
|
"summary": "Test session summary",
|
|
"leafUuid": "abc123"
|
|
},
|
|
{
|
|
"type": "user",
|
|
"uuid": "msg-001",
|
|
"parentUuid": None,
|
|
"timestamp": "2025-12-11T10:00:00Z",
|
|
"cwd": "/Users/test/Projects/myapp",
|
|
"gitBranch": "main",
|
|
"sessionId": "test-session",
|
|
"message": {
|
|
"role": "user",
|
|
"content": "Hello, can you help me with testing?"
|
|
}
|
|
},
|
|
{
|
|
"type": "assistant",
|
|
"uuid": "msg-002",
|
|
"parentUuid": "msg-001",
|
|
"timestamp": "2025-12-11T10:00:05Z",
|
|
"message": {
|
|
"role": "assistant",
|
|
"content": [
|
|
{"type": "text", "text": "Of course! I can help you with testing."}
|
|
]
|
|
}
|
|
},
|
|
{
|
|
"type": "user",
|
|
"uuid": "msg-003",
|
|
"parentUuid": "msg-002",
|
|
"timestamp": "2025-12-11T10:00:10Z",
|
|
"message": {
|
|
"role": "user",
|
|
"content": "Can you write a test for me?"
|
|
}
|
|
},
|
|
{
|
|
"type": "assistant",
|
|
"uuid": "msg-004",
|
|
"parentUuid": "msg-003",
|
|
"timestamp": "2025-12-11T10:00:15Z",
|
|
"message": {
|
|
"role": "assistant",
|
|
"content": [
|
|
{"type": "text", "text": "I'll write a test for you."},
|
|
{
|
|
"type": "tool_use",
|
|
"id": "tool-001",
|
|
"name": "Write",
|
|
"input": {
|
|
"file_path": "/Users/test/Projects/myapp/test.py",
|
|
"content": "def test_example():\n pass"
|
|
}
|
|
}
|
|
]
|
|
}
|
|
},
|
|
{
|
|
"type": "file-history-snapshot",
|
|
"messageId": "msg-004",
|
|
"snapshot": {
|
|
"messageId": "msg-004",
|
|
"timestamp": "2025-12-11T10:00:20Z",
|
|
"trackedFileBackups": {
|
|
"/Users/test/Projects/myapp/test.py": {"content": "def test_example():\n pass"}
|
|
}
|
|
}
|
|
}
|
|
]
|
|
|
|
with open(session_file, 'w') as f:
|
|
for entry in entries:
|
|
f.write(json.dumps(entry) + '\n')
|
|
|
|
return session_file
|
|
|
|
|
|
def test_session_parser_init(sample_session_file):
|
|
"""Test SessionParser initialization."""
|
|
parser = SessionParser(str(sample_session_file))
|
|
assert parser.path == sample_session_file
|
|
assert parser.session_id == "test-session"
|
|
|
|
|
|
def test_session_parser_parse(sample_session_file):
|
|
"""Test parsing a session file."""
|
|
parser = SessionParser(str(sample_session_file))
|
|
session_data = parser.parse()
|
|
|
|
# Check basic metadata
|
|
assert session_data.session_id == "test-session"
|
|
assert session_data.cwd == "/Users/test/Projects/myapp"
|
|
assert session_data.git_branch == "main"
|
|
assert session_data.total_entries == 6
|
|
|
|
# Check messages (2 user + 2 assistant = 4 messages)
|
|
assert len(session_data.messages) == 4
|
|
assert session_data.messages[0].role == "user"
|
|
assert "help me with testing" in session_data.messages[0].content
|
|
assert session_data.messages[1].role == "assistant"
|
|
assert "help you with testing" in session_data.messages[1].content
|
|
|
|
# Check tool calls
|
|
assert len(session_data.tool_calls) == 1
|
|
assert session_data.tool_calls[0].tool_name == "Write"
|
|
assert session_data.tool_calls[0].input_data["file_path"] == "/Users/test/Projects/myapp/test.py"
|
|
|
|
# Check file changes
|
|
assert len(session_data.file_changes) == 1
|
|
assert session_data.file_changes[0].file_path == "/Users/test/Projects/myapp/test.py"
|
|
|
|
|
|
def test_session_parser_get_conversation(sample_session_file):
|
|
"""Test getting conversation messages."""
|
|
parser = SessionParser(str(sample_session_file))
|
|
messages = parser.get_conversation()
|
|
|
|
assert len(messages) == 4 # 2 user + 2 assistant messages
|
|
assert all(isinstance(msg, Message) for msg in messages)
|
|
assert messages[0].role == "user"
|
|
assert messages[1].role == "assistant"
|
|
|
|
|
|
def test_session_parser_get_summary(sample_session_file):
|
|
"""Test generating summary for extraction."""
|
|
parser = SessionParser(str(sample_session_file))
|
|
summary = parser.get_summary_for_extraction()
|
|
|
|
assert "test-session" in summary
|
|
assert "/Users/test/Projects/myapp" in summary
|
|
assert "main" in summary
|
|
assert "help me with testing" in summary
|
|
assert "Write" in summary
|
|
|
|
|
|
def test_get_project_sessions_dir():
|
|
"""Test encoding project path to sessions directory."""
|
|
project_path = "/Users/test/Projects/myapp"
|
|
sessions_dir = get_project_sessions_dir(project_path)
|
|
|
|
# Leading hyphen is preserved from the path encoding
|
|
expected = Path.home() / ".claude" / "projects" / "-Users-test-Projects-myapp"
|
|
assert sessions_dir == expected
|
|
|
|
|
|
def test_list_session_files_empty_dir(tmp_path):
|
|
"""Test listing sessions when directory doesn't exist."""
|
|
fake_project = str(tmp_path / "nonexistent")
|
|
sessions = list_session_files(fake_project)
|
|
assert sessions == []
|
|
|
|
|
|
def test_session_parser_invalid_file():
|
|
"""Test parser with non-existent file."""
|
|
with pytest.raises(FileNotFoundError):
|
|
parser = SessionParser("/path/to/nonexistent/file.jsonl")
|
|
|
|
|
|
def test_session_parser_malformed_json(tmp_path):
|
|
"""Test parser handles malformed JSON gracefully."""
|
|
session_file = tmp_path / "malformed.jsonl"
|
|
|
|
with open(session_file, 'w') as f:
|
|
f.write('{"type": "user", "content": "valid"}\n')
|
|
f.write('this is not json\n')
|
|
f.write('{"type": "assistant", "content": "also valid"}\n')
|
|
|
|
parser = SessionParser(str(session_file))
|
|
session_data = parser.parse()
|
|
|
|
# Should parse the valid lines, skip the malformed one
|
|
assert session_data.total_entries == 2
|
|
|
|
|
|
def test_session_data_defaults():
|
|
"""Test SessionData dataclass defaults."""
|
|
data = SessionData(
|
|
session_id="test",
|
|
session_path="/path/to/session.jsonl"
|
|
)
|
|
|
|
assert data.messages == []
|
|
assert data.tool_calls == []
|
|
assert data.file_changes == []
|
|
assert data.start_time is None
|
|
assert data.end_time is None
|
|
assert data.total_entries == 0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_insight_extractor_import():
|
|
"""Test that InsightExtractor can be imported."""
|
|
from app.core.insight_extractor import InsightExtractor, Insight
|
|
|
|
# Just verify imports work
|
|
assert InsightExtractor is not None
|
|
assert Insight is not None
|
|
|
|
|
|
def test_session_parser_truncation(tmp_path):
|
|
"""Test that very long sessions are truncated in summary."""
|
|
session_file = tmp_path / "long-session.jsonl"
|
|
|
|
# Create a very long message
|
|
long_content = "A" * 2000
|
|
|
|
entries = [
|
|
{
|
|
"type": "user",
|
|
"uuid": "msg-001",
|
|
"parentUuid": None,
|
|
"timestamp": "2025-12-11T10:00:00Z",
|
|
"cwd": "/test",
|
|
"sessionId": "long-session",
|
|
"message": {
|
|
"role": "user",
|
|
"content": long_content
|
|
}
|
|
}
|
|
]
|
|
|
|
with open(session_file, 'w') as f:
|
|
for entry in entries:
|
|
f.write(json.dumps(entry) + '\n')
|
|
|
|
parser = SessionParser(str(session_file))
|
|
summary = parser.get_summary_for_extraction(max_tokens=100)
|
|
|
|
# Summary should be shorter than the original content
|
|
# (header adds overhead, so just verify truncation occurred)
|
|
assert len(summary) < len(long_content)
|
|
assert "[... truncated ...]" in summary
|