Co-Authored-By: Quentin Torroba <quentin.torroba@mistral.ai>
Co-Authored-By: Michel Thomazo <michel.thomazo@mistral.ai>
Co-Authored-By: Clément Drouin <clement.drouin@mistral.ai>
Co-Authored-By: Vincent Guilloux <vincent.guilloux@mistral.ai>
Co-Authored-By: Clément Siriex <clement.sirieix@mistral.ai>
Co-Authored-By: Kim-Adeline Miguel <kimadeline.miguel@mistral.ai>
Co-Authored-By: Thaddee Tyl <thaddee.tyl@gmail.com>
Co-Authored-By: David Brochart <david.brochart@gmail.com>
Co-Authored-By: Joseph Guhlin <joseph.guhlin@gmail.com>
Co-Authored-By: Thomas Kenbeek <thomaskenbeek@gmail.com>
Co-Authored-By: Remenby31 <baptiste.cruvellier31@gmail.com>
This commit is contained in:
Mathias Gesbert 2026-01-27 16:39:30 +01:00 committed by Mathias Gesbert
parent 79f215d91c
commit d33db9fff8
217 changed files with 16911 additions and 4305 deletions

View file

@ -0,0 +1,539 @@
from __future__ import annotations
from datetime import datetime
import json
from pathlib import Path
import time
import pytest
from vibe.core.config import SessionLoggingConfig
from vibe.core.session.session_loader import SessionLoader
from vibe.core.types import LLMMessage, Role, ToolCall
@pytest.fixture
def temp_session_dir(tmp_path: Path) -> Path:
"""Create a temporary directory for session loader tests."""
session_dir = tmp_path / "sessions"
session_dir.mkdir()
return session_dir
@pytest.fixture
def session_config(temp_session_dir: Path) -> SessionLoggingConfig:
"""Create a session logging config for testing."""
return SessionLoggingConfig(
save_dir=str(temp_session_dir), session_prefix="test", enabled=True
)
@pytest.fixture
def create_test_session():
"""Helper fixture to create a test session with messages and metadata."""
def _create_test_session(
session_dir: Path,
session_id: str,
messages: list[LLMMessage] | None = None,
metadata: dict | None = None,
) -> Path:
"""Create a test session directory with messages and metadata files."""
# Create session directory
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
session_folder = session_dir / f"test_{timestamp}_{session_id[:8]}"
session_folder.mkdir(exist_ok=True)
# Create messages file
messages_file = session_folder / "messages.jsonl"
if messages is None:
messages = [
LLMMessage(role=Role.system, content="System prompt"),
LLMMessage(role=Role.user, content="Hello"),
LLMMessage(role=Role.assistant, content="Hi there!"),
]
with messages_file.open("w", encoding="utf-8") as f:
for message in messages:
f.write(
json.dumps(
message.model_dump(exclude_none=True), ensure_ascii=False
)
+ "\n"
)
# Create metadata file
metadata_file = session_folder / "meta.json"
if metadata is None:
metadata = {
"session_id": session_id,
"start_time": "2023-01-01T12:00:00",
"end_time": "2023-01-01T12:05:00",
"total_messages": 2,
"stats": {
"steps": 1,
"session_prompt_tokens": 10,
"session_completion_tokens": 20,
},
"system_prompt": {"content": "System prompt", "role": "system"},
}
with metadata_file.open("w", encoding="utf-8") as f:
json.dump(metadata, f, indent=2)
return session_folder
return _create_test_session
class TestSessionLoaderFindLatestSession:
def test_find_latest_session_no_sessions(
self, session_config: SessionLoggingConfig
) -> None:
"""Test finding latest session when no sessions exist."""
result = SessionLoader.find_latest_session(session_config)
assert result is None
def test_find_latest_session_single_session(
self, session_config: SessionLoggingConfig, create_test_session
) -> None:
"""Test finding latest session with a single session."""
session_dir = Path(session_config.save_dir)
session = create_test_session(session_dir, "session-123")
result = SessionLoader.find_latest_session(session_config)
assert result is not None
assert result.exists()
assert result == session
def test_find_latest_session_multiple_sessions(
self, session_config: SessionLoggingConfig, create_test_session
) -> None:
"""Test finding latest session with multiple sessions."""
session_dir = Path(session_config.save_dir)
create_test_session(session_dir, "session-123")
time.sleep(0.01)
create_test_session(session_dir, "session-456")
time.sleep(0.01)
latest = create_test_session(session_dir, "session-789")
result = SessionLoader.find_latest_session(session_config)
assert result is not None
assert result.exists()
assert result == latest
def test_find_latest_session_nonexistent_save_dir(self) -> None:
"""Test finding latest session when save directory doesn't exist."""
# Modify config to point to non-existent directory
bad_config = SessionLoggingConfig(
save_dir="/nonexistent/path", session_prefix="test", enabled=True
)
result = SessionLoader.find_latest_session(bad_config)
assert result is None
def test_find_latest_session_with_invalid_sessions(
self, session_config: SessionLoggingConfig
) -> None:
"""Test finding latest session when only invalid sessions exist."""
session_dir = Path(session_config.save_dir)
invalid_session1 = session_dir / "test_20230101_120000_invalid1"
invalid_session1.mkdir()
(invalid_session1 / "messages.jsonl").write_text("[]") # Missing meta.json
invalid_session2 = session_dir / "test_20230101_120001_invalid2"
invalid_session2.mkdir()
(invalid_session2 / "meta.json").write_text('{"session_id": "invalid"}')
result = SessionLoader.find_latest_session(session_config)
assert result is None # Should return None when no valid sessions exist
def test_find_latest_session_with_mixed_valid_invalid(
self, session_config: SessionLoggingConfig, create_test_session
) -> None:
"""Test finding latest session when both valid and invalid sessions exist."""
session_dir = Path(session_config.save_dir)
invalid_session = session_dir / "test_20230101_120000_invalid"
invalid_session.mkdir()
(invalid_session / "messages.jsonl").write_text(
'{"role": "user", "content": "test"}\n'
)
time.sleep(0.01)
valid_session = create_test_session(session_dir, "test_20230101_120001_valid")
time.sleep(0.01)
newest_invalid = session_dir / "test_20230101_120002_newest"
newest_invalid.mkdir()
(newest_invalid / "messages.jsonl").write_text(
'{"role": "user", "content": "test"}\n'
)
result = SessionLoader.find_latest_session(session_config)
assert result is not None
assert result == valid_session
def test_find_latest_session_with_invalid_json(
self, session_config: SessionLoggingConfig, create_test_session
) -> None:
"""Test finding latest session when sessions have invalid JSON."""
session_dir = Path(session_config.save_dir)
invalid_meta_session = session_dir / "test_20230101_120000_invalid_meta"
invalid_meta_session.mkdir()
(invalid_meta_session / "messages.jsonl").write_text(
'{"role": "user", "content": "test"}\n'
)
(invalid_meta_session / "meta.json").write_text("{invalid json}")
time.sleep(0.01)
invalid_msg_session = session_dir / "test_20230101_120001_invalid_msg"
invalid_msg_session.mkdir()
(invalid_msg_session / "messages.jsonl").write_text("{invalid json}")
(invalid_msg_session / "meta.json").write_text('{"session_id": "invalid"}')
time.sleep(0.01)
valid_session = create_test_session(session_dir, "test_20230101_120002_valid")
result = SessionLoader.find_latest_session(session_config)
assert result is not None
assert result == valid_session
class TestSessionLoaderFindSessionById:
def test_find_session_by_id_exact_match(
self, session_config: SessionLoggingConfig, create_test_session
) -> None:
"""Test finding session by exact ID match."""
session_dir = Path(session_config.save_dir)
session_folder = create_test_session(session_dir, "test-session-123")
# Test with full UUID format
result = SessionLoader.find_session_by_id("test-session-123", session_config)
assert result is not None
assert result == session_folder
def test_find_session_by_id_short_uuid(
self, session_config: SessionLoggingConfig, create_test_session
) -> None:
"""Test finding session by short UUID."""
session_dir = Path(session_config.save_dir)
session_folder = create_test_session(
session_dir, "abc12345-6789-0123-4567-89abcdef0123"
)
# Test with short UUID
result = SessionLoader.find_session_by_id("abc12345", session_config)
assert result is not None
assert result == session_folder
def test_find_session_by_id_partial_match(
self, session_config: SessionLoggingConfig, create_test_session
) -> None:
"""Test finding session by partial ID match"""
session_dir = Path(session_config.save_dir)
session_folder = create_test_session(session_dir, "abc12345678")
# Test with partial match
result = SessionLoader.find_session_by_id("abc12345", session_config)
assert result is not None
assert result == session_folder
def test_find_session_by_id_multiple_matches(
self, session_config: SessionLoggingConfig, create_test_session
) -> None:
"""Test finding session when multiple sessions match (should return most recent)."""
session_dir = Path(session_config.save_dir)
# Create first session
create_test_session(session_dir, "abcd1234")
# Sleep to ensure different modification times
time.sleep(0.01)
# Create second session with similar ID prefix
session_2 = create_test_session(session_dir, "abcd1234")
result = SessionLoader.find_session_by_id("abcd1234", session_config)
assert result is not None
assert result == session_2
def test_find_session_by_id_no_match(
self, session_config: SessionLoggingConfig, create_test_session
) -> None:
"""Test finding session by ID when no match exists."""
session_dir = Path(session_config.save_dir)
create_test_session(session_dir, "test-session-123")
result = SessionLoader.find_session_by_id("nonexistent", session_config)
assert result is None
def test_find_session_by_id_nonexistent_save_dir(self) -> None:
"""Test finding session by ID when save directory doesn't exist."""
bad_config = SessionLoggingConfig(
save_dir="/nonexistent/path", session_prefix="test", enabled=True
)
result = SessionLoader.find_session_by_id("test-session", bad_config)
assert result is None
class TestSessionLoaderLoadSession:
def test_load_session_success(
self, session_config: SessionLoggingConfig, create_test_session
) -> None:
"""Test successfully loading a session."""
session_dir = Path(session_config.save_dir)
session_folder = create_test_session(session_dir, "test-session-123")
messages, metadata = SessionLoader.load_session(session_folder)
# Verify messages
assert len(messages) == 2
assert messages[0].role == "user"
assert messages[0].content == "Hello"
assert messages[1].role == "assistant"
assert messages[1].content == "Hi there!"
# Verify metadata
assert metadata["session_id"] == "test-session-123"
assert metadata["total_messages"] == 2
assert "stats" in metadata
assert "system_prompt" in metadata
def test_load_session_empty_messages(
self, session_config: SessionLoggingConfig
) -> None:
"""Test loading session with empty messages file."""
session_dir = Path(session_config.save_dir)
session_folder = session_dir / "test_20230101_120000_test123"
session_folder.mkdir()
# Create empty messages file
messages_file = session_folder / "messages.jsonl"
messages_file.write_text("")
# Create metadata file
metadata_file = session_folder / "meta.json"
metadata_file.write_text('{"session_id": "test-session"}')
with pytest.raises(ValueError, match="Session messages file is empty"):
SessionLoader.load_session(session_folder)
def test_load_session_invalid_json_messages(
self, session_config: SessionLoggingConfig
) -> None:
"""Test loading session with invalid JSON in messages file."""
session_dir = Path(session_config.save_dir)
session_folder = session_dir / "test_20230101_120000_test123"
session_folder.mkdir()
# Create messages file with invalid JSON
messages_file = session_folder / "messages.jsonl"
messages_file.write_text("{invalid json}")
# Create metadata file
metadata_file = session_folder / "meta.json"
metadata_file.write_text('{"session_id": "test-session"}')
with pytest.raises(ValueError, match="Session messages contain invalid JSON"):
SessionLoader.load_session(session_folder)
def test_load_session_invalid_json_metadata(
self, session_config: SessionLoggingConfig
) -> None:
"""Test loading session with invalid JSON in metadata file."""
session_dir = Path(session_config.save_dir)
session_folder = session_dir / "test_20230101_120000_test123"
session_folder.mkdir()
# Create valid messages file
messages_file = session_folder / "messages.jsonl"
messages_file.write_text('{"role": "user", "content": "Hello"}\n')
# Create metadata file with invalid JSON
metadata_file = session_folder / "meta.json"
metadata_file.write_text("{invalid json}")
with pytest.raises(ValueError, match="Session metadata contains invalid JSON"):
SessionLoader.load_session(session_folder)
def test_load_session_no_metadata_file(
self, session_config: SessionLoggingConfig
) -> None:
"""Test loading session when metadata file doesn't exist."""
session_dir = Path(session_config.save_dir)
session_folder = session_dir / "test_20230101_120000_test123"
session_folder.mkdir()
# Create valid messages file using the same format as create_test_session
messages = [
LLMMessage(role=Role.system, content="System prompt"),
LLMMessage(role=Role.user, content="Hello"),
LLMMessage(role=Role.assistant, content="Hi there!"),
]
messages_file = session_folder / "messages.jsonl"
with messages_file.open("w", encoding="utf-8") as f:
for message in messages:
f.write(
json.dumps(
message.model_dump(exclude_none=True), ensure_ascii=False
)
+ "\n"
)
loaded_messages, metadata = SessionLoader.load_session(session_folder)
assert len(loaded_messages) == 2
assert loaded_messages[0].content == "Hello"
assert loaded_messages[0].role == Role.user
assert loaded_messages[1].content == "Hi there!"
assert loaded_messages[1].role == Role.assistant
assert metadata == {}
def test_load_session_nonexistent_directory(
self, session_config: SessionLoggingConfig
) -> None:
"""Test loading session from non-existent directory."""
nonexistent_dir = Path(session_config.save_dir) / "nonexistent"
with pytest.raises(ValueError, match="Error reading session messages"):
SessionLoader.load_session(nonexistent_dir)
class TestSessionLoaderEdgeCases:
def test_find_latest_session_with_different_prefixes(
self, session_config: SessionLoggingConfig
) -> None:
"""Test finding latest session when sessions have different prefixes."""
session_dir = Path(session_config.save_dir)
# Create sessions with different prefixes
other_session = session_dir / "other_20230101_120000_test123"
other_session.mkdir()
(other_session / "messages.jsonl").write_text(
'{"role": "user", "content": "test"}\n'
)
test_session = session_dir / "test_20230101_120000_test456"
test_session.mkdir()
(test_session / "messages.jsonl").write_text(
'{"role": "user", "content": "test"}\n'
)
(test_session / "meta.json").write_text('{"session_id": "test456"}')
result = SessionLoader.find_latest_session(session_config)
assert result is not None
assert result.name == "test_20230101_120000_test456"
def test_find_session_by_id_with_special_characters(
self, session_config: SessionLoggingConfig, create_test_session
) -> None:
"""Test finding session by ID containing special characters."""
session_dir = Path(session_config.save_dir)
session_folder = create_test_session(
session_dir, "test-session_with-special.chars"
)
result = SessionLoader.find_session_by_id(
"test-session_with-special.chars", session_config
)
assert result is not None
assert result == session_folder
def test_load_session_with_complex_messages(
self, session_config: SessionLoggingConfig
) -> None:
"""Test loading session with complex message structures."""
session_dir = Path(session_config.save_dir)
session_folder = session_dir / "test_20230101_120000_test123"
session_folder.mkdir()
# Create messages with complex structure
complex_messages = [
LLMMessage(role=Role.system, content="System prompt"),
LLMMessage(
role=Role.user,
content="Complex message",
reasoning_content="Some reasoning",
tool_calls=[ToolCall(id="call1", index=1, type="function")],
),
LLMMessage(
role=Role.assistant,
content="Response",
tool_calls=[ToolCall(id="call2", index=2, type="function")],
),
]
messages_file = session_folder / "messages.jsonl"
with messages_file.open("w", encoding="utf-8") as f:
for message in complex_messages:
f.write(
json.dumps(
message.model_dump(exclude_none=True), ensure_ascii=False
)
+ "\n"
)
# Create metadata file
metadata_file = session_folder / "meta.json"
metadata_file.write_text('{"session_id": "complex-session"}')
messages, _ = SessionLoader.load_session(session_folder)
# Verify complex messages are loaded correctly
assert len(messages) == 2
assert messages[0].role == Role.user
assert messages[0].content == "Complex message"
assert messages[0].reasoning_content == "Some reasoning"
assert len(messages[0].tool_calls or []) == 1
assert messages[1].role == Role.assistant
assert len(messages[1].tool_calls or []) == 1
assert messages[1].content == "Response"
def test_load_session_system_prompt_ignored_in_messages(
self, session_config: SessionLoggingConfig
) -> None:
"""Test that system prompt is ignored when written in messages.jsonl."""
session_dir = Path(session_config.save_dir)
session_folder = session_dir / "test_20230101_120000_test123"
session_folder.mkdir()
messages_with_system = [
LLMMessage(role=Role.system, content="System prompt from messages"),
LLMMessage(role=Role.user, content="Hello"),
LLMMessage(role=Role.assistant, content="Hi there!"),
]
messages_file = session_folder / "messages.jsonl"
with messages_file.open("w", encoding="utf-8") as f:
for message in messages_with_system:
f.write(
json.dumps(
message.model_dump(exclude_none=True), ensure_ascii=False
)
+ "\n"
)
metadata_file = session_folder / "meta.json"
metadata_file.write_text(
json.dumps({"session_id": "test-session", "total_messages": 3})
)
messages, metadata = SessionLoader.load_session(session_folder)
# Verify that system prompt from messages.jsonl is ignored
assert len(messages) == 2
assert messages[0].role == Role.user
assert messages[0].content == "Hello"
assert messages[1].role == Role.assistant
assert messages[1].content == "Hi there!"

View file

@ -0,0 +1,641 @@
from __future__ import annotations
from datetime import datetime, timedelta
import json
import os
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from vibe.core.agents.models import AgentProfile, AgentSafety
from vibe.core.config import SessionLoggingConfig, VibeConfig
from vibe.core.session.session_logger import SessionLogger
from vibe.core.tools.manager import ToolManager
from vibe.core.types import AgentStats, LLMMessage, Role, SessionMetadata
@pytest.fixture
def temp_session_dir(tmp_path: Path) -> Path:
"""Create a temporary directory for session logging tests."""
session_dir = tmp_path / "sessions"
session_dir.mkdir()
return session_dir
@pytest.fixture
def session_config(temp_session_dir: Path) -> SessionLoggingConfig:
"""Create a session logging config for testing."""
return SessionLoggingConfig(
save_dir=str(temp_session_dir), session_prefix="test", enabled=True
)
@pytest.fixture
def disabled_session_config() -> SessionLoggingConfig:
"""Create a disabled session logging config for testing."""
return SessionLoggingConfig(
save_dir="/tmp/test", session_prefix="test", enabled=False
)
@pytest.fixture
def mock_agent_profile() -> AgentProfile:
"""Create a mock agent profile for testing."""
return AgentProfile(
name="test-agent",
display_name="Test Agent",
description="A test agent",
safety=AgentSafety.NEUTRAL,
overrides={},
)
@pytest.fixture
def mock_tool_manager() -> ToolManager:
"""Create a mock tool manager for testing."""
manager = MagicMock(spec=ToolManager)
manager.available_tools = {}
return manager
@pytest.fixture
def mock_vibe_config() -> VibeConfig:
"""Create a mock vibe config for testing."""
return VibeConfig(active_model="test-model", models=[], providers=[])
class TestSessionLoggerInitialization:
def test_enabled_session_logger_initialization(
self, session_config: SessionLoggingConfig
) -> None:
"""Test that SessionLogger initializes correctly when enabled."""
session_id = "test-session-123"
logger = SessionLogger(session_config, session_id)
assert logger.enabled is True
assert logger.session_id == session_id
assert logger.save_dir == Path(session_config.save_dir)
assert logger.session_prefix == session_config.session_prefix
assert logger.session_dir is not None
assert logger.session_metadata is not None
assert isinstance(logger.session_metadata, SessionMetadata)
# Check that session directory was created
assert logger.session_dir is not None
assert str(logger.session_dir).startswith(str(session_config.save_dir))
# Check session directory name format
dir_name = logger.session_dir.name
assert dir_name.startswith(f"{session_config.session_prefix}_")
assert session_id[:8] in dir_name
def test_disabled_session_logger_initialization(
self, disabled_session_config: SessionLoggingConfig
) -> None:
"""Test that SessionLogger initializes correctly when disabled."""
session_id = "test-session-123"
logger = SessionLogger(disabled_session_config, session_id)
assert logger.enabled is False
assert logger.session_id == "disabled"
assert logger.save_dir is None
assert logger.session_prefix is None
assert logger.session_dir is None
assert logger.session_metadata is None
class TestSessionLoggerMetadata:
@patch("vibe.core.session.session_logger.subprocess.run")
@patch("vibe.core.session.session_logger.getpass.getuser")
def test_session_metadata_initialization(
self, mock_getuser, mock_subprocess, session_config: SessionLoggingConfig
) -> None:
"""Test that session metadata is correctly initialized."""
# Mock git commands
git_commit_mock = MagicMock()
git_commit_mock.returncode = 0
git_commit_mock.stdout = "abc123\n"
git_branch_mock = MagicMock()
git_branch_mock.returncode = 0
git_branch_mock.stdout = "main\n"
mock_subprocess.side_effect = [git_commit_mock, git_branch_mock]
mock_getuser.return_value = "testuser"
session_id = "test-session-123"
logger = SessionLogger(session_config, session_id)
assert logger.session_metadata is not None
metadata = logger.session_metadata
assert metadata.session_id == session_id
assert metadata.start_time == logger.session_start_time
assert metadata.end_time is None
assert metadata.git_commit == "abc123"
assert metadata.git_branch == "main"
assert metadata.username == "testuser"
assert "working_directory" in metadata.environment
assert metadata.environment["working_directory"] == str(Path.cwd())
@patch("vibe.core.session.session_logger.subprocess.run")
@patch("vibe.core.session.session_logger.getpass.getuser")
def test_session_metadata_with_git_errors(
self, mock_getuser, mock_subprocess, session_config: SessionLoggingConfig
) -> None:
"""Test that session metadata handles git command errors gracefully."""
# Mock git commands to fail
mock_subprocess.side_effect = FileNotFoundError("git not found")
mock_getuser.return_value = "testuser"
session_id = "test-session-123"
logger = SessionLogger(session_config, session_id)
assert logger.session_metadata is not None
metadata = logger.session_metadata
assert metadata.git_commit is None
assert metadata.git_branch is None
assert metadata.username == "testuser"
class TestSessionLoggerSaveInteraction:
@pytest.mark.asyncio
async def test_save_interaction_disabled(
self, disabled_session_config: SessionLoggingConfig
) -> None:
"""Test that save_interaction returns None when logging is disabled."""
logger = SessionLogger(disabled_session_config, "test-session")
result = await logger.save_interaction(
messages=[],
stats=AgentStats(),
base_config=VibeConfig(active_model="test", models=[], providers=[]),
tool_manager=MagicMock(),
agent_profile=AgentProfile(
name="test",
display_name="Test",
description="Test agent",
safety=AgentSafety.NEUTRAL,
overrides={},
),
)
assert result is None
@pytest.mark.asyncio
async def test_save_interaction_success(
self,
session_config: SessionLoggingConfig,
mock_vibe_config: VibeConfig,
mock_tool_manager: ToolManager,
mock_agent_profile: AgentProfile,
) -> None:
"""Test that save_interaction successfully saves session data."""
session_id = "test-session-123"
logger = SessionLogger(session_config, session_id)
# Create test messages
messages = [
LLMMessage(role=Role.system, content="System prompt"),
LLMMessage(role=Role.user, content="Hello"),
LLMMessage(role=Role.assistant, content="Hi there!"),
]
# Create test stats
stats = AgentStats(
steps=1, session_prompt_tokens=10, session_completion_tokens=20
)
# Test that save_interaction returns a path when enabled
result = await logger.save_interaction(
messages=messages,
stats=stats,
base_config=mock_vibe_config,
tool_manager=mock_tool_manager,
agent_profile=mock_agent_profile,
)
# Verify the result
assert result is not None
assert str(logger.session_dir) in result
# Verify that files were created
assert logger.session_dir is not None
messages_file = logger.session_dir / "messages.jsonl"
metadata_file = logger.session_dir / "meta.json"
assert messages_file.exists()
assert metadata_file.exists()
# Verify that metadata contains expected data
with open(metadata_file) as f:
metadata = json.load(f)
assert metadata["session_id"] == session_id
assert metadata["total_messages"] == 2
assert metadata["stats"]["steps"] == stats.steps
assert "title" in metadata
assert metadata["title"] == "Hello"
assert "system_prompt" in metadata
@pytest.mark.asyncio
async def test_save_interaction_system_prompt_in_metadata(
self,
session_config: SessionLoggingConfig,
mock_vibe_config: VibeConfig,
mock_tool_manager: ToolManager,
mock_agent_profile: AgentProfile,
) -> None:
"""Test that system prompt is saved in metadata and not in messages."""
session_id = "test-session-123"
logger = SessionLogger(session_config, session_id)
messages = [
LLMMessage(role=Role.system, content="System prompt"),
LLMMessage(role=Role.user, content="Hello"),
LLMMessage(role=Role.assistant, content="Hi there!"),
]
stats = AgentStats(
steps=1, session_prompt_tokens=10, session_completion_tokens=20
)
result = await logger.save_interaction(
messages=messages,
stats=stats,
base_config=mock_vibe_config,
tool_manager=mock_tool_manager,
agent_profile=mock_agent_profile,
)
assert result is not None
assert logger.session_dir is not None
metadata_file = logger.session_dir / "meta.json"
with open(metadata_file) as f:
metadata = json.load(f)
assert "system_prompt" in metadata
assert metadata["system_prompt"]["content"] == "System prompt"
assert metadata["system_prompt"]["role"] == "system"
messages_file = logger.session_dir / "messages.jsonl"
with open(messages_file) as f:
lines = f.readlines()
messages_data = [json.loads(line) for line in lines]
assert len(messages_data) == 2
assert messages_data[0]["role"] == "user"
assert messages_data[1]["role"] == "assistant"
@pytest.mark.asyncio
async def test_save_interaction_with_existing_messages(
self,
session_config: SessionLoggingConfig,
mock_vibe_config: VibeConfig,
mock_tool_manager: ToolManager,
mock_agent_profile: AgentProfile,
) -> None:
"""Test that save_interaction correctly handles existing messages."""
session_id = "test-session-123"
logger = SessionLogger(session_config, session_id)
# First save - create initial session
initial_messages = [
LLMMessage(role=Role.system, content="System prompt"),
LLMMessage(role=Role.user, content="Hello"),
LLMMessage(role=Role.assistant, content="Hi there!"),
]
stats = AgentStats(
steps=1, session_prompt_tokens=10, session_completion_tokens=20
)
await logger.save_interaction(
messages=initial_messages,
stats=stats,
base_config=mock_vibe_config,
tool_manager=mock_tool_manager,
agent_profile=mock_agent_profile,
)
# Second save - add more messages
new_messages = [
LLMMessage(role=Role.user, content="How are you?"),
LLMMessage(role=Role.assistant, content="I'm fine, thanks!"),
]
all_messages = initial_messages + new_messages
updated_stats = AgentStats(
steps=2, session_prompt_tokens=20, session_completion_tokens=40
)
result = await logger.save_interaction(
messages=all_messages,
stats=updated_stats,
base_config=mock_vibe_config,
tool_manager=mock_tool_manager,
agent_profile=mock_agent_profile,
)
# Verify that the result is not None
assert result is not None
# Verify that metadata was updated
assert logger.session_dir is not None
metadata_file = logger.session_dir / "meta.json"
with open(metadata_file) as f:
metadata = json.load(f)
assert metadata["total_messages"] == 4
assert metadata["stats"]["steps"] == updated_stats.steps
@pytest.mark.asyncio
async def test_save_interaction_no_user_messages(
self,
session_config: SessionLoggingConfig,
mock_vibe_config: VibeConfig,
mock_tool_manager: ToolManager,
mock_agent_profile: AgentProfile,
) -> None:
"""Test that save_interaction handles sessions with no user messages."""
session_id = "test-session-123"
logger = SessionLogger(session_config, session_id)
# Create messages with no user messages (only system and assistant)
messages = [
LLMMessage(role=Role.system, content="System prompt"),
LLMMessage(role=Role.assistant, content="Hi there!"),
]
stats = AgentStats(
steps=1, session_prompt_tokens=10, session_completion_tokens=20
)
result = await logger.save_interaction(
messages=messages,
stats=stats,
base_config=mock_vibe_config,
tool_manager=mock_tool_manager,
agent_profile=mock_agent_profile,
)
# Verify the result
assert result is not None
assert str(logger.session_dir) in result
# Verify that metadata contains expected data
assert logger.session_dir is not None
metadata_file = logger.session_dir / "meta.json"
with open(metadata_file) as f:
metadata = json.load(f)
assert metadata["session_id"] == session_id
assert metadata["total_messages"] == 1
assert metadata["stats"]["steps"] == stats.steps
assert metadata["title"] == "Untitled session"
@pytest.mark.asyncio
async def test_save_interaction_long_user_message(
self,
session_config: SessionLoggingConfig,
mock_vibe_config: VibeConfig,
mock_tool_manager: ToolManager,
mock_agent_profile: AgentProfile,
) -> None:
"""Test that save_interaction truncates long user messages for title."""
session_id = "test-session-123"
logger = SessionLogger(session_config, session_id)
# Create a long user message (more than 50 characters)
long_message = "This is a very long user message that exceeds fifty characters and should be truncated"
messages = [
LLMMessage(role=Role.system, content="System prompt"),
LLMMessage(role=Role.user, content=long_message),
LLMMessage(role=Role.assistant, content="Response"),
]
stats = AgentStats(
steps=1, session_prompt_tokens=10, session_completion_tokens=20
)
result = await logger.save_interaction(
messages=messages,
stats=stats,
base_config=mock_vibe_config,
tool_manager=mock_tool_manager,
agent_profile=mock_agent_profile,
)
# Verify the result
assert result is not None
assert str(logger.session_dir) in result
# Verify that metadata contains expected data
assert logger.session_dir is not None
metadata_file = logger.session_dir / "meta.json"
with open(metadata_file) as f:
metadata = json.load(f)
assert metadata["session_id"] == session_id
assert metadata["total_messages"] == 2
assert metadata["stats"]["steps"] == stats.steps
expected_title = long_message[:50] + ""
assert metadata["title"] == expected_title
class TestSessionLoggerResetSession:
def test_reset_session(self, session_config: SessionLoggingConfig) -> None:
"""Test that reset_session correctly resets session information."""
session_id = "test-session-123"
logger = SessionLogger(session_config, session_id)
# Store original session info
original_session_id = logger.session_id
original_metadata = logger.session_metadata
# Reset session
new_session_id = "test-session-456"
logger.reset_session(new_session_id)
# Verify session was reset
assert logger.session_id == new_session_id
assert logger.session_start_time != "N/A" # Should be a valid timestamp
assert logger.session_metadata is not None
assert logger.session_metadata.session_id == new_session_id
# Verify that metadata was recreated (different object)
assert logger.session_metadata is not original_metadata
assert logger.session_id != original_session_id
def test_reset_session_disabled(
self, disabled_session_config: SessionLoggingConfig
) -> None:
"""Test that reset_session does nothing when logging is disabled."""
logger = SessionLogger(disabled_session_config, "test-session")
# Reset session should not raise any errors
logger.reset_session("new-session")
# Verify state is unchanged
assert logger.enabled is False
assert logger.session_id == "disabled"
class TestSessionLoggerFileOperations:
def test_save_folder(self, session_config: SessionLoggingConfig) -> None:
"""Test that save_folder creates correct folder name."""
session_id = "test-session-123"
logger = SessionLogger(session_config, session_id)
folder = logger.save_folder
assert folder.parent == Path(session_config.save_dir)
assert folder.name.startswith(f"{session_config.session_prefix}_")
assert session_id[:8] in folder.name
def test_metadata_filepath(self, session_config: SessionLoggingConfig) -> None:
"""Test that metadata_filepath returns correct path."""
session_id = "test-session-123"
logger = SessionLogger(session_config, session_id)
metadata_file = logger.metadata_filepath
assert logger.session_dir is not None
assert metadata_file == logger.session_dir / "meta.json"
def test_messages_filepath(self, session_config: SessionLoggingConfig) -> None:
"""Test that messages_filepath returns correct path."""
session_id = "test-session-123"
logger = SessionLogger(session_config, session_id)
messages_file = logger.messages_filepath
assert logger.session_dir is not None
assert messages_file == logger.session_dir / "messages.jsonl"
def test_disabled_file_operations_raise_errors(
self, disabled_session_config: SessionLoggingConfig
) -> None:
"""Test that file operations raise errors when logging is disabled."""
logger = SessionLogger(disabled_session_config, "test-session")
with pytest.raises(
RuntimeError,
match="Cannot get session save folder when logging is disabled",
):
assert logger.save_folder is None
with pytest.raises(
RuntimeError,
match="Cannot get session metadata filepath when logging is disabled",
):
assert logger.metadata_filepath is None
with pytest.raises(
RuntimeError,
match="Cannot get session messages filepath when logging is disabled",
):
assert logger.messages_filepath is None
def create_temp_file_ago(tmp_path: Path, filename: str, minutes_ago: int = 0) -> Path:
"""Create a file with a modification time of `minutes_ago` minutes ago."""
file = tmp_path / filename
file.touch()
old_time = datetime.now() - timedelta(minutes=minutes_ago)
os.utime(file, (old_time.timestamp(), old_time.timestamp()))
return file
class TestSessionLoggerCleanupTmpFiles:
def test_cleanup_tmp_files_disabled(
self, disabled_session_config: SessionLoggingConfig
) -> None:
"""Test that cleanup_tmp_files returns early when logging is disabled."""
logger = SessionLogger(disabled_session_config, "test-session")
logger.cleanup_tmp_files()
def test_cleanup_tmp_files_no_tmp_files(
self, session_config: SessionLoggingConfig
) -> None:
"""Test that cleanup_tmp_files handles no tmp files gracefully."""
session_id = "test-session-123"
logger = SessionLogger(session_config, session_id)
logger.cleanup_tmp_files()
def test_cleanup_tmp_files_deletes_old_files(
self, session_config: SessionLoggingConfig
) -> None:
"""Test that cleanup_tmp_files deletes tmp files older than 5 minutes."""
session_id = "test-session-123"
logger = SessionLogger(session_config, session_id)
assert logger.session_dir is not None
logger.session_dir.mkdir(parents=True, exist_ok=True)
old_tmp_file = create_temp_file_ago(
logger.session_dir, "session-123.json.tmp", 10
)
new_tmp_file = create_temp_file_ago(logger.session_dir, "session-123.json")
logger.cleanup_tmp_files()
assert not old_tmp_file.exists()
assert new_tmp_file.exists()
def test_cleanup_tmp_files_recursive(
self, session_config: SessionLoggingConfig
) -> None:
"""Test that cleanup_tmp_files works recursively in subdirectories."""
session_id = "test-session-123"
logger = SessionLogger(session_config, session_id)
assert logger.session_dir is not None
logger.session_dir.mkdir(parents=True, exist_ok=True)
subdir_1 = logger.session_dir / "session-123"
subdir_1.mkdir()
old_tmp_file = create_temp_file_ago(subdir_1, "meta.json.tmp", 10)
new_tmp_file = create_temp_file_ago(subdir_1, "meta.json")
subdir_2 = logger.session_dir / "session-456"
subdir_2.mkdir()
old_tmp_file_2 = create_temp_file_ago(subdir_2, "meta.json.tmp", 10)
logger.cleanup_tmp_files()
assert not old_tmp_file.exists()
assert not old_tmp_file_2.exists()
assert new_tmp_file.exists()
def test_cleanup_tmp_files_handles_exceptions(
self, session_config: SessionLoggingConfig
) -> None:
"""Test that cleanup_tmp_files handles exceptions gracefully."""
session_id = "test-session-123"
logger = SessionLogger(session_config, session_id)
assert logger.session_dir is not None
logger.session_dir.mkdir(parents=True, exist_ok=True)
old_tmp_file = create_temp_file_ago(logger.session_dir, "meta.json.tmp", 10)
another_old_tmp_file = create_temp_file_ago(
logger.session_dir, "meta-002.json.tmp", 10
)
# Mock the unlink method to raise an exception for the first file
original_unlink = Path.unlink
def mock_unlink(self):
if str(self) == str(old_tmp_file):
raise OSError("Mocked error")
return original_unlink(self)
with patch.object(Path, "unlink", mock_unlink):
logger.cleanup_tmp_files()
assert old_tmp_file.exists()
assert not another_old_tmp_file.exists()

View file

@ -0,0 +1,177 @@
from __future__ import annotations
import json
from pathlib import Path
import pytest
from vibe.core.config import SessionLoggingConfig
from vibe.core.session.session_migration import migrate_sessions
@pytest.fixture
def temp_session_dir(tmp_path: Path) -> Path:
session_dir = tmp_path / "sessions"
session_dir.mkdir()
return session_dir
@pytest.fixture
def session_config(temp_session_dir: Path) -> SessionLoggingConfig:
return SessionLoggingConfig(
save_dir=str(temp_session_dir), session_prefix="test", enabled=True
)
@pytest.fixture
def disabled_session_config() -> SessionLoggingConfig:
return SessionLoggingConfig(
save_dir="/tmp/test", session_prefix="test", enabled=False
)
@pytest.fixture
def old_session_data() -> dict:
return {
"metadata": {
"session_id": "test-session-123",
"start_time": "2023-01-01T00:00:00",
"end_time": "2023-01-01T01:00:00",
"git_commit": "abc123",
"git_branch": "main",
"username": "testuser",
"environment": {"working_directory": "/test"},
},
"messages": [
{"role": "system", "content": "System prompt"},
{"role": "user", "content": "Hello"},
{"role": "assistant", "content": "Hi there!"},
],
}
class TestSessionMigration:
@pytest.mark.asyncio
async def test_migrate_sessions_disabled_config(
self, disabled_session_config: SessionLoggingConfig
) -> None:
"""Test that migration does nothing when config is disabled."""
result = await migrate_sessions(disabled_session_config)
assert result == 0
@pytest.mark.asyncio
async def test_migrate_sessions_no_save_dir(
self, session_config: SessionLoggingConfig
) -> None:
"""Test that migration handles missing save_dir gracefully."""
config = SessionLoggingConfig(save_dir="", session_prefix="test", enabled=True)
result = await migrate_sessions(config)
assert result == 0
@pytest.mark.asyncio
async def test_migrate_sessions_no_old_files(
self, session_config: SessionLoggingConfig
) -> None:
"""Test that migration handles no old session files gracefully."""
session_dir = Path(session_config.save_dir)
session_dir.mkdir(exist_ok=True)
result = await migrate_sessions(session_config)
assert result == 0
@pytest.mark.asyncio
async def test_migrate_sessions_successful_migration(
self, session_config: SessionLoggingConfig, old_session_data: dict
) -> None:
"""Test successful migration of old session files."""
session_dir = Path(session_config.save_dir)
old_session_file = session_dir / "test_session-123.json"
with open(old_session_file, "w") as f:
json.dump(old_session_data, f)
result = await migrate_sessions(session_config)
assert result == 1
assert not old_session_file.exists()
session_subdir = session_dir / "test_session-123"
assert session_subdir.is_dir()
metadata_file = session_subdir / "meta.json"
assert metadata_file.is_file()
with open(metadata_file) as f:
metadata = json.load(f)
assert metadata == old_session_data["metadata"]
messages_file = session_subdir / "messages.jsonl"
assert messages_file.exists()
with open(messages_file) as f:
lines = f.readlines()
assert len(lines) == len(old_session_data["messages"])
for i, line in enumerate(lines):
message = json.loads(line.strip())
assert message == old_session_data["messages"][i]
@pytest.mark.asyncio
async def test_migrate_sessions_multiple_files(
self, session_config: SessionLoggingConfig, old_session_data: dict
) -> None:
"""Test migration of multiple old session files."""
session_dir = Path(session_config.save_dir)
session_files = []
for i in range(3):
session_file = session_dir / f"test_session-{i:03d}.json"
session_files.append(session_file)
modified_data = old_session_data.copy()
modified_data["metadata"]["session_id"] = f"test-session-{i}"
with open(session_file, "w") as f:
json.dump(modified_data, f)
result = await migrate_sessions(session_config)
assert result == 3
for session_file in session_files:
assert not session_file.exists()
for i in range(3):
session_subdir = session_dir / f"test_session-{i:03d}"
assert session_subdir.exists()
assert session_subdir.is_dir()
metadata_file = session_subdir / "meta.json"
messages_file = session_subdir / "messages.jsonl"
assert metadata_file.exists()
assert messages_file.exists()
@pytest.mark.asyncio
async def test_migrate_sessions_error_handling(
self, session_config: SessionLoggingConfig
) -> None:
"""Test that migration handles errors gracefully and continues."""
session_dir = Path(session_config.save_dir)
valid_session_file = session_dir / "test_session-valid.json"
valid_data = {
"metadata": {"session_id": "valid-session"},
"messages": [{"role": "user", "content": "Hello"}],
}
with open(valid_session_file, "w") as f:
json.dump(valid_data, f)
invalid_session_file = session_dir / "test_session-invalid.json"
with open(invalid_session_file, "w") as f:
f.write("{invalid json}")
result = await migrate_sessions(session_config)
assert result == 1
valid_session_subdir = session_dir / "test_session-valid"
assert valid_session_subdir.exists()
assert not valid_session_file.exists()
assert invalid_session_file.exists()