* refactor: re-architect tests to mirror the codebase
* Update tests.yml
* fix: add missing tool_error imports after registry refactor
* fix(tests): replace patch.dict with monkeypatch to prevent env var leaks under xdist
patch.dict(os.environ) can leak TERMINAL_ENV across xdist workers,
causing test_code_execution tests to hit the Modal remote path.
* fix(tests): fix update_check and telegram xdist failures
- test_update_check: replace patch("hermes_cli.banner.os.getenv") with
monkeypatch.setenv("HERMES_HOME") — banner.py no longer imports os
directly, it uses get_hermes_home() from hermes_constants.
- test_telegram_conflict/approval_buttons: provide real exception classes
for telegram.error mock (NetworkError, TimedOut, BadRequest) so the
except clause in connect() doesn't fail with "catching classes that do
not inherit from BaseException" when xdist pollutes sys.modules.
* fix(tests): accept unavailable_models kwarg in _prompt_model_selection mock
199 lines
7.3 KiB
Python
199 lines
7.3 KiB
Python
"""Tests for the /branch (/fork) command — session branching.
|
|
|
|
Verifies that:
|
|
- Branching creates a new session with copied conversation history
|
|
- The original session is preserved (ended with "branched" reason)
|
|
- Auto-generated titles use lineage numbering
|
|
- Custom branch names are used when provided
|
|
- parent_session_id links are set correctly
|
|
- Edge cases: empty conversation, missing session DB
|
|
"""
|
|
|
|
import os
|
|
import uuid
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from unittest.mock import MagicMock, patch, PropertyMock
|
|
|
|
import pytest
|
|
|
|
|
|
@pytest.fixture
|
|
def session_db(tmp_path):
|
|
"""Create a real SessionDB for testing."""
|
|
os.environ["HERMES_HOME"] = str(tmp_path / ".hermes")
|
|
os.makedirs(tmp_path / ".hermes", exist_ok=True)
|
|
from hermes_state import SessionDB
|
|
db = SessionDB(db_path=tmp_path / ".hermes" / "test_sessions.db")
|
|
yield db
|
|
db.close()
|
|
|
|
|
|
@pytest.fixture
|
|
def cli_instance(tmp_path, session_db):
|
|
"""Create a minimal HermesCLI-like object for testing _handle_branch_command."""
|
|
# We'll mock the CLI enough to test the branch logic without full init
|
|
from unittest.mock import MagicMock
|
|
|
|
cli = MagicMock()
|
|
cli._session_db = session_db
|
|
cli.session_id = "20260403_120000_abc123"
|
|
cli.model = "anthropic/claude-sonnet-4.6"
|
|
cli.max_turns = 90
|
|
cli.reasoning_config = {"enabled": True, "effort": "medium"}
|
|
cli.session_start = datetime.now()
|
|
cli._pending_title = None
|
|
cli._resumed = False
|
|
cli.agent = None
|
|
cli.conversation_history = [
|
|
{"role": "user", "content": "Hello, can you help me?"},
|
|
{"role": "assistant", "content": "Of course! How can I help?"},
|
|
{"role": "user", "content": "Write a Python function to sort a list."},
|
|
{"role": "assistant", "content": "def sort_list(lst): return sorted(lst)"},
|
|
]
|
|
|
|
# Create the original session in the DB
|
|
session_db.create_session(
|
|
session_id=cli.session_id,
|
|
source="cli",
|
|
model=cli.model,
|
|
)
|
|
session_db.set_session_title(cli.session_id, "My Coding Session")
|
|
|
|
return cli
|
|
|
|
|
|
class TestBranchCommandCLI:
|
|
"""Test the /branch command logic for the CLI."""
|
|
|
|
def test_branch_creates_new_session(self, cli_instance, session_db):
|
|
"""Branching should create a new session in the DB."""
|
|
from cli import HermesCLI
|
|
|
|
# Call the real method on the mock, using the real implementation
|
|
HermesCLI._handle_branch_command(cli_instance, "/branch")
|
|
|
|
# Verify a new session was created
|
|
assert cli_instance.session_id != "20260403_120000_abc123"
|
|
new_session = session_db.get_session(cli_instance.session_id)
|
|
assert new_session is not None
|
|
|
|
def test_branch_copies_history(self, cli_instance, session_db):
|
|
"""Branching should copy all messages to the new session."""
|
|
from cli import HermesCLI
|
|
|
|
HermesCLI._handle_branch_command(cli_instance, "/branch")
|
|
|
|
messages = session_db.get_messages_as_conversation(cli_instance.session_id)
|
|
assert len(messages) == 4 # All 4 messages copied
|
|
|
|
def test_branch_preserves_parent_link(self, cli_instance, session_db):
|
|
"""The new session should reference the original as parent."""
|
|
from cli import HermesCLI
|
|
original_id = cli_instance.session_id
|
|
|
|
HermesCLI._handle_branch_command(cli_instance, "/branch")
|
|
|
|
new_session = session_db.get_session(cli_instance.session_id)
|
|
assert new_session["parent_session_id"] == original_id
|
|
|
|
def test_branch_ends_original_session(self, cli_instance, session_db):
|
|
"""The original session should be marked as ended with 'branched' reason."""
|
|
from cli import HermesCLI
|
|
original_id = cli_instance.session_id
|
|
|
|
HermesCLI._handle_branch_command(cli_instance, "/branch")
|
|
|
|
original = session_db.get_session(original_id)
|
|
assert original["end_reason"] == "branched"
|
|
|
|
def test_branch_with_custom_name(self, cli_instance, session_db):
|
|
"""Custom branch name should be used as the title."""
|
|
from cli import HermesCLI
|
|
|
|
HermesCLI._handle_branch_command(cli_instance, "/branch refactor approach")
|
|
|
|
title = session_db.get_session_title(cli_instance.session_id)
|
|
assert title == "refactor approach"
|
|
|
|
def test_branch_auto_title_lineage(self, cli_instance, session_db):
|
|
"""Without a name, branch should auto-generate a title from the parent's title."""
|
|
from cli import HermesCLI
|
|
|
|
HermesCLI._handle_branch_command(cli_instance, "/branch")
|
|
|
|
title = session_db.get_session_title(cli_instance.session_id)
|
|
assert title == "My Coding Session #2"
|
|
|
|
def test_branch_empty_conversation(self, cli_instance, session_db):
|
|
"""Branching with no history should show an error."""
|
|
from cli import HermesCLI
|
|
cli_instance.conversation_history = []
|
|
|
|
HermesCLI._handle_branch_command(cli_instance, "/branch")
|
|
|
|
# session_id should not have changed
|
|
assert cli_instance.session_id == "20260403_120000_abc123"
|
|
|
|
def test_branch_no_session_db(self, cli_instance):
|
|
"""Branching without a session DB should show an error."""
|
|
from cli import HermesCLI
|
|
cli_instance._session_db = None
|
|
|
|
HermesCLI._handle_branch_command(cli_instance, "/branch")
|
|
|
|
# session_id should not have changed
|
|
assert cli_instance.session_id == "20260403_120000_abc123"
|
|
|
|
def test_branch_syncs_agent(self, cli_instance, session_db):
|
|
"""If an agent is active, branch should sync it to the new session."""
|
|
from cli import HermesCLI
|
|
|
|
agent = MagicMock()
|
|
agent._last_flushed_db_idx = 0
|
|
cli_instance.agent = agent
|
|
|
|
HermesCLI._handle_branch_command(cli_instance, "/branch")
|
|
|
|
# Agent should have been updated
|
|
assert agent.session_id == cli_instance.session_id
|
|
assert agent.reset_session_state.called
|
|
assert agent._last_flushed_db_idx == 4 # len(conversation_history)
|
|
|
|
def test_branch_sets_resumed_flag(self, cli_instance, session_db):
|
|
"""Branch should set _resumed=True to prevent auto-title generation."""
|
|
from cli import HermesCLI
|
|
|
|
HermesCLI._handle_branch_command(cli_instance, "/branch")
|
|
|
|
assert cli_instance._resumed is True
|
|
|
|
def test_fork_alias(self):
|
|
"""The /fork alias should resolve to 'branch'."""
|
|
from hermes_cli.commands import resolve_command
|
|
result = resolve_command("fork")
|
|
assert result is not None
|
|
assert result.name == "branch"
|
|
|
|
|
|
class TestBranchCommandDef:
|
|
"""Test the CommandDef registration for /branch."""
|
|
|
|
def test_branch_in_registry(self):
|
|
"""The branch command should be in the command registry."""
|
|
from hermes_cli.commands import COMMAND_REGISTRY
|
|
names = [c.name for c in COMMAND_REGISTRY]
|
|
assert "branch" in names
|
|
|
|
def test_branch_has_fork_alias(self):
|
|
"""The branch command should have 'fork' as an alias."""
|
|
from hermes_cli.commands import COMMAND_REGISTRY
|
|
branch = next(c for c in COMMAND_REGISTRY if c.name == "branch")
|
|
assert "fork" in branch.aliases
|
|
|
|
def test_branch_in_session_category(self):
|
|
"""The branch command should be in the Session category."""
|
|
from hermes_cli.commands import COMMAND_REGISTRY
|
|
branch = next(c for c in COMMAND_REGISTRY if c.name == "branch")
|
|
assert branch.category == "Session"
|