forked from Rockachopa/Timmy-time-dashboard
Co-authored-by: Kimi Agent <kimi@timmy.local> Co-committed-by: Kimi Agent <kimi@timmy.local>
1083 lines
37 KiB
Python
1083 lines
37 KiB
Python
"""Comprehensive unit tests for src/timmy/session_logger.py."""
|
|
|
|
import json
|
|
from datetime import date
|
|
from pathlib import Path
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from timmy.session_logger import (
|
|
SessionLogger,
|
|
flush_session_logs,
|
|
get_session_logger,
|
|
get_session_summary,
|
|
self_reflect,
|
|
session_history,
|
|
)
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def reset_global_singleton(monkeypatch):
|
|
"""Reset global singleton between tests."""
|
|
monkeypatch.setattr("timmy.session_logger._session_logger", None)
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_settings(tmp_path):
|
|
"""Mock config.settings.repo_root to use tmp_path."""
|
|
mock_settings = MagicMock()
|
|
mock_settings.repo_root = str(tmp_path)
|
|
# Patch at the source where settings is imported from
|
|
with patch("config.settings", mock_settings):
|
|
yield mock_settings
|
|
|
|
|
|
class TestSessionLoggerInit:
|
|
"""Tests for SessionLogger initialization."""
|
|
|
|
def test_init_with_custom_logs_dir(self, tmp_path):
|
|
"""Should initialize with custom logs_dir using tmp_path."""
|
|
logs_dir = tmp_path / "custom_logs"
|
|
logger = SessionLogger(logs_dir=logs_dir)
|
|
|
|
assert logger.logs_dir == logs_dir
|
|
assert logs_dir.exists()
|
|
|
|
def test_init_default_logs_dir(self, mock_settings, tmp_path):
|
|
"""Should default to /logs in repo root when logs_dir is None."""
|
|
logger = SessionLogger()
|
|
|
|
assert logger.logs_dir == tmp_path / "logs"
|
|
assert logger.logs_dir.exists()
|
|
|
|
def test_init_creates_session_file_path(self, tmp_path):
|
|
"""Should create session file path with today's date."""
|
|
logger = SessionLogger(logs_dir=tmp_path)
|
|
|
|
today_str = date.today().isoformat()
|
|
expected_path = tmp_path / f"session_{today_str}.jsonl"
|
|
assert logger.session_file == expected_path
|
|
|
|
def test_init_initializes_empty_buffer(self, tmp_path):
|
|
"""Should initialize with empty buffer."""
|
|
logger = SessionLogger(logs_dir=tmp_path)
|
|
|
|
assert logger._buffer == []
|
|
|
|
|
|
class TestRecordMessage:
|
|
"""Tests for record_message method."""
|
|
|
|
def test_record_message_without_confidence(self, tmp_path):
|
|
"""Should record message without confidence."""
|
|
logger = SessionLogger(logs_dir=tmp_path)
|
|
|
|
logger.record_message("user", "Hello Timmy")
|
|
|
|
assert len(logger._buffer) == 1
|
|
entry = logger._buffer[0]
|
|
assert entry["type"] == "message"
|
|
assert entry["role"] == "user"
|
|
assert entry["content"] == "Hello Timmy"
|
|
assert "timestamp" in entry
|
|
assert "confidence" not in entry
|
|
|
|
def test_record_message_with_confidence(self, tmp_path):
|
|
"""Should record message with confidence."""
|
|
logger = SessionLogger(logs_dir=tmp_path)
|
|
|
|
logger.record_message("timmy", "Hello user", confidence=0.95)
|
|
|
|
assert len(logger._buffer) == 1
|
|
entry = logger._buffer[0]
|
|
assert entry["type"] == "message"
|
|
assert entry["role"] == "timmy"
|
|
assert entry["content"] == "Hello user"
|
|
assert entry["confidence"] == 0.95
|
|
|
|
def test_record_message_multiple(self, tmp_path):
|
|
"""Should record multiple messages."""
|
|
logger = SessionLogger(logs_dir=tmp_path)
|
|
|
|
logger.record_message("user", "First message")
|
|
logger.record_message("timmy", "Second message", confidence=0.8)
|
|
logger.record_message("user", "Third message")
|
|
|
|
assert len(logger._buffer) == 3
|
|
assert logger._buffer[0]["role"] == "user"
|
|
assert logger._buffer[1]["role"] == "timmy"
|
|
assert logger._buffer[2]["role"] == "user"
|
|
|
|
|
|
class TestRecordToolCall:
|
|
"""Tests for record_tool_call method."""
|
|
|
|
def test_record_tool_call_basic(self, tmp_path):
|
|
"""Should record basic tool call."""
|
|
logger = SessionLogger(logs_dir=tmp_path)
|
|
|
|
logger.record_tool_call("read_file", {"path": "test.py"}, "file content")
|
|
|
|
assert len(logger._buffer) == 1
|
|
entry = logger._buffer[0]
|
|
assert entry["type"] == "tool_call"
|
|
assert entry["tool"] == "read_file"
|
|
assert entry["args"] == {"path": "test.py"}
|
|
assert entry["result"] == "file content"
|
|
assert "timestamp" in entry
|
|
|
|
def test_record_tool_call_result_truncation_over_500_chars(self, tmp_path):
|
|
"""Should truncate result when over 500 characters."""
|
|
logger = SessionLogger(logs_dir=tmp_path)
|
|
|
|
long_result = "a" * 600
|
|
logger.record_tool_call("search", {"query": "test"}, long_result)
|
|
|
|
entry = logger._buffer[0]
|
|
assert len(entry["result"]) == 500
|
|
assert entry["result"] == "a" * 500
|
|
|
|
def test_record_tool_call_result_exactly_500_chars(self, tmp_path):
|
|
"""Should not truncate result when exactly 500 characters."""
|
|
logger = SessionLogger(logs_dir=tmp_path)
|
|
|
|
exact_result = "b" * 500
|
|
logger.record_tool_call("read_file", {"path": "file.txt"}, exact_result)
|
|
|
|
entry = logger._buffer[0]
|
|
assert len(entry["result"]) == 500
|
|
assert entry["result"] == exact_result
|
|
|
|
def test_record_tool_call_result_under_500_chars(self, tmp_path):
|
|
"""Should not truncate result when under 500 characters."""
|
|
logger = SessionLogger(logs_dir=tmp_path)
|
|
|
|
short_result = "short result"
|
|
logger.record_tool_call("calc", {"expr": "1+1"}, short_result)
|
|
|
|
entry = logger._buffer[0]
|
|
assert entry["result"] == short_result
|
|
|
|
def test_record_tool_call_non_string_result(self, tmp_path):
|
|
"""Should handle non-string result."""
|
|
logger = SessionLogger(logs_dir=tmp_path)
|
|
|
|
logger.record_tool_call("calc", {"expr": "1+1"}, 42)
|
|
|
|
entry = logger._buffer[0]
|
|
assert entry["result"] == "42"
|
|
|
|
def test_record_tool_call_non_string_result_truncation(self, tmp_path):
|
|
"""Should truncate non-string result over 500 chars."""
|
|
logger = SessionLogger(logs_dir=tmp_path)
|
|
|
|
long_list = list(range(200)) # Will be longer than 500 chars as string
|
|
logger.record_tool_call("search", {"query": "test"}, long_list)
|
|
|
|
entry = logger._buffer[0]
|
|
assert len(entry["result"]) == 500
|
|
|
|
|
|
class TestRecordError:
|
|
"""Tests for record_error method."""
|
|
|
|
def test_record_error_without_context(self, tmp_path):
|
|
"""Should record error without context."""
|
|
logger = SessionLogger(logs_dir=tmp_path)
|
|
|
|
logger.record_error("File not found")
|
|
|
|
assert len(logger._buffer) == 1
|
|
entry = logger._buffer[0]
|
|
assert entry["type"] == "error"
|
|
assert entry["error"] == "File not found"
|
|
assert entry["context"] is None
|
|
assert "timestamp" in entry
|
|
|
|
def test_record_error_with_context(self, tmp_path):
|
|
"""Should record error with context."""
|
|
logger = SessionLogger(logs_dir=tmp_path)
|
|
|
|
logger.record_error("Connection failed", "Reading config file")
|
|
|
|
assert len(logger._buffer) == 1
|
|
entry = logger._buffer[0]
|
|
assert entry["type"] == "error"
|
|
assert entry["error"] == "Connection failed"
|
|
assert entry["context"] == "Reading config file"
|
|
|
|
def test_record_error_multiple(self, tmp_path):
|
|
"""Should record multiple errors."""
|
|
logger = SessionLogger(logs_dir=tmp_path)
|
|
|
|
logger.record_error("Error 1", "Context 1")
|
|
logger.record_error("Error 2")
|
|
logger.record_error("Error 3", "Context 3")
|
|
|
|
assert len(logger._buffer) == 3
|
|
assert logger._buffer[0]["error"] == "Error 1"
|
|
assert logger._buffer[1]["context"] is None
|
|
assert logger._buffer[2]["error"] == "Error 3"
|
|
|
|
|
|
class TestRecordDecision:
|
|
"""Tests for record_decision method."""
|
|
|
|
def test_record_decision_without_rationale(self, tmp_path):
|
|
"""Should record decision without rationale."""
|
|
logger = SessionLogger(logs_dir=tmp_path)
|
|
|
|
logger.record_decision("Use OOP pattern")
|
|
|
|
assert len(logger._buffer) == 1
|
|
entry = logger._buffer[0]
|
|
assert entry["type"] == "decision"
|
|
assert entry["decision"] == "Use OOP pattern"
|
|
assert entry["rationale"] is None
|
|
assert "timestamp" in entry
|
|
|
|
def test_record_decision_with_rationale(self, tmp_path):
|
|
"""Should record decision with rationale."""
|
|
logger = SessionLogger(logs_dir=tmp_path)
|
|
|
|
logger.record_decision("Refactor to classes", "More maintainable code")
|
|
|
|
assert len(logger._buffer) == 1
|
|
entry = logger._buffer[0]
|
|
assert entry["type"] == "decision"
|
|
assert entry["decision"] == "Refactor to classes"
|
|
assert entry["rationale"] == "More maintainable code"
|
|
|
|
def test_record_decision_multiple(self, tmp_path):
|
|
"""Should record multiple decisions."""
|
|
logger = SessionLogger(logs_dir=tmp_path)
|
|
|
|
logger.record_decision("Decision 1")
|
|
logger.record_decision("Decision 2", "Reason 2")
|
|
logger.record_decision("Decision 3", "Reason 3")
|
|
|
|
assert len(logger._buffer) == 3
|
|
assert logger._buffer[0]["rationale"] is None
|
|
assert logger._buffer[1]["rationale"] == "Reason 2"
|
|
|
|
|
|
class TestFlush:
|
|
"""Tests for flush method."""
|
|
|
|
def test_flush_writes_jsonl(self, tmp_path):
|
|
"""Should write buffer to JSONL file."""
|
|
logger = SessionLogger(logs_dir=tmp_path)
|
|
logger.record_message("user", "Hello")
|
|
logger.record_tool_call("read", {"path": "test.txt"}, "content")
|
|
|
|
result_path = logger.flush()
|
|
|
|
assert result_path == logger.session_file
|
|
assert result_path.exists()
|
|
|
|
# Verify JSONL format
|
|
lines = result_path.read_text().strip().split("\n")
|
|
assert len(lines) == 2
|
|
|
|
# Verify each line is valid JSON
|
|
entry1 = json.loads(lines[0])
|
|
assert entry1["type"] == "message"
|
|
assert entry1["content"] == "Hello"
|
|
|
|
entry2 = json.loads(lines[1])
|
|
assert entry2["type"] == "tool_call"
|
|
assert entry2["tool"] == "read"
|
|
|
|
def test_flush_clears_buffer(self, tmp_path):
|
|
"""Should clear buffer after flush."""
|
|
logger = SessionLogger(logs_dir=tmp_path)
|
|
logger.record_message("user", "Hello")
|
|
logger.record_error("Test error")
|
|
|
|
assert len(logger._buffer) == 2
|
|
|
|
logger.flush()
|
|
|
|
assert len(logger._buffer) == 0
|
|
assert logger._buffer == []
|
|
|
|
def test_flush_returns_path(self, tmp_path):
|
|
"""Should return path to session file."""
|
|
logger = SessionLogger(logs_dir=tmp_path)
|
|
logger.record_message("user", "Hello")
|
|
|
|
result_path = logger.flush()
|
|
|
|
assert isinstance(result_path, Path)
|
|
assert result_path == logger.session_file
|
|
|
|
def test_flush_empty_buffer_returns_path_without_writing(self, tmp_path):
|
|
"""Should return path without writing when buffer is empty."""
|
|
logger = SessionLogger(logs_dir=tmp_path)
|
|
|
|
result_path = logger.flush()
|
|
|
|
assert isinstance(result_path, Path)
|
|
assert result_path == logger.session_file
|
|
# File should not exist since buffer was empty
|
|
assert not result_path.exists()
|
|
|
|
def test_flush_appends_to_existing_file(self, tmp_path):
|
|
"""Should append to existing file on subsequent flushes."""
|
|
logger = SessionLogger(logs_dir=tmp_path)
|
|
|
|
logger.record_message("user", "First")
|
|
logger.flush()
|
|
|
|
logger.record_message("user", "Second")
|
|
logger.record_error("Error")
|
|
logger.flush()
|
|
|
|
lines = logger.session_file.read_text().strip().split("\n")
|
|
assert len(lines) == 3
|
|
|
|
entries = [json.loads(line) for line in lines]
|
|
assert entries[0]["content"] == "First"
|
|
assert entries[1]["content"] == "Second"
|
|
assert entries[2]["type"] == "error"
|
|
|
|
|
|
class TestGetSessionSummary:
|
|
"""Tests for get_session_summary method."""
|
|
|
|
def test_get_session_summary_file_not_exist(self, tmp_path):
|
|
"""Should return exists=False when file does not exist."""
|
|
logger = SessionLogger(logs_dir=tmp_path)
|
|
|
|
summary = logger.get_session_summary()
|
|
|
|
assert summary["exists"] is False
|
|
assert summary["entries"] == 0
|
|
|
|
def test_get_session_summary_after_flush(self, tmp_path):
|
|
"""Should return summary after flushing entries."""
|
|
logger = SessionLogger(logs_dir=tmp_path)
|
|
|
|
logger.record_message("user", "Hello")
|
|
logger.record_message("timmy", "Hi")
|
|
logger.record_tool_call("read", {"path": "test.txt"}, "content")
|
|
logger.record_error("Test error")
|
|
logger.record_decision("Use pattern")
|
|
|
|
logger.flush()
|
|
|
|
summary = logger.get_session_summary()
|
|
|
|
assert summary["exists"] is True
|
|
assert summary["file"] == str(logger.session_file)
|
|
assert summary["entries"] == 5
|
|
assert summary["messages"] == 2
|
|
assert summary["tool_calls"] == 1
|
|
assert summary["errors"] == 1
|
|
assert summary["decisions"] == 1
|
|
|
|
def test_get_session_summary_empty_file(self, tmp_path):
|
|
"""Should handle empty file."""
|
|
logger = SessionLogger(logs_dir=tmp_path)
|
|
|
|
# Create empty file
|
|
logger.session_file.touch()
|
|
|
|
summary = logger.get_session_summary()
|
|
|
|
assert summary["exists"] is True
|
|
assert summary["entries"] == 0
|
|
|
|
def test_get_session_summary_with_blank_lines(self, tmp_path):
|
|
"""Should handle file with blank lines."""
|
|
logger = SessionLogger(logs_dir=tmp_path)
|
|
|
|
# Create file with blank lines
|
|
logger.session_file.write_text(
|
|
'{"type": "message", "content": "Hello"}\n\n{"type": "error", "error": "Oops"}\n'
|
|
)
|
|
|
|
summary = logger.get_session_summary()
|
|
|
|
assert summary["exists"] is True
|
|
assert summary["entries"] == 2
|
|
|
|
|
|
class TestModuleLevelFunctions:
|
|
"""Tests for module-level functions."""
|
|
|
|
def test_get_session_logger_returns_singleton(self, mock_settings, tmp_path):
|
|
"""Should return same singleton instance."""
|
|
logger1 = get_session_logger()
|
|
logger2 = get_session_logger()
|
|
|
|
assert logger1 is logger2
|
|
assert isinstance(logger1, SessionLogger)
|
|
|
|
def test_get_session_logger_creates_new_when_none(self, mock_settings, tmp_path):
|
|
"""Should create new logger when singleton is None."""
|
|
logger = get_session_logger()
|
|
|
|
assert isinstance(logger, SessionLogger)
|
|
assert logger.logs_dir == tmp_path / "logs"
|
|
|
|
def test_get_session_summary_module_function(self, mock_settings, tmp_path):
|
|
"""Should get summary via module-level function."""
|
|
# First, create and flush some logs
|
|
logger = get_session_logger()
|
|
logger.record_message("user", "Test")
|
|
logger.flush()
|
|
|
|
# Call module-level function
|
|
summary = get_session_summary()
|
|
|
|
assert summary["exists"] is True
|
|
assert summary["entries"] >= 1
|
|
assert summary["messages"] >= 1
|
|
|
|
def test_flush_session_logs_module_function(self, mock_settings, tmp_path):
|
|
"""Should flush logs via module-level function."""
|
|
logger = get_session_logger()
|
|
logger.record_message("user", "Test message")
|
|
|
|
# Call module-level function
|
|
result_path_str = flush_session_logs()
|
|
|
|
assert isinstance(result_path_str, str)
|
|
assert result_path_str == str(logger.session_file)
|
|
assert logger._buffer == []
|
|
|
|
def test_flush_session_logs_returns_string_path(self, mock_settings, tmp_path):
|
|
"""Should return string path, not Path object."""
|
|
get_session_logger()
|
|
|
|
result = flush_session_logs()
|
|
|
|
assert isinstance(result, str)
|
|
|
|
|
|
class TestIntegration:
|
|
"""Integration tests for SessionLogger."""
|
|
|
|
def test_full_session_workflow(self, tmp_path):
|
|
"""Should handle full session workflow."""
|
|
logger = SessionLogger(logs_dir=tmp_path)
|
|
|
|
# Simulate a session
|
|
logger.record_message("user", "What is 2+2?")
|
|
logger.record_decision("Use calculator tool", "User needs calculation")
|
|
logger.record_tool_call("calculate", {"expr": "2+2"}, "4")
|
|
logger.record_message("timmy", "2+2 is 4", confidence=0.99)
|
|
|
|
# Verify buffer state
|
|
assert len(logger._buffer) == 4
|
|
|
|
# Flush and verify
|
|
logger.flush()
|
|
assert len(logger._buffer) == 0
|
|
|
|
# Verify summary
|
|
summary = logger.get_session_summary()
|
|
assert summary["entries"] == 4
|
|
assert summary["messages"] == 2
|
|
assert summary["decisions"] == 1
|
|
assert summary["tool_calls"] == 1
|
|
assert summary["errors"] == 0
|
|
|
|
# Verify file content
|
|
lines = logger.session_file.read_text().strip().split("\n")
|
|
assert len(lines) == 4
|
|
|
|
# Load and verify structure
|
|
entries = [json.loads(line) for line in lines]
|
|
assert entries[0]["type"] == "message"
|
|
assert entries[1]["type"] == "decision"
|
|
assert entries[2]["type"] == "tool_call"
|
|
assert entries[3]["type"] == "message"
|
|
|
|
def test_logs_dir_created_as_string(self, tmp_path):
|
|
"""Should accept logs_dir as string."""
|
|
logs_dir_str = str(tmp_path / "string_logs")
|
|
logger = SessionLogger(logs_dir=logs_dir_str)
|
|
|
|
assert logger.logs_dir == Path(logs_dir_str)
|
|
assert logger.logs_dir.exists()
|
|
|
|
def test_logs_dir_created_with_parents(self, tmp_path):
|
|
"""Should create parent directories if needed."""
|
|
deep_logs_dir = tmp_path / "very" / "deep" / "logs"
|
|
logger = SessionLogger(logs_dir=deep_logs_dir)
|
|
|
|
assert logger.logs_dir == deep_logs_dir
|
|
assert deep_logs_dir.exists()
|
|
|
|
|
|
class TestSearch:
|
|
"""Tests for SessionLogger.search() method."""
|
|
|
|
def _write_session_file(self, logs_dir, filename, entries):
|
|
"""Helper: write JSONL entries to a session file."""
|
|
path = logs_dir / filename
|
|
with open(path, "w") as f:
|
|
for entry in entries:
|
|
f.write(json.dumps(entry) + "\n")
|
|
|
|
def test_search_finds_message_by_content(self, tmp_path):
|
|
"""Should find messages matching query in content field."""
|
|
sl = SessionLogger(logs_dir=tmp_path)
|
|
self._write_session_file(
|
|
tmp_path,
|
|
"session_2026-03-01.jsonl",
|
|
[
|
|
{
|
|
"type": "message",
|
|
"role": "user",
|
|
"content": "Tell me about bitcoin",
|
|
"timestamp": "2026-03-01T10:00:00",
|
|
},
|
|
{
|
|
"type": "message",
|
|
"role": "timmy",
|
|
"content": "Bitcoin is a cryptocurrency",
|
|
"timestamp": "2026-03-01T10:01:00",
|
|
},
|
|
{
|
|
"type": "message",
|
|
"role": "user",
|
|
"content": "What about ethereum?",
|
|
"timestamp": "2026-03-01T10:02:00",
|
|
},
|
|
],
|
|
)
|
|
results = sl.search("bitcoin")
|
|
assert len(results) == 2
|
|
assert all("bitcoin" in r.get("content", "").lower() for r in results)
|
|
|
|
def test_search_case_insensitive(self, tmp_path):
|
|
"""Should match regardless of case."""
|
|
sl = SessionLogger(logs_dir=tmp_path)
|
|
self._write_session_file(
|
|
tmp_path,
|
|
"session_2026-03-01.jsonl",
|
|
[
|
|
{
|
|
"type": "message",
|
|
"role": "user",
|
|
"content": "BITCOIN price?",
|
|
"timestamp": "2026-03-01T10:00:00",
|
|
},
|
|
],
|
|
)
|
|
results = sl.search("bitcoin")
|
|
assert len(results) == 1
|
|
|
|
def test_search_role_filter(self, tmp_path):
|
|
"""Should filter by role when specified."""
|
|
sl = SessionLogger(logs_dir=tmp_path)
|
|
self._write_session_file(
|
|
tmp_path,
|
|
"session_2026-03-01.jsonl",
|
|
[
|
|
{
|
|
"type": "message",
|
|
"role": "user",
|
|
"content": "Tell me about bitcoin",
|
|
"timestamp": "2026-03-01T10:00:00",
|
|
},
|
|
{
|
|
"type": "message",
|
|
"role": "timmy",
|
|
"content": "Bitcoin is great",
|
|
"timestamp": "2026-03-01T10:01:00",
|
|
},
|
|
],
|
|
)
|
|
results = sl.search("bitcoin", role="user")
|
|
assert len(results) == 1
|
|
assert results[0]["role"] == "user"
|
|
|
|
def test_search_respects_limit(self, tmp_path):
|
|
"""Should return at most 'limit' results."""
|
|
sl = SessionLogger(logs_dir=tmp_path)
|
|
entries = [
|
|
{
|
|
"type": "message",
|
|
"role": "user",
|
|
"content": f"bitcoin msg {i}",
|
|
"timestamp": f"2026-03-01T10:{i:02d}:00",
|
|
}
|
|
for i in range(20)
|
|
]
|
|
self._write_session_file(tmp_path, "session_2026-03-01.jsonl", entries)
|
|
results = sl.search("bitcoin", limit=5)
|
|
assert len(results) == 5
|
|
|
|
def test_search_returns_newest_first(self, tmp_path):
|
|
"""Should return most recent matches first."""
|
|
sl = SessionLogger(logs_dir=tmp_path)
|
|
self._write_session_file(
|
|
tmp_path,
|
|
"session_2026-03-01.jsonl",
|
|
[
|
|
{
|
|
"type": "message",
|
|
"role": "user",
|
|
"content": "old bitcoin",
|
|
"timestamp": "2026-03-01T08:00:00",
|
|
},
|
|
],
|
|
)
|
|
self._write_session_file(
|
|
tmp_path,
|
|
"session_2026-03-02.jsonl",
|
|
[
|
|
{
|
|
"type": "message",
|
|
"role": "user",
|
|
"content": "new bitcoin",
|
|
"timestamp": "2026-03-02T10:00:00",
|
|
},
|
|
],
|
|
)
|
|
results = sl.search("bitcoin")
|
|
assert len(results) == 2
|
|
# Newest file first (03-02 > 03-01)
|
|
assert "new" in results[0]["content"]
|
|
assert "old" in results[1]["content"]
|
|
|
|
def test_search_across_multiple_files(self, tmp_path):
|
|
"""Should search across all session files."""
|
|
sl = SessionLogger(logs_dir=tmp_path)
|
|
for day in range(1, 4):
|
|
self._write_session_file(
|
|
tmp_path,
|
|
f"session_2026-03-{day:02d}.jsonl",
|
|
[
|
|
{
|
|
"type": "message",
|
|
"role": "user",
|
|
"content": f"day {day} bitcoin",
|
|
"timestamp": f"2026-03-{day:02d}T10:00:00",
|
|
},
|
|
],
|
|
)
|
|
results = sl.search("bitcoin")
|
|
assert len(results) == 3
|
|
|
|
def test_search_finds_errors(self, tmp_path):
|
|
"""Should match errors by error field."""
|
|
sl = SessionLogger(logs_dir=tmp_path)
|
|
self._write_session_file(
|
|
tmp_path,
|
|
"session_2026-03-01.jsonl",
|
|
[
|
|
{
|
|
"type": "error",
|
|
"error": "Connection refused to ollama",
|
|
"timestamp": "2026-03-01T10:00:00",
|
|
},
|
|
{
|
|
"type": "message",
|
|
"role": "user",
|
|
"content": "hello",
|
|
"timestamp": "2026-03-01T10:01:00",
|
|
},
|
|
],
|
|
)
|
|
results = sl.search("ollama")
|
|
assert len(results) == 1
|
|
assert results[0]["type"] == "error"
|
|
|
|
def test_search_finds_tool_calls(self, tmp_path):
|
|
"""Should match tool calls by tool name and result."""
|
|
sl = SessionLogger(logs_dir=tmp_path)
|
|
self._write_session_file(
|
|
tmp_path,
|
|
"session_2026-03-01.jsonl",
|
|
[
|
|
{
|
|
"type": "tool_call",
|
|
"tool": "calculator",
|
|
"args": {"expr": "2+2"},
|
|
"result": "4",
|
|
"timestamp": "2026-03-01T10:00:00",
|
|
},
|
|
],
|
|
)
|
|
results = sl.search("calculator")
|
|
assert len(results) == 1
|
|
assert results[0]["type"] == "tool_call"
|
|
|
|
def test_search_finds_decisions(self, tmp_path):
|
|
"""Should match decisions by decision and rationale fields."""
|
|
sl = SessionLogger(logs_dir=tmp_path)
|
|
self._write_session_file(
|
|
tmp_path,
|
|
"session_2026-03-01.jsonl",
|
|
[
|
|
{
|
|
"type": "decision",
|
|
"decision": "Use local model",
|
|
"rationale": "Privacy matters",
|
|
"timestamp": "2026-03-01T10:00:00",
|
|
},
|
|
],
|
|
)
|
|
results = sl.search("privacy")
|
|
assert len(results) == 1
|
|
assert results[0]["type"] == "decision"
|
|
|
|
def test_search_empty_query_matches_nothing(self, tmp_path):
|
|
"""Empty string matches everything (substring of any string)."""
|
|
sl = SessionLogger(logs_dir=tmp_path)
|
|
self._write_session_file(
|
|
tmp_path,
|
|
"session_2026-03-01.jsonl",
|
|
[
|
|
{
|
|
"type": "message",
|
|
"role": "user",
|
|
"content": "hello",
|
|
"timestamp": "2026-03-01T10:00:00",
|
|
},
|
|
],
|
|
)
|
|
# Empty string is a substring of everything
|
|
results = sl.search("")
|
|
assert len(results) == 1
|
|
|
|
def test_search_no_results(self, tmp_path):
|
|
"""Should return empty list when nothing matches."""
|
|
sl = SessionLogger(logs_dir=tmp_path)
|
|
self._write_session_file(
|
|
tmp_path,
|
|
"session_2026-03-01.jsonl",
|
|
[
|
|
{
|
|
"type": "message",
|
|
"role": "user",
|
|
"content": "hello world",
|
|
"timestamp": "2026-03-01T10:00:00",
|
|
},
|
|
],
|
|
)
|
|
results = sl.search("xyznonexistent")
|
|
assert results == []
|
|
|
|
def test_search_no_session_files(self, tmp_path):
|
|
"""Should return empty list when no session files exist."""
|
|
sl = SessionLogger(logs_dir=tmp_path)
|
|
results = sl.search("anything")
|
|
assert results == []
|
|
|
|
def test_search_skips_malformed_json(self, tmp_path):
|
|
"""Should skip lines with bad JSON and continue."""
|
|
sl = SessionLogger(logs_dir=tmp_path)
|
|
path = tmp_path / "session_2026-03-01.jsonl"
|
|
with open(path, "w") as f:
|
|
f.write(
|
|
'{"type": "message", "role": "user", "content": "good line", "timestamp": "2026-03-01T10:00:00"}\n'
|
|
)
|
|
f.write("THIS IS NOT JSON\n")
|
|
f.write(
|
|
'{"type": "message", "role": "user", "content": "also good", "timestamp": "2026-03-01T10:01:00"}\n'
|
|
)
|
|
results = sl.search("good")
|
|
assert len(results) == 2
|
|
|
|
def test_search_includes_source_file(self, tmp_path):
|
|
"""Should tag each result with _source_file."""
|
|
sl = SessionLogger(logs_dir=tmp_path)
|
|
self._write_session_file(
|
|
tmp_path,
|
|
"session_2026-03-01.jsonl",
|
|
[
|
|
{
|
|
"type": "message",
|
|
"role": "user",
|
|
"content": "hello",
|
|
"timestamp": "2026-03-01T10:00:00",
|
|
},
|
|
],
|
|
)
|
|
results = sl.search("hello")
|
|
assert results[0]["_source_file"] == "session_2026-03-01.jsonl"
|
|
|
|
|
|
class TestSessionHistoryTool:
|
|
"""Tests for the session_history() tool function."""
|
|
|
|
def test_session_history_returns_formatted_string(self, tmp_path, mock_settings, monkeypatch):
|
|
"""Should return a formatted string of results."""
|
|
sl = SessionLogger(logs_dir=tmp_path)
|
|
monkeypatch.setattr("timmy.session_logger._session_logger", sl)
|
|
# Write a session file
|
|
path = tmp_path / f"session_{date.today().isoformat()}.jsonl"
|
|
with open(path, "w") as f:
|
|
f.write(
|
|
json.dumps(
|
|
{
|
|
"type": "message",
|
|
"role": "user",
|
|
"content": "bitcoin price",
|
|
"timestamp": "2026-03-01T10:00:00",
|
|
}
|
|
)
|
|
+ "\n"
|
|
)
|
|
|
|
result = session_history("bitcoin")
|
|
assert "Found 1 result(s)" in result
|
|
assert "bitcoin price" in result
|
|
|
|
def test_session_history_no_results(self, tmp_path, mock_settings, monkeypatch):
|
|
"""Should return a 'no results' message."""
|
|
sl = SessionLogger(logs_dir=tmp_path)
|
|
monkeypatch.setattr("timmy.session_logger._session_logger", sl)
|
|
|
|
result = session_history("xyznonexistent")
|
|
assert "No session history found" in result
|
|
|
|
def test_session_history_role_filter(self, tmp_path, mock_settings, monkeypatch):
|
|
"""Should respect role filter parameter."""
|
|
sl = SessionLogger(logs_dir=tmp_path)
|
|
monkeypatch.setattr("timmy.session_logger._session_logger", sl)
|
|
path = tmp_path / f"session_{date.today().isoformat()}.jsonl"
|
|
with open(path, "w") as f:
|
|
f.write(
|
|
json.dumps(
|
|
{
|
|
"type": "message",
|
|
"role": "user",
|
|
"content": "hello bitcoin",
|
|
"timestamp": "2026-03-01T10:00:00",
|
|
}
|
|
)
|
|
+ "\n"
|
|
)
|
|
f.write(
|
|
json.dumps(
|
|
{
|
|
"type": "message",
|
|
"role": "timmy",
|
|
"content": "bitcoin info",
|
|
"timestamp": "2026-03-01T10:01:00",
|
|
}
|
|
)
|
|
+ "\n"
|
|
)
|
|
|
|
result = session_history("bitcoin", role="timmy")
|
|
assert "Found 1 result(s)" in result
|
|
assert "timmy:" in result
|
|
|
|
def test_session_history_formats_errors(self, tmp_path, mock_settings, monkeypatch):
|
|
"""Should format error entries correctly."""
|
|
sl = SessionLogger(logs_dir=tmp_path)
|
|
monkeypatch.setattr("timmy.session_logger._session_logger", sl)
|
|
path = tmp_path / f"session_{date.today().isoformat()}.jsonl"
|
|
with open(path, "w") as f:
|
|
f.write(
|
|
json.dumps(
|
|
{"type": "error", "error": "Ollama timeout", "timestamp": "2026-03-01T10:00:00"}
|
|
)
|
|
+ "\n"
|
|
)
|
|
|
|
result = session_history("ollama")
|
|
assert "ERROR:" in result
|
|
|
|
def test_session_history_formats_decisions(self, tmp_path, mock_settings, monkeypatch):
|
|
"""Should format decision entries correctly."""
|
|
sl = SessionLogger(logs_dir=tmp_path)
|
|
monkeypatch.setattr("timmy.session_logger._session_logger", sl)
|
|
path = tmp_path / f"session_{date.today().isoformat()}.jsonl"
|
|
with open(path, "w") as f:
|
|
f.write(
|
|
json.dumps(
|
|
{
|
|
"type": "decision",
|
|
"decision": "Use local model",
|
|
"timestamp": "2026-03-01T10:00:00",
|
|
}
|
|
)
|
|
+ "\n"
|
|
)
|
|
|
|
result = session_history("local model")
|
|
assert "DECIDED:" in result
|
|
|
|
def test_session_history_formats_tool_calls(self, tmp_path, mock_settings, monkeypatch):
|
|
"""Should format tool call entries correctly."""
|
|
sl = SessionLogger(logs_dir=tmp_path)
|
|
monkeypatch.setattr("timmy.session_logger._session_logger", sl)
|
|
path = tmp_path / f"session_{date.today().isoformat()}.jsonl"
|
|
with open(path, "w") as f:
|
|
f.write(
|
|
json.dumps(
|
|
{
|
|
"type": "tool_call",
|
|
"tool": "calculator",
|
|
"result": "42",
|
|
"timestamp": "2026-03-01T10:00:00",
|
|
}
|
|
)
|
|
+ "\n"
|
|
)
|
|
|
|
result = session_history("calculator")
|
|
assert "tool:calculator" in result
|
|
|
|
|
|
class TestSelfReflect:
|
|
"""Tests for the self_reflect() tool function."""
|
|
|
|
def _write_entries(self, logs_dir, entries):
|
|
"""Helper: write entries to today's session file."""
|
|
path = logs_dir / f"session_{date.today().isoformat()}.jsonl"
|
|
with open(path, "w") as f:
|
|
for entry in entries:
|
|
f.write(json.dumps(entry) + "\n")
|
|
|
|
def test_self_reflect_no_history(self, tmp_path, mock_settings, monkeypatch):
|
|
"""Should return a message when no history exists."""
|
|
sl = SessionLogger(logs_dir=tmp_path)
|
|
monkeypatch.setattr("timmy.session_logger._session_logger", sl)
|
|
|
|
result = self_reflect()
|
|
assert "No conversation history" in result
|
|
|
|
def test_self_reflect_detects_low_confidence(self, tmp_path, mock_settings, monkeypatch):
|
|
"""Should flag low-confidence responses."""
|
|
sl = SessionLogger(logs_dir=tmp_path)
|
|
monkeypatch.setattr("timmy.session_logger._session_logger", sl)
|
|
self._write_entries(
|
|
tmp_path,
|
|
[
|
|
{
|
|
"type": "message",
|
|
"role": "timmy",
|
|
"content": "I think maybe it could be X",
|
|
"confidence": 0.3,
|
|
"timestamp": "2026-03-01T10:00:00",
|
|
},
|
|
{
|
|
"type": "message",
|
|
"role": "timmy",
|
|
"content": "The answer is Y",
|
|
"confidence": 0.9,
|
|
"timestamp": "2026-03-01T10:01:00",
|
|
},
|
|
],
|
|
)
|
|
|
|
result = self_reflect()
|
|
assert "Low-Confidence Responses (1)" in result
|
|
assert "confidence=30%" in result
|
|
|
|
def test_self_reflect_detects_errors(self, tmp_path, mock_settings, monkeypatch):
|
|
"""Should report errors in reflection."""
|
|
sl = SessionLogger(logs_dir=tmp_path)
|
|
monkeypatch.setattr("timmy.session_logger._session_logger", sl)
|
|
self._write_entries(
|
|
tmp_path,
|
|
[
|
|
{
|
|
"type": "error",
|
|
"error": "Ollama connection refused",
|
|
"timestamp": "2026-03-01T10:00:00",
|
|
},
|
|
{
|
|
"type": "message",
|
|
"role": "user",
|
|
"content": "hello",
|
|
"timestamp": "2026-03-01T10:01:00",
|
|
},
|
|
],
|
|
)
|
|
|
|
result = self_reflect()
|
|
assert "Errors (1)" in result
|
|
assert "Ollama connection refused" in result
|
|
|
|
def test_self_reflect_detects_repeated_topics(self, tmp_path, mock_settings, monkeypatch):
|
|
"""Should identify recurring user topics."""
|
|
sl = SessionLogger(logs_dir=tmp_path)
|
|
monkeypatch.setattr("timmy.session_logger._session_logger", sl)
|
|
self._write_entries(
|
|
tmp_path,
|
|
[
|
|
{
|
|
"type": "message",
|
|
"role": "user",
|
|
"content": "tell me about bitcoin",
|
|
"timestamp": "2026-03-01T10:00:00",
|
|
},
|
|
{
|
|
"type": "message",
|
|
"role": "user",
|
|
"content": "more about bitcoin please",
|
|
"timestamp": "2026-03-01T10:01:00",
|
|
},
|
|
{
|
|
"type": "message",
|
|
"role": "user",
|
|
"content": "bitcoin price today",
|
|
"timestamp": "2026-03-01T10:02:00",
|
|
},
|
|
],
|
|
)
|
|
|
|
result = self_reflect()
|
|
assert "Recurring Topics" in result
|
|
assert "bitcoin" in result
|
|
|
|
def test_self_reflect_healthy_session(self, tmp_path, mock_settings, monkeypatch):
|
|
"""Should report healthy when no issues found."""
|
|
sl = SessionLogger(logs_dir=tmp_path)
|
|
monkeypatch.setattr("timmy.session_logger._session_logger", sl)
|
|
self._write_entries(
|
|
tmp_path,
|
|
[
|
|
{
|
|
"type": "message",
|
|
"role": "user",
|
|
"content": "hi",
|
|
"timestamp": "2026-03-01T10:00:00",
|
|
},
|
|
{
|
|
"type": "message",
|
|
"role": "timmy",
|
|
"content": "Hello!",
|
|
"confidence": 0.9,
|
|
"timestamp": "2026-03-01T10:01:00",
|
|
},
|
|
],
|
|
)
|
|
|
|
result = self_reflect()
|
|
assert "Self-Reflection Report" in result
|
|
assert "Keep up the good work" in result
|
|
|
|
def test_self_reflect_includes_insights(self, tmp_path, mock_settings, monkeypatch):
|
|
"""Should include actionable insights section."""
|
|
sl = SessionLogger(logs_dir=tmp_path)
|
|
monkeypatch.setattr("timmy.session_logger._session_logger", sl)
|
|
self._write_entries(
|
|
tmp_path,
|
|
[
|
|
{
|
|
"type": "message",
|
|
"role": "timmy",
|
|
"content": "I'm not sure about this",
|
|
"confidence": 0.2,
|
|
"timestamp": "2026-03-01T10:00:00",
|
|
},
|
|
],
|
|
)
|
|
|
|
result = self_reflect()
|
|
assert "Insights" in result
|
|
assert "confidence was low" in result
|